fix(editor): Fix '=' handling in expressions (#13129)

This commit is contained in:
Elias Meire 2025-02-10 16:41:55 +01:00 committed by GitHub
parent f057cfb46a
commit 8f25a06e6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 200 additions and 207 deletions

View file

@ -1,3 +1,4 @@
import { EDIT_FIELDS_SET_NODE_NAME } from '../constants';
import { NDV } from '../pages/ndv';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
@ -24,6 +25,25 @@ describe('Inline expression editor', () => {
ndv.getters.outputPanel().click();
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', () => {

View file

@ -49,7 +49,8 @@ describe('Editors', () => {
ndv.getters
.sqlEditorContainer()
.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
.inlineExpressionEditorOutput()
.should('have.text', 'SELECT * FROM test_table');

View file

@ -204,7 +204,7 @@ export class NDV extends BasePage {
typeIntoParameterInput: (
parameterName: string,
content: string,
opts?: { parseSpecialCharSequences: boolean },
opts?: Partial<Cypress.TypeOptions>,
) => {
this.getters.parameterInput(parameterName).type(content, opts);
},

View file

@ -4,7 +4,6 @@ import { Prec } from '@codemirror/state';
import { dropCursor, EditorView, keymap } from '@codemirror/view';
import { computed, onMounted, ref, watch } from 'vue';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { forceParse } from '@/utils/forceParse';
import { inputTheme } from './theme';
@ -15,6 +14,7 @@ import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { expressionCloseBrackets } from '@/plugins/codemirror/expressionCloseBrackets';
type Props = {
modelValue: string;
@ -41,7 +41,7 @@ const extensions = computed(() => [
mappingDropCursor(),
dropCursor(),
history(),
expressionInputHandler(),
expressionCloseBrackets(),
EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
infoBoxTooltips(),

View file

@ -25,7 +25,6 @@ import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { htmlEditorEventBus } from '@/event-bus';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
@ -33,6 +32,10 @@ import { codeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types';
import { nonTakenRanges } from './utils';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import {
expressionCloseBrackets,
expressionCloseBracketsConfig,
} from '@/plugins/codemirror/expressionCloseBrackets';
type Props = {
modelValue: string;
@ -56,12 +59,12 @@ const editorValue = ref<string>(props.modelValue);
const extensions = computed(() => [
bracketMatching(),
n8nAutocompletion(),
new LanguageSupport(
htmlLanguage,
new LanguageSupport(htmlLanguage, [
htmlLanguage.data.of({ closeBrackets: expressionCloseBracketsConfig }),
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
),
]),
autoCloseTags,
expressionInputHandler(),
expressionCloseBrackets(),
Prec.highest(keymap.of(editorKeymap)),
indentOnInput(),
codeEditorTheme({

View file

@ -6,14 +6,14 @@ import { computed, ref, watch } from 'vue';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions';
import type { IDataObject } from 'n8n-workflow';
import { inputTheme } from './theme';
import { onKeyStroke } from '@vueuse/core';
import { expressionCloseBrackets } from '@/plugins/codemirror/expressionCloseBrackets';
type Props = {
modelValue: string;
@ -44,11 +44,20 @@ const extensions = computed(() => [
history(),
mappingDropCursor(),
dropCursor(),
expressionInputHandler(),
expressionCloseBrackets(),
EditorView.lineWrapping,
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 {
editor: editorRef,
@ -67,13 +76,6 @@ const {
additionalData: props.additionalData,
});
watch(
() => props.modelValue,
(newValue) => {
editorValue.value = removeExpressionPrefix(newValue);
},
);
watch(segments.display, (newSegments) => {
emit('update:model-value', {
value: '=' + readEditorValue(),

View file

@ -1067,11 +1067,11 @@ watch(remoteParameterOptionsLoading, () => {
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) => {
if (!props.isReadOnly && isExpression && !wasExpression) {
if (!props.isReadOnly && isExpression !== wasExpression) {
await nextTick();
inputField.value?.focus();
await setFocus();
}
});

View file

@ -4,7 +4,6 @@ import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { codeNodeEditorEventBus } from '@/event-bus';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { ifNotIn } from '@codemirror/autocomplete';
@ -33,6 +32,10 @@ import {
import { onClickOutside } from '@vueuse/core';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
import {
expressionCloseBrackets,
expressionCloseBracketsConfig,
} from '@/plugins/codemirror/expressionCloseBrackets';
const SQL_DIALECTS = {
StandardSQL,
@ -72,6 +75,7 @@ const extensions = computed(() => {
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
function sqlWithN8nLanguageSupport() {
return new LanguageSupport(dialect.language, [
dialect.language.data.of({ closeBrackets: expressionCloseBracketsConfig }),
dialect.language.data.of({
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
}),
@ -81,7 +85,7 @@ const extensions = computed(() => {
const baseExtensions = [
sqlWithN8nLanguageSupport(),
expressionInputHandler(),
expressionCloseBrackets(),
codeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh',

View file

@ -2,7 +2,12 @@ import { codeEditorTheme } from '@/components/CodeNodeEditor/theme';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { useTypescript } from '@/plugins/codemirror/typescript/client/useTypescript';
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 { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
@ -290,6 +295,7 @@ export const useCodeEditor = <L extends CodeEditorLanguage>({
},
}),
keymap.of(editorKeymap),
keymap.of(closeBracketsKeymap),
];
const parsedStoredState = jsonParse<IDataObject | null>(

View file

@ -1,10 +1,13 @@
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
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 { 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 type { MockInstance } from 'vitest';
import { autocompletableNodeNames, expressionWithFirstItem, stripExcessParens } from './utils';
vi.mock('@/composables/useWorkflowHelpers', () => ({
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('expressionWithFirstItem', () => {
it('should replace $input.item', () => {
@ -122,4 +141,41 @@ describe('completion utils', () => {
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);
});
});
});

View file

@ -15,7 +15,7 @@ import {
type CompletionSection,
} from '@codemirror/autocomplete';
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 { useRouter } from 'vue-router';
import type { DocMetadata } from 'n8n-workflow';
@ -229,9 +229,27 @@ export function getPreviousNodes(nodeName: string) {
.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
* 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) => {
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);
}
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;
};

View file

@ -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),
];

View file

@ -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];

View file

@ -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];

View file

@ -1,17 +1,20 @@
import { parserWithMetaData as n8nParser } from '@n8n/codemirror-lang';
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 { n8nCompletionSources } from './completions/addCompletions';
import { autocompletion } from '@codemirror/autocomplete';
import { expressionCloseBracketsConfig } from './expressionCloseBrackets';
const isResolvable = (node: SyntaxNodeRef) => node.type.name === 'Resolvable';
const n8nParserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {
if (node.type.isTop) return null;
return node.name === 'Resolvable'
? { parser: javascriptLanguage.parser, overlay: (node) => node.type.name === 'Resolvable' }
? { parser: javascriptLanguage.parser, overlay: isResolvable }
: null;
}),
});
@ -20,7 +23,7 @@ const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser });
export function n8nLang() {
return new LanguageSupport(n8nLanguage, [
n8nLanguage.data.of({ closeBrackets: { brackets: ['{', '('] } }),
n8nLanguage.data.of(expressionCloseBracketsConfig),
...n8nCompletionSources().map((source) => n8nLanguage.data.of(source)),
]);
}