mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Fix '=' handling in expressions (#13129)
This commit is contained in:
parent
f057cfb46a
commit
8f25a06e6c
|
@ -1,3 +1,4 @@
|
||||||
|
import { EDIT_FIELDS_SET_NODE_NAME } from '../constants';
|
||||||
import { NDV } from '../pages/ndv';
|
import { NDV } from '../pages/ndv';
|
||||||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||||
|
|
||||||
|
@ -24,6 +25,25 @@ describe('Inline expression editor', () => {
|
||||||
ndv.getters.outputPanel().click();
|
ndv.getters.outputPanel().click();
|
||||||
WorkflowPage.getters.inlineExpressionEditorOutput().should('not.exist');
|
WorkflowPage.getters.inlineExpressionEditorOutput().should('not.exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should switch between expression and fixed using keyboard', () => {
|
||||||
|
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||||
|
WorkflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||||
|
|
||||||
|
// Should switch to expression with =
|
||||||
|
ndv.getters.assignmentCollectionAdd('assignments').click();
|
||||||
|
ndv.actions.typeIntoParameterInput('value', '=');
|
||||||
|
|
||||||
|
// Should complete {{ --> {{ | }}
|
||||||
|
WorkflowPage.getters.inlineExpressionEditorInput().click().type('{{');
|
||||||
|
WorkflowPage.getters.inlineExpressionEditorInput().should('have.text', '{{ }}');
|
||||||
|
|
||||||
|
// Should switch back to fixed with backspace on empty expression
|
||||||
|
ndv.actions.typeIntoParameterInput('value', '{selectall}{backspace}');
|
||||||
|
ndv.getters.parameterInput('value').click();
|
||||||
|
ndv.actions.typeIntoParameterInput('value', '{backspace}');
|
||||||
|
ndv.getters.inlineExpressionEditorInput().should('not.exist');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Static data', () => {
|
describe('Static data', () => {
|
||||||
|
|
|
@ -49,7 +49,8 @@ describe('Editors', () => {
|
||||||
ndv.getters
|
ndv.getters
|
||||||
.sqlEditorContainer()
|
.sqlEditorContainer()
|
||||||
.find('.cm-content')
|
.find('.cm-content')
|
||||||
.type('SELECT * FROM {{ $json.table }}', { parseSpecialCharSequences: false });
|
// }} is inserted automatically by bracket matching
|
||||||
|
.type('SELECT * FROM {{ $json.table', { parseSpecialCharSequences: false });
|
||||||
workflowPage.getters
|
workflowPage.getters
|
||||||
.inlineExpressionEditorOutput()
|
.inlineExpressionEditorOutput()
|
||||||
.should('have.text', 'SELECT * FROM test_table');
|
.should('have.text', 'SELECT * FROM test_table');
|
||||||
|
|
|
@ -204,7 +204,7 @@ export class NDV extends BasePage {
|
||||||
typeIntoParameterInput: (
|
typeIntoParameterInput: (
|
||||||
parameterName: string,
|
parameterName: string,
|
||||||
content: string,
|
content: string,
|
||||||
opts?: { parseSpecialCharSequences: boolean },
|
opts?: Partial<Cypress.TypeOptions>,
|
||||||
) => {
|
) => {
|
||||||
this.getters.parameterInput(parameterName).type(content, opts);
|
this.getters.parameterInput(parameterName).type(content, opts);
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { Prec } from '@codemirror/state';
|
||||||
import { dropCursor, EditorView, keymap } from '@codemirror/view';
|
import { dropCursor, EditorView, keymap } from '@codemirror/view';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
|
||||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
import { forceParse } from '@/utils/forceParse';
|
import { forceParse } from '@/utils/forceParse';
|
||||||
import { inputTheme } from './theme';
|
import { inputTheme } from './theme';
|
||||||
|
@ -15,6 +14,7 @@ import type { Segment } from '@/types/expressions';
|
||||||
import { removeExpressionPrefix } from '@/utils/expressions';
|
import { removeExpressionPrefix } from '@/utils/expressions';
|
||||||
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
|
import { expressionCloseBrackets } from '@/plugins/codemirror/expressionCloseBrackets';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
|
@ -41,7 +41,7 @@ const extensions = computed(() => [
|
||||||
mappingDropCursor(),
|
mappingDropCursor(),
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
history(),
|
history(),
|
||||||
expressionInputHandler(),
|
expressionCloseBrackets(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
|
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
|
||||||
infoBoxTooltips(),
|
infoBoxTooltips(),
|
||||||
|
|
|
@ -25,7 +25,6 @@ import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from
|
||||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import { htmlEditorEventBus } from '@/event-bus';
|
import { htmlEditorEventBus } from '@/event-bus';
|
||||||
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
|
||||||
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||||
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
|
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
|
||||||
|
@ -33,6 +32,10 @@ import { codeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
import type { Range, Section } from './types';
|
import type { Range, Section } from './types';
|
||||||
import { nonTakenRanges } from './utils';
|
import { nonTakenRanges } from './utils';
|
||||||
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
import {
|
||||||
|
expressionCloseBrackets,
|
||||||
|
expressionCloseBracketsConfig,
|
||||||
|
} from '@/plugins/codemirror/expressionCloseBrackets';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
|
@ -56,12 +59,12 @@ const editorValue = ref<string>(props.modelValue);
|
||||||
const extensions = computed(() => [
|
const extensions = computed(() => [
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
new LanguageSupport(
|
new LanguageSupport(htmlLanguage, [
|
||||||
htmlLanguage,
|
htmlLanguage.data.of({ closeBrackets: expressionCloseBracketsConfig }),
|
||||||
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
|
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
|
||||||
),
|
]),
|
||||||
autoCloseTags,
|
autoCloseTags,
|
||||||
expressionInputHandler(),
|
expressionCloseBrackets(),
|
||||||
Prec.highest(keymap.of(editorKeymap)),
|
Prec.highest(keymap.of(editorKeymap)),
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
codeEditorTheme({
|
codeEditorTheme({
|
||||||
|
|
|
@ -6,14 +6,14 @@ import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
|
||||||
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
import { removeExpressionPrefix } from '@/utils/expressions';
|
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
import { inputTheme } from './theme';
|
import { inputTheme } from './theme';
|
||||||
|
import { onKeyStroke } from '@vueuse/core';
|
||||||
|
import { expressionCloseBrackets } from '@/plugins/codemirror/expressionCloseBrackets';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
|
@ -44,11 +44,20 @@ const extensions = computed(() => [
|
||||||
history(),
|
history(),
|
||||||
mappingDropCursor(),
|
mappingDropCursor(),
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
expressionInputHandler(),
|
expressionCloseBrackets(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
infoBoxTooltips(),
|
infoBoxTooltips(),
|
||||||
]);
|
]);
|
||||||
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
const editorValue = computed(() => props.modelValue);
|
||||||
|
|
||||||
|
// Exit expression editor when pressing Backspace in empty field
|
||||||
|
onKeyStroke(
|
||||||
|
'Backspace',
|
||||||
|
() => {
|
||||||
|
if (props.modelValue === '') emit('update:model-value', { value: '', segments: [] });
|
||||||
|
},
|
||||||
|
{ target: root },
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
editor: editorRef,
|
editor: editorRef,
|
||||||
|
@ -67,13 +76,6 @@ const {
|
||||||
additionalData: props.additionalData,
|
additionalData: props.additionalData,
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(newValue) => {
|
|
||||||
editorValue.value = removeExpressionPrefix(newValue);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(segments.display, (newSegments) => {
|
watch(segments.display, (newSegments) => {
|
||||||
emit('update:model-value', {
|
emit('update:model-value', {
|
||||||
value: '=' + readEditorValue(),
|
value: '=' + readEditorValue(),
|
||||||
|
|
|
@ -1067,11 +1067,11 @@ watch(remoteParameterOptionsLoading, () => {
|
||||||
tempValue.value = displayValue.value as string;
|
tempValue.value = displayValue.value as string;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus input field when changing from fixed value to expression
|
// Focus input field when changing between fixed and expression
|
||||||
watch(isModelValueExpression, async (isExpression, wasExpression) => {
|
watch(isModelValueExpression, async (isExpression, wasExpression) => {
|
||||||
if (!props.isReadOnly && isExpression && !wasExpression) {
|
if (!props.isReadOnly && isExpression !== wasExpression) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
inputField.value?.focus();
|
await setFocus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||||
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
||||||
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
|
||||||
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||||
import { ifNotIn } from '@codemirror/autocomplete';
|
import { ifNotIn } from '@codemirror/autocomplete';
|
||||||
|
@ -33,6 +32,10 @@ import {
|
||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||||
import { codeEditorTheme } from '../CodeNodeEditor/theme';
|
import { codeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
|
import {
|
||||||
|
expressionCloseBrackets,
|
||||||
|
expressionCloseBracketsConfig,
|
||||||
|
} from '@/plugins/codemirror/expressionCloseBrackets';
|
||||||
|
|
||||||
const SQL_DIALECTS = {
|
const SQL_DIALECTS = {
|
||||||
StandardSQL,
|
StandardSQL,
|
||||||
|
@ -72,6 +75,7 @@ const extensions = computed(() => {
|
||||||
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
|
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
|
||||||
function sqlWithN8nLanguageSupport() {
|
function sqlWithN8nLanguageSupport() {
|
||||||
return new LanguageSupport(dialect.language, [
|
return new LanguageSupport(dialect.language, [
|
||||||
|
dialect.language.data.of({ closeBrackets: expressionCloseBracketsConfig }),
|
||||||
dialect.language.data.of({
|
dialect.language.data.of({
|
||||||
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
|
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
|
||||||
}),
|
}),
|
||||||
|
@ -81,7 +85,7 @@ const extensions = computed(() => {
|
||||||
|
|
||||||
const baseExtensions = [
|
const baseExtensions = [
|
||||||
sqlWithN8nLanguageSupport(),
|
sqlWithN8nLanguageSupport(),
|
||||||
expressionInputHandler(),
|
expressionCloseBrackets(),
|
||||||
codeEditorTheme({
|
codeEditorTheme({
|
||||||
isReadOnly: props.isReadOnly,
|
isReadOnly: props.isReadOnly,
|
||||||
maxHeight: props.fullscreen ? '100%' : '40vh',
|
maxHeight: props.fullscreen ? '100%' : '40vh',
|
||||||
|
|
|
@ -2,7 +2,12 @@ import { codeEditorTheme } from '@/components/CodeNodeEditor/theme';
|
||||||
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
import { editorKeymap } from '@/plugins/codemirror/keymap';
|
||||||
import { useTypescript } from '@/plugins/codemirror/typescript/client/useTypescript';
|
import { useTypescript } from '@/plugins/codemirror/typescript/client/useTypescript';
|
||||||
import { closeCursorInfoBox } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
import { closeCursorInfoBox } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||||
import { closeBrackets, closeCompletion, completionStatus } from '@codemirror/autocomplete';
|
import {
|
||||||
|
closeBrackets,
|
||||||
|
closeBracketsKeymap,
|
||||||
|
closeCompletion,
|
||||||
|
completionStatus,
|
||||||
|
} from '@codemirror/autocomplete';
|
||||||
import { history, historyField } from '@codemirror/commands';
|
import { history, historyField } from '@codemirror/commands';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { json } from '@codemirror/lang-json';
|
import { json } from '@codemirror/lang-json';
|
||||||
|
@ -290,6 +295,7 @@ export const useCodeEditor = <L extends CodeEditorLanguage>({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
keymap.of(editorKeymap),
|
keymap.of(editorKeymap),
|
||||||
|
keymap.of(closeBracketsKeymap),
|
||||||
];
|
];
|
||||||
|
|
||||||
const parsedStoredState = jsonParse<IDataObject | null>(
|
const parsedStoredState = jsonParse<IDataObject | null>(
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
|
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
|
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
|
||||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
|
||||||
import { autocompletableNodeNames, expressionWithFirstItem } from './utils';
|
|
||||||
import type { MockInstance } from 'vitest';
|
|
||||||
import * as ndvStore from '@/stores/ndv.store';
|
import * as ndvStore from '@/stores/ndv.store';
|
||||||
|
import { CompletionContext, insertCompletionText } from '@codemirror/autocomplete';
|
||||||
|
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||||
|
import { EditorState } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
import { NodeConnectionType, type IConnections } from 'n8n-workflow';
|
import { NodeConnectionType, type IConnections } from 'n8n-workflow';
|
||||||
|
import type { MockInstance } from 'vitest';
|
||||||
|
import { autocompletableNodeNames, expressionWithFirstItem, stripExcessParens } from './utils';
|
||||||
|
|
||||||
vi.mock('@/composables/useWorkflowHelpers', () => ({
|
vi.mock('@/composables/useWorkflowHelpers', () => ({
|
||||||
useWorkflowHelpers: vi.fn().mockReturnValue({
|
useWorkflowHelpers: vi.fn().mockReturnValue({
|
||||||
|
@ -12,6 +15,22 @@ vi.mock('@/composables/useWorkflowHelpers', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const editorFromString = (docWithCursor: string) => {
|
||||||
|
const cursorPosition = docWithCursor.indexOf('|');
|
||||||
|
|
||||||
|
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc,
|
||||||
|
selection: { anchor: cursorPosition },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: new CompletionContext(state, cursorPosition, false),
|
||||||
|
view: new EditorView({ state, doc }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
describe('completion utils', () => {
|
describe('completion utils', () => {
|
||||||
describe('expressionWithFirstItem', () => {
|
describe('expressionWithFirstItem', () => {
|
||||||
it('should replace $input.item', () => {
|
it('should replace $input.item', () => {
|
||||||
|
@ -122,4 +141,41 @@ describe('completion utils', () => {
|
||||||
expect(autocompletableNodeNames()).toEqual(['Normal Node']);
|
expect(autocompletableNodeNames()).toEqual(['Normal Node']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stripExcessParens', () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
doc: '$(|',
|
||||||
|
completion: { label: "$('Node Name')" },
|
||||||
|
expected: "$('Node Name')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
doc: '$(|)',
|
||||||
|
completion: { label: "$('Node Name')" },
|
||||||
|
expected: "$('Node Name')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
doc: "$('|')",
|
||||||
|
completion: { label: "$('Node Name')" },
|
||||||
|
expected: "$('Node Name')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
doc: "$('No|')",
|
||||||
|
completion: { label: "$('Node Name')" },
|
||||||
|
expected: "$('Node Name')",
|
||||||
|
},
|
||||||
|
])('should complete $doc to $expected', ({ doc, completion, expected }) => {
|
||||||
|
const { context, view } = editorFromString(doc);
|
||||||
|
const result = stripExcessParens(context)(completion);
|
||||||
|
const from = 0;
|
||||||
|
const to = doc.indexOf('|');
|
||||||
|
if (typeof result.apply === 'function') {
|
||||||
|
result.apply(view, completion, from, to);
|
||||||
|
} else {
|
||||||
|
view.dispatch(insertCompletionText(view.state, completion.label, from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(view.state.doc.toString()).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
type CompletionSection,
|
type CompletionSection,
|
||||||
} from '@codemirror/autocomplete';
|
} from '@codemirror/autocomplete';
|
||||||
import type { EditorView } from '@codemirror/view';
|
import type { EditorView } from '@codemirror/view';
|
||||||
import type { TransactionSpec } from '@codemirror/state';
|
import { EditorSelection, type TransactionSpec } from '@codemirror/state';
|
||||||
import type { SyntaxNode, Tree } from '@lezer/common';
|
import type { SyntaxNode, Tree } from '@lezer/common';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import type { DocMetadata } from 'n8n-workflow';
|
import type { DocMetadata } from 'n8n-workflow';
|
||||||
|
@ -229,9 +229,27 @@ export function getPreviousNodes(nodeName: string) {
|
||||||
.filter((name) => name !== nodeName);
|
.filter((name) => name !== nodeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the amount of common chars at the end of the source and the start of the target.
|
||||||
|
* Example: "hello world", "world peace" => 5 ("world" is the overlap)
|
||||||
|
*/
|
||||||
|
function findCommonBoundary(source: string, target: string) {
|
||||||
|
return (
|
||||||
|
[...source]
|
||||||
|
.reverse()
|
||||||
|
.map((_, i) => source.slice(-i - 1))
|
||||||
|
.find((end) => target.startsWith(end))?.length ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClosingChars(input: string): string {
|
||||||
|
const match = input.match(/^['"\])]+/);
|
||||||
|
return match ? match[0] : '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove excess parens from an option label when the cursor is already
|
* Remove excess parens from an option label when the cursor is already
|
||||||
* followed by parens, e.g. `$json.myStr.|()` -> `isNumeric`
|
* followed by parens, e.g. `$json.myStr.|()` -> `isNumeric` or `$(|)` -> `$("Node Name")|`
|
||||||
*/
|
*/
|
||||||
export const stripExcessParens = (context: CompletionContext) => (option: Completion) => {
|
export const stripExcessParens = (context: CompletionContext) => (option: Completion) => {
|
||||||
const followedByParens = context.state.sliceDoc(context.pos, context.pos + 2) === '()';
|
const followedByParens = context.state.sliceDoc(context.pos, context.pos + 2) === '()';
|
||||||
|
@ -240,6 +258,21 @@ export const stripExcessParens = (context: CompletionContext) => (option: Comple
|
||||||
option.label = option.label.slice(0, '()'.length * -1);
|
option.label = option.label.slice(0, '()'.length * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closingChars = getClosingChars(context.state.sliceDoc(context.pos));
|
||||||
|
const commonClosingChars = findCommonBoundary(option.label, closingChars);
|
||||||
|
|
||||||
|
if (commonClosingChars > 0) {
|
||||||
|
option.apply = (view: EditorView, completion: Completion, from: number, to: number): void => {
|
||||||
|
const tx: TransactionSpec = {
|
||||||
|
...insertCompletionText(view.state, option.label.slice(0, -commonClosingChars), from, to),
|
||||||
|
annotations: pickedCompletion.of(completion),
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.selection = EditorSelection.cursor(from + option.label.length);
|
||||||
|
view.dispatch(tx);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return option;
|
return option;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import {
|
||||||
|
closeBrackets,
|
||||||
|
closeBracketsKeymap,
|
||||||
|
type CloseBracketConfig,
|
||||||
|
} from '@codemirror/autocomplete';
|
||||||
|
import { EditorSelection, Text } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
|
|
||||||
|
const expressionBracketSpacing = EditorView.updateListener.of((update) => {
|
||||||
|
if (!update.changes || update.changes.empty) return;
|
||||||
|
|
||||||
|
// {{|}} --> {{ | }}
|
||||||
|
update.changes.iterChanges((_fromA, _toA, fromB, toB, inserted) => {
|
||||||
|
const doc = update.state.doc;
|
||||||
|
if (
|
||||||
|
inserted.eq(Text.of(['{}'])) &&
|
||||||
|
doc.sliceString(fromB - 1, fromB) === '{' &&
|
||||||
|
doc.sliceString(toB, toB + 1) === '}'
|
||||||
|
) {
|
||||||
|
update.view.dispatch({
|
||||||
|
changes: [{ from: fromB + 1, insert: ' ' }],
|
||||||
|
selection: EditorSelection.cursor(toB),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expressionCloseBracketsConfig: CloseBracketConfig = {
|
||||||
|
brackets: ['{', '(', '"', "'", '['],
|
||||||
|
// <> so bracket completion works in HTML tags
|
||||||
|
before: ')]}:;<>\'"',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expressionCloseBrackets = () => [
|
||||||
|
expressionBracketSpacing,
|
||||||
|
closeBrackets(),
|
||||||
|
keymap.of(closeBracketsKeymap),
|
||||||
|
];
|
|
@ -1,50 +0,0 @@
|
||||||
import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete';
|
|
||||||
import type { Extension } from '@codemirror/state';
|
|
||||||
import { codePointAt, codePointSize } from '@codemirror/state';
|
|
||||||
import { EditorView } from '@codemirror/view';
|
|
||||||
|
|
||||||
const handler = EditorView.inputHandler.of((view, from, to, insert) => {
|
|
||||||
if (view.composing || view.state.readOnly) return false;
|
|
||||||
|
|
||||||
// customization: do not autoclose tokens while autocompletion is active
|
|
||||||
if (completionStatus(view.state) !== null) return false;
|
|
||||||
|
|
||||||
const selection = view.state.selection.main;
|
|
||||||
|
|
||||||
// customization: do not autoclose square brackets prior to `.json`
|
|
||||||
if (
|
|
||||||
insert === '[' &&
|
|
||||||
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
insert.length > 2 ||
|
|
||||||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
|
|
||||||
from !== selection.from ||
|
|
||||||
to !== selection.to
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transaction = insertBracket(view.state, insert);
|
|
||||||
|
|
||||||
if (!transaction) return false;
|
|
||||||
|
|
||||||
view.dispatch(transaction);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [_, bracketState] = closeBrackets() as readonly Extension[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CodeMirror plugin for code node editor:
|
|
||||||
*
|
|
||||||
* - prevent token autoclosing during autocompletion
|
|
||||||
* - prevent square bracket autoclosing prior to `.json`
|
|
||||||
*
|
|
||||||
* Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79).
|
|
||||||
*/
|
|
||||||
export const codeInputHandler = () => [handler, bracketState];
|
|
|
@ -1,123 +0,0 @@
|
||||||
import {
|
|
||||||
closeBrackets,
|
|
||||||
completionStatus,
|
|
||||||
insertBracket,
|
|
||||||
startCompletion,
|
|
||||||
} from '@codemirror/autocomplete';
|
|
||||||
import type { Extension } from '@codemirror/state';
|
|
||||||
import { codePointAt, codePointSize } from '@codemirror/state';
|
|
||||||
import { EditorView } from '@codemirror/view';
|
|
||||||
|
|
||||||
const handler = EditorView.inputHandler.of((view, from, to, insert) => {
|
|
||||||
if (view.composing || view.state.readOnly) return false;
|
|
||||||
|
|
||||||
// customization: do not autoclose tokens while autocompletion is active
|
|
||||||
if (completionStatus(view.state) !== null) return false;
|
|
||||||
|
|
||||||
const selection = view.state.selection.main;
|
|
||||||
|
|
||||||
// customization: do not autoclose square brackets prior to `.json`
|
|
||||||
if (
|
|
||||||
insert === '[' &&
|
|
||||||
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
insert.length > 2 ||
|
|
||||||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
|
|
||||||
from !== selection.from ||
|
|
||||||
to !== selection.to
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transaction = insertBracket(view.state, insert);
|
|
||||||
|
|
||||||
if (!transaction) {
|
|
||||||
// customization: brace setup when surrounded by HTML tags: <div></div> -> <div>{| }</div>
|
|
||||||
if (insert === '{') {
|
|
||||||
const cursor = view.state.selection.main.head;
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: cursor, insert: '{ }' },
|
|
||||||
selection: { anchor: cursor + 1 },
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
view.dispatch(transaction);
|
|
||||||
|
|
||||||
// customization: inject whitespace and second brace for brace completion: {| } -> {{ | }}
|
|
||||||
|
|
||||||
const cursor = view.state.selection.main.head;
|
|
||||||
|
|
||||||
const isBraceCompletion =
|
|
||||||
view.state.sliceDoc(cursor - 2, cursor) === '{{' &&
|
|
||||||
view.state.sliceDoc(cursor, cursor + 1) === '}';
|
|
||||||
|
|
||||||
if (isBraceCompletion) {
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: cursor, to: cursor + 2, insert: ' }' },
|
|
||||||
selection: { anchor: cursor + 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
startCompletion(view);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// customization: inject whitespace for brace setup: empty -> {| }
|
|
||||||
|
|
||||||
const isBraceSetup =
|
|
||||||
view.state.sliceDoc(cursor - 1, cursor) === '{' &&
|
|
||||||
view.state.sliceDoc(cursor, cursor + 1) === '}';
|
|
||||||
|
|
||||||
const { head } = view.state.selection.main;
|
|
||||||
|
|
||||||
const isInsideResolvable =
|
|
||||||
view.state.sliceDoc(0, head).includes('{{') &&
|
|
||||||
view.state.sliceDoc(head, view.state.doc.length).includes('}}');
|
|
||||||
|
|
||||||
if (isBraceSetup && !isInsideResolvable) {
|
|
||||||
view.dispatch({ changes: { from: cursor, insert: ' ' } });
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// customization: inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }}
|
|
||||||
|
|
||||||
const [range] = view.state.selection.ranges;
|
|
||||||
|
|
||||||
const isBraceCompletionFromSelection =
|
|
||||||
view.state.sliceDoc(range.from - 2, range.from) === '{{' &&
|
|
||||||
view.state.sliceDoc(range.to, range.to + 2) === '}}';
|
|
||||||
|
|
||||||
if (isBraceCompletionFromSelection) {
|
|
||||||
view.dispatch(
|
|
||||||
{ changes: { from: range.from, insert: ' ' } },
|
|
||||||
{ changes: { from: range.to, insert: ' ' }, selection: { anchor: range.to, head: range.to } },
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [_, bracketState] = closeBrackets() as readonly Extension[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CodeMirror plugin for (inline and modal) expression editor:
|
|
||||||
*
|
|
||||||
* - prevent token autoclosing during autocompletion (exception: `{`),
|
|
||||||
* - prevent square bracket autoclosing prior to `.json`
|
|
||||||
* - inject whitespace and braces for resolvables
|
|
||||||
* - set up braces when surrounded by HTML tags
|
|
||||||
*
|
|
||||||
* Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79).
|
|
||||||
*/
|
|
||||||
export const expressionInputHandler = () => [handler, bracketState];
|
|
|
@ -1,17 +1,20 @@
|
||||||
import { parserWithMetaData as n8nParser } from '@n8n/codemirror-lang';
|
import { parserWithMetaData as n8nParser } from '@n8n/codemirror-lang';
|
||||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||||
import { parseMixed } from '@lezer/common';
|
import { parseMixed, type SyntaxNodeRef } from '@lezer/common';
|
||||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||||
|
|
||||||
import { n8nCompletionSources } from './completions/addCompletions';
|
import { n8nCompletionSources } from './completions/addCompletions';
|
||||||
import { autocompletion } from '@codemirror/autocomplete';
|
import { autocompletion } from '@codemirror/autocomplete';
|
||||||
|
import { expressionCloseBracketsConfig } from './expressionCloseBrackets';
|
||||||
|
|
||||||
|
const isResolvable = (node: SyntaxNodeRef) => node.type.name === 'Resolvable';
|
||||||
|
|
||||||
const n8nParserWithNestedJsParser = n8nParser.configure({
|
const n8nParserWithNestedJsParser = n8nParser.configure({
|
||||||
wrap: parseMixed((node) => {
|
wrap: parseMixed((node) => {
|
||||||
if (node.type.isTop) return null;
|
if (node.type.isTop) return null;
|
||||||
|
|
||||||
return node.name === 'Resolvable'
|
return node.name === 'Resolvable'
|
||||||
? { parser: javascriptLanguage.parser, overlay: (node) => node.type.name === 'Resolvable' }
|
? { parser: javascriptLanguage.parser, overlay: isResolvable }
|
||||||
: null;
|
: null;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -20,7 +23,7 @@ const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser });
|
||||||
|
|
||||||
export function n8nLang() {
|
export function n8nLang() {
|
||||||
return new LanguageSupport(n8nLanguage, [
|
return new LanguageSupport(n8nLanguage, [
|
||||||
n8nLanguage.data.of({ closeBrackets: { brackets: ['{', '('] } }),
|
n8nLanguage.data.of(expressionCloseBracketsConfig),
|
||||||
...n8nCompletionSources().map((source) => n8nLanguage.data.of(source)),
|
...n8nCompletionSources().map((source) => n8nLanguage.data.of(source)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue