feat(editor): Refactor expression editors and mixins to composition API (#8894)

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Elias Meire 2024-03-15 18:40:37 +01:00 committed by GitHub
parent a10120f74e
commit 0c179e4e51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1661 additions and 1126 deletions

View file

@ -19,6 +19,7 @@ describe('Data transformation expressions', () => {
const output = 'monday is TODAY';
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().contains(output);
@ -34,6 +35,7 @@ describe('Data transformation expressions', () => {
const output = 'hello@n8n.io false';
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().contains(output);
@ -49,6 +51,7 @@ describe('Data transformation expressions', () => {
const output = '9.12';
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().contains(output);
@ -64,6 +67,7 @@ describe('Data transformation expressions', () => {
const output = 'hello@n8n.io false';
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().contains(output);
@ -78,6 +82,7 @@ describe('Data transformation expressions', () => {
const output = 'true 3';
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
ndv.actions.execute();
ndv.getters.outputDataContainer().find('[class*=value_]').should('exist');
ndv.getters.outputDataContainer().find('[class*=value_]').should('contain', output);
@ -93,6 +98,7 @@ describe('Data transformation expressions', () => {
const output = '1 3';
ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
ndv.actions.execute();
ndv.getters.outputDataContainer().find('[class*=value_]').should('exist');
ndv.getters.outputDataContainer().find('[class*=value_]').should('contain', output);

View file

@ -39,6 +39,7 @@ export class NDV extends BasePage {
inputTbodyCell: (row: number, col: number) =>
this.getters.inputTableRow(row).find('td').eq(col),
inlineExpressionEditorInput: () => cy.getByTestId('inline-expression-editor-input'),
inlineExpressionEditorOutput: () => cy.getByTestId('inline-expression-editor-output'),
nodeParameters: () => cy.getByTestId('node-parameters'),
parameterInput: (parameterName: string) => cy.getByTestId(`parameter-input-${parameterName}`),
parameterInputIssues: (parameterName: string) =>

View file

@ -2,159 +2,142 @@
<div ref="root" :class="$style.editor" @keydown.stop></div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, Prec } from '@codemirror/state';
<script setup lang="ts">
import { history } from '@codemirror/commands';
import { Prec } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { computed, onMounted, ref, toValue, watch } from 'vue';
import { expressionManager } from '@/mixins/expressionManager';
import { completionManager } from '@/mixins/completionManager';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { inputTheme } from './theme';
import { forceParse } from '@/utils/forceParse';
import { completionStatus } from '@codemirror/autocomplete';
import { inputTheme } from './theme';
import type { IVariableItemSelected } from '@/Interface';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions';
export default defineComponent({
name: 'ExpressionEditorModalInput',
mixins: [expressionManager, completionManager],
props: {
modelValue: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
isReadOnly: {
type: Boolean,
default: false,
},
},
data() {
return {
editor: null as EditorView | null,
};
},
mounted() {
const extensions = [
inputTheme(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...historyKeyMap,
...enterKeyMap,
...autocompleteKeyMap,
{
any: (view, event) => {
if (event.key === 'Escape' && completionStatus(view.state) === null) {
event.stopPropagation();
this.$emit('close');
}
type Props = {
modelValue: string;
path: string;
isReadOnly?: boolean;
};
return false;
},
},
]),
),
n8nLang(),
n8nAutocompletion(),
history(),
expressionInputHandler(),
EditorView.lineWrapping,
EditorView.editable.of(!this.isReadOnly),
EditorState.readOnly.of(this.isReadOnly),
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
EditorView.domEventHandlers({ scroll: forceParse }),
EditorView.updateListener.of((viewUpdate) => {
if (!this.editor) return;
this.completionStatus = completionStatus(viewUpdate.view.state);
if (!viewUpdate.docChanged) return;
this.editorState = this.editor.state;
highlighter.removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvableSegments);
setTimeout(() => {
this.editor?.focus(); // prevent blur on paste
try {
this.trackCompletion(viewUpdate, this.path);
} catch {}
});
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
}),
];
this.editor = new EditorView({
parent: this.$refs.root as HTMLDivElement,
state: EditorState.create({
doc: this.modelValue.startsWith('=') ? this.modelValue.slice(1) : this.modelValue,
extensions,
}),
});
this.editorState = this.editor.state;
this.editor.focus();
highlighter.addColor(this.editor, this.resolvableSegments);
this.editor.dispatch({
selection: { anchor: this.editor.state.doc.length },
});
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
},
beforeUnmount() {
this.editor?.destroy();
},
methods: {
itemSelected({ variable }: IVariableItemSelected) {
if (!this.editor || this.isReadOnly) return;
const OPEN_MARKER = '{{';
const CLOSE_MARKER = '}}';
const { doc, selection } = this.editor.state;
const { head } = selection.main;
const isInsideResolvable =
doc.toString().slice(0, head).includes(OPEN_MARKER) &&
doc.toString().slice(head, doc.length).includes(CLOSE_MARKER);
const insert = isInsideResolvable
? variable
: [OPEN_MARKER, variable, CLOSE_MARKER].join(' ');
this.editor.dispatch({
changes: {
from: head,
insert,
},
});
},
},
const props = withDefaults(defineProps<Props>(), {
isReadOnly: false,
});
const emit = defineEmits<{
(event: 'change', value: { value: string; segments: Segment[] }): void;
(event: 'focus'): void;
(event: 'close'): void;
}>();
const root = ref<HTMLElement>();
const extensions = computed(() => [
inputTheme(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...historyKeyMap,
...enterKeyMap,
...autocompleteKeyMap,
{
any: (view, event) => {
if (event.key === 'Escape' && completionStatus(view.state) === null) {
event.stopPropagation();
emit('close');
}
return false;
},
},
]),
),
n8nLang(),
n8nAutocompletion(),
history(),
expressionInputHandler(),
EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: forceParse }),
]);
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
const {
editor: editorRef,
segments,
readEditorValue,
setCursorPosition,
hasFocus,
focus,
} = useExpressionEditor({
editorRef: root,
editorValue,
extensions,
isReadOnly: props.isReadOnly,
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
});
watch(
() => props.modelValue,
(newValue) => {
editorValue.value = removeExpressionPrefix(newValue);
},
);
watch(segments.display, (newSegments) => {
emit('change', {
value: '=' + readEditorValue(),
segments: newSegments,
});
});
watch(hasFocus, (focused) => {
if (focused) {
emit('focus');
}
});
onMounted(() => {
focus();
});
function itemSelected({ variable }: IVariableItemSelected) {
const editor = toValue(editorRef);
if (!editor || props.isReadOnly) return;
const OPEN_MARKER = '{{';
const CLOSE_MARKER = '}}';
const { selection, doc } = editor.state;
const { head } = selection.main;
const isInsideResolvable =
editor.state.sliceDoc(0, head).includes(OPEN_MARKER) &&
editor.state.sliceDoc(head, doc.length).includes(CLOSE_MARKER);
const insert = isInsideResolvable ? variable : [OPEN_MARKER, variable, CLOSE_MARKER].join(' ');
editor.dispatch({
changes: {
from: head,
insert,
},
});
focus();
setCursorPosition(head + insert.length);
}
defineExpose({ itemSelected });
</script>
<style lang="scss" module>

View file

@ -17,11 +17,10 @@
<InlineExpressionEditorInput
ref="inlineInput"
:model-value="modelValue"
:path="path"
:is-read-only="isReadOnly"
:target-item="hoveringItem"
:rows="rows"
:additional-data="additionalExpressionData"
:path="path"
:event-bus="eventBus"
@focus="onFocus"
@blur="onBlur"
@ -36,7 +35,7 @@
size="xsmall"
:class="$style['expression-editor-modal-opener']"
data-test-id="expander"
@click="$emit('modalOpenerClick')"
@click="$emit('modal-opener-click')"
/>
</div>
<InlineExpressionEditorOutput
@ -62,7 +61,6 @@ import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import type { Segment } from '@/types/expressions';
import type { TargetItem } from '@/Interface';
import type { IDataObject } from 'n8n-workflow';
import { useDebounce } from '@/composables/useDebounce';
import { type EventBus, createEventBus } from 'n8n-design-system/utils';
@ -79,9 +77,11 @@ export default defineComponent({
props: {
path: {
type: String,
required: true,
},
modelValue: {
type: String,
required: true,
},
isReadOnly: {
type: Boolean,
@ -104,6 +104,7 @@ export default defineComponent({
default: () => createEventBus(),
},
},
emits: ['focus', 'blur', 'update:model-value', 'modal-opener-click'],
setup() {
const { callDebounced } = useDebounce();
return { callDebounced };
@ -119,9 +120,6 @@ export default defineComponent({
hoveringItemNumber(): number {
return this.ndvStore.hoveringItemNumber;
},
hoveringItem(): TargetItem | null {
return this.ndvStore.getHoveringItem;
},
isDragging(): boolean {
return this.ndvStore.isDraggableDragging;
},
@ -141,9 +139,9 @@ export default defineComponent({
this.$emit('focus');
},
onBlur(event: FocusEvent | KeyboardEvent) {
onBlur(event?: FocusEvent | KeyboardEvent) {
if (
event.target instanceof Element &&
event?.target instanceof Element &&
Array.from(event.target.classList).some((_class) => _class.includes('resizer'))
) {
return; // prevent blur on resizing
@ -169,16 +167,13 @@ export default defineComponent({
this.$telemetry.track('User closed Expression Editor', telemetryPayload);
}
},
onChange(value: { value: string; segments: Segment[] }) {
void this.callDebounced(this.onChangeDebounced, { debounceTime: 100, trailing: true }, value);
},
onChangeDebounced({ value, segments }: { value: string; segments: Segment[] }) {
onChange({ value, segments }: { value: string; segments: Segment[] }) {
this.segments = segments;
if (this.isDragging) return;
if (value === '=' + this.modelValue) return; // prevent report on change of target item
this.$emit('update:modelValue', value);
this.$emit('update:model-value', value);
},
},
});

View file

@ -5,7 +5,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { history } from '@codemirror/commands';
import {
LanguageSupport,
@ -14,33 +14,25 @@ import {
foldGutter,
indentOnInput,
} from '@codemirror/language';
import type { Extension } from '@codemirror/state';
import { EditorState, Prec } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { Prec } from '@codemirror/state';
import {
EditorView,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
} from '@codemirror/view';
import { autoCloseTags, html, htmlLanguage } from 'codemirror-lang-html-n8n';
import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
import htmlParser from 'prettier/plugins/html';
import cssParser from 'prettier/plugins/postcss';
import { defineComponent } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
import { htmlEditorEventBus } from '@/event-bus';
import { expressionManager } from '@/mixins/expressionManager';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types';
import { nonTakenRanges } from './utils';
import {
autocompleteKeyMap,
enterKeyMap,
@ -48,263 +40,211 @@ import {
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { completionStatus } from '@codemirror/autocomplete';
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types';
import { nonTakenRanges } from './utils';
export default defineComponent({
name: 'HtmlEditor',
mixins: [expressionManager],
props: {
modelValue: {
type: String,
required: true,
},
isReadOnly: {
type: Boolean,
default: false,
},
fillParent: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 4,
},
disableExpressionColoring: {
type: Boolean,
default: false,
},
disableExpressionCompletions: {
type: Boolean,
default: false,
},
},
data() {
return {
editor: null as EditorView | null,
editorState: null as EditorState | null,
};
},
computed: {
doc(): string {
return this.editor.state.doc.toString();
},
type Props = {
modelValue: string;
rows?: number;
isReadOnly?: boolean;
fullscreen?: boolean;
};
extensions(): Extension[] {
function htmlWithCompletions() {
return new LanguageSupport(
htmlLanguage,
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
);
}
const props = withDefaults(defineProps<Props>(), {
rows: 4,
isReadOnly: false,
fullscreen: false,
});
return [
bracketMatching(),
n8nAutocompletion(),
this.disableExpressionCompletions ? html() : htmlWithCompletions(),
autoCloseTags,
expressionInputHandler(),
Prec.highest(
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
indentOnInput(),
codeNodeEditorTheme({
isReadOnly: this.isReadOnly,
maxHeight: this.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: this.rows,
highlightColors: 'html',
}),
lineNumbers(),
highlightActiveLineGutter(),
history(),
foldGutter(),
dropCursor(),
indentOnInput(),
highlightActiveLine(),
EditorView.editable.of(!this.isReadOnly),
EditorState.readOnly.of(this.isReadOnly),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!this.editor) return;
const emit = defineEmits<{
(event: 'update:model-value', value: string): void;
}>();
this.completionStatus = completionStatus(viewUpdate.view.state);
const htmlEditor = ref<HTMLElement>();
const editorValue = ref<string>(props.modelValue);
const extensions = computed(() => [
bracketMatching(),
n8nAutocompletion(),
new LanguageSupport(
htmlLanguage,
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
),
autoCloseTags,
expressionInputHandler(),
Prec.highest(
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
indentOnInput(),
codeNodeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh',
minHeight: '20vh',
rows: props.rows,
highlightColors: 'html',
}),
lineNumbers(),
highlightActiveLineGutter(),
history(),
foldGutter(),
dropCursor(),
indentOnInput(),
highlightActiveLine(),
]);
const {
editor: editorRef,
segments,
readEditorValue,
} = useExpressionEditor({
editorRef: htmlEditor,
editorValue,
extensions,
});
if (!viewUpdate.docChanged) return;
const sections = computed(() => {
const editor = toValue(editorRef);
if (!editor) return [];
const { state } = editor;
// Force segments value update by keeping track of editor state
this.editorState = this.editor.state;
highlighter.removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvableSegments);
const fullTree = ensureSyntaxTree(state, state.doc.length);
this.$emit('update:modelValue', this.editor?.state.doc.toString());
}),
];
},
if (fullTree === null) {
throw new Error('Failed to parse syntax tree');
}
sections(): Section[] {
const { state } = this.editor;
let documentRange: Range = [-1, -1];
const styleRanges: Range[] = [];
const scriptRanges: Range[] = [];
const fullTree = ensureSyntaxTree(this.editor.state, this.doc.length);
if (fullTree === null) {
throw new Error(`Failed to parse syntax tree for: ${this.doc}`);
}
let documentRange: Range = [-1, -1];
const styleRanges: Range[] = [];
const scriptRanges: Range[] = [];
fullTree.cursor().iterate((node) => {
if (node.type.name === 'Document') {
documentRange = [node.from, node.to];
}
if (node.type.name === 'StyleSheet') {
styleRanges.push([node.from - '<style>'.length, node.to + '</style>'.length]);
}
if (node.type.name === 'Script') {
scriptRanges.push([node.from - '<script>'.length, node.to + ('<' + '/script>').length]);
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
}
});
const htmlRanges = nonTakenRanges(documentRange, [...styleRanges, ...scriptRanges]);
const styleSections: Section[] = styleRanges.map(([start, end]) => ({
kind: 'style' as const,
range: [start, end],
content: state.sliceDoc(start, end).replace(/<\/?style>/g, ''),
}));
const scriptSections: Section[] = scriptRanges.map(([start, end]) => ({
kind: 'script' as const,
range: [start, end],
content: state.sliceDoc(start, end).replace(/<\/?script>/g, ''),
}));
const htmlSections: Section[] = htmlRanges.map(([start, end]) => ({
kind: 'html' as const,
range: [start, end] as Range,
content: state.sliceDoc(start, end).replace(/<\/html>/g, ''),
// opening tag may contain attributes, e.g. <html lang="en">
}));
return [...styleSections, ...scriptSections, ...htmlSections].sort(
(a, b) => a.range[0] - b.range[0],
);
},
},
mounted() {
htmlEditorEventBus.on('format-html', this.format);
let doc = this.modelValue;
if (this.modelValue === '' && this.rows > 0) {
doc = '\n'.repeat(this.rows - 1);
fullTree.cursor().iterate((node) => {
if (node.type.name === 'Document') {
documentRange = [node.from, node.to];
}
const state = EditorState.create({ doc, extensions: this.extensions });
if (node.type.name === 'StyleSheet') {
styleRanges.push([node.from - '<style>'.length, node.to + '</style>'.length]);
}
this.editor = new EditorView({ parent: this.root(), state });
this.editorState = this.editor.state;
if (node.type.name === 'Script') {
scriptRanges.push([node.from - '<script>'.length, node.to + ('<' + '/script>').length]);
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
}
});
this.getHighlighter()?.addColor(this.editor, this.resolvableSegments);
},
const htmlRanges = nonTakenRanges(documentRange, [...styleRanges, ...scriptRanges]);
beforeUnmount() {
htmlEditorEventBus.off('format-html', this.format);
},
const styleSections: Section[] = styleRanges.map(([start, end]) => ({
kind: 'style' as const,
range: [start, end],
content: state.sliceDoc(start, end).replace(/<\/?style>/g, ''),
}));
methods: {
root() {
const rootRef = this.$refs.htmlEditor as HTMLDivElement | undefined;
if (!rootRef) {
throw new Error('Expected div with ref "htmlEditor"');
}
const scriptSections: Section[] = scriptRanges.map(([start, end]) => ({
kind: 'script' as const,
range: [start, end],
content: state.sliceDoc(start, end).replace(/<\/?script>/g, ''),
}));
return rootRef;
},
const htmlSections: Section[] = htmlRanges.map(([start, end]) => ({
kind: 'html' as const,
range: [start, end] as Range,
content: state.sliceDoc(start, end).replace(/<\/html>/g, ''),
// opening tag may contain attributes, e.g. <html lang="en">
}));
isMissingHtmlTags() {
const zerothSection = this.sections.at(0);
return [...styleSections, ...scriptSections, ...htmlSections].sort(
(a, b) => a.range[0] - b.range[0],
);
});
return (
!zerothSection?.content.trim().startsWith('<html') &&
!zerothSection?.content.trim().endsWith('</html>')
);
},
function isMissingHtmlTags() {
const zerothSection = sections.value.at(0);
async format() {
if (this.sections.length === 1 && this.isMissingHtmlTags()) {
const zerothSection = this.sections.at(0) as Section;
return (
!zerothSection?.content.trim().startsWith('<html') &&
!zerothSection?.content.trim().endsWith('</html>')
);
}
const formatted = (
await format(zerothSection.content, {
parser: 'html',
plugins: [htmlParser],
})
).trim();
async function formatHtml() {
const editor = toValue(editorRef);
if (!editor) return;
return this.editor.dispatch({
changes: { from: 0, to: this.doc.length, insert: formatted },
});
}
const sectionToFormat = sections.value;
if (sectionToFormat.length === 1 && isMissingHtmlTags()) {
const zerothSection = sectionToFormat.at(0) as Section;
const formatted = [];
const formatted = (
await format(zerothSection.content, {
parser: 'html',
plugins: [htmlParser],
})
).trim();
for (const { kind, content } of this.sections) {
if (kind === 'style') {
const formattedStyle = await format(content, {
parser: 'css',
plugins: [cssParser],
});
return editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: formatted },
});
}
formatted.push(`<style>\n${formattedStyle}</style>`);
}
const formatted = [];
if (kind === 'script') {
const formattedScript = await format(content, {
parser: 'babel',
plugins: [jsParser, estree],
});
formatted.push(`<script>\n${formattedScript}<` + '/script>');
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
}
if (kind === 'html') {
const match = content.match(/(?<pre>[\s\S]*<html[\s\S]*?>)(?<rest>[\s\S]*)/);
if (!match?.groups?.pre || !match.groups?.rest) continue;
// Prettier cannot format pre-HTML section, e.g. <!DOCTYPE html>, so keep as is
const { pre, rest } = match.groups;
const formattedRest = await format(rest, {
parser: 'html',
plugins: [htmlParser],
});
formatted.push(`${pre}\n${formattedRest}</html>`);
}
}
if (formatted.length === 0) return;
this.editor.dispatch({
changes: { from: 0, to: this.doc.length, insert: formatted.join('\n\n') },
for (const { kind, content } of sections.value) {
if (kind === 'style') {
const formattedStyle = await format(content, {
parser: 'css',
plugins: [cssParser],
});
},
getHighlighter() {
if (this.disableExpressionColoring) return;
formatted.push(`<style>\n${formattedStyle}</style>`);
}
return highlighter;
},
},
if (kind === 'script') {
const formattedScript = await format(content, {
parser: 'babel',
plugins: [jsParser, estree],
});
formatted.push(`<script>\n${formattedScript}<` + '/script>');
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
}
if (kind === 'html') {
const match = content.match(/(?<pre>[\s\S]*<html[\s\S]*?>)(?<rest>[\s\S]*)/);
if (!match?.groups?.pre || !match.groups?.rest) continue;
// Prettier cannot format pre-HTML section, e.g. <!DOCTYPE html>, so keep as is
const { pre, rest } = match.groups;
const formattedRest = await format(rest, {
parser: 'html',
plugins: [htmlParser],
});
formatted.push(`${pre}\n${formattedRest}</html>`);
}
}
if (formatted.length === 0) return;
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: formatted.join('\n\n') },
});
}
watch(segments.display, () => {
emit('update:model-value', readEditorValue());
});
onMounted(() => {
htmlEditorEventBus.on('format-html', formatHtml);
});
onBeforeUnmount(() => {
htmlEditorEventBus.off('format-html', formatHtml);
emit('update:model-value', readEditorValue());
});
</script>

View file

@ -2,16 +2,14 @@
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div>
</template>
<script lang="ts">
import { completionStatus, startCompletion } from '@codemirror/autocomplete';
<script setup lang="ts">
import { startCompletion } from '@codemirror/autocomplete';
import { history } from '@codemirror/commands';
import { Compartment, EditorState, Prec } from '@codemirror/state';
import { Prec } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import type { PropType } from 'vue';
import { defineComponent, nextTick } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
import { completionManager } from '@/mixins/completionManager';
import { expressionManager } from '@/mixins/expressionManager';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import {
autocompleteKeyMap,
@ -20,152 +18,110 @@ import {
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { useNDVStore } from '@/stores/ndv.store';
import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions';
import { createEventBus, type EventBus } from 'n8n-design-system/utils';
import type { IDataObject } from 'n8n-workflow';
import { inputTheme } from './theme';
import { useNDVStore } from '@/stores/ndv.store';
import { mapStores } from 'pinia';
const editableConf = new Compartment();
type Props = {
modelValue: string;
path: string;
rows?: number;
isReadonly?: boolean;
additionalData?: IDataObject;
eventBus?: EventBus;
};
export default defineComponent({
name: 'InlineExpressionEditorInput',
mixins: [completionManager, expressionManager],
props: {
modelValue: {
type: String,
required: true,
},
isReadOnly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 5,
},
path: {
type: String,
required: true,
},
additionalData: {
type: Object as PropType<IDataObject>,
default: () => ({}),
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
const props = withDefaults(defineProps<Props>(), {
rows: 5,
isReadonly: false,
additionalData: () => ({}),
eventBus: () => createEventBus(),
});
const emit = defineEmits<{
(event: 'change', value: { value: string; segments: Segment[] }): void;
(event: 'focus'): void;
}>();
const ndvStore = useNDVStore();
const root = ref<HTMLElement>();
const extensions = computed(() => [
Prec.highest(
keymap.of([...tabKeyMap(true), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
),
n8nLang(),
n8nAutocompletion(),
inputTheme({ rows: props.rows }),
history(),
expressionInputHandler(),
EditorView.lineWrapping,
]);
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
const {
editor: editorRef,
segments,
readEditorValue,
setCursorPosition,
hasFocus,
focus,
} = useExpressionEditor({
editorRef: root,
editorValue,
extensions,
isReadOnly: props.isReadonly,
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
additionalData: props.additionalData,
});
defineExpose({
focus: () => {
setCursorPosition('lastExpression');
focus();
},
computed: {
...mapStores(useNDVStore),
});
async function onDrop() {
const editor = toValue(editorRef);
if (!editor) return;
await nextTick();
focus();
setCursorPosition('lastExpression');
if (!ndvStore.isAutocompleteOnboarded) {
startCompletion(editor);
}
}
watch(
() => props.modelValue,
(newValue) => {
editorValue.value = removeExpressionPrefix(newValue);
},
watch: {
isReadOnly(newValue: boolean) {
this.editor?.dispatch({
effects: editableConf.reconfigure(EditorView.editable.of(!newValue)),
});
},
modelValue(newValue) {
const isInternalChange = newValue === this.editor?.state.doc.toString();
);
if (isInternalChange) return;
watch(segments.display, (newSegments) => {
emit('change', {
value: '=' + readEditorValue(),
segments: newSegments,
});
});
// manual update on external change, e.g. from expression modal or mapping drop
watch(hasFocus, (focused) => {
if (focused) emit('focus');
});
this.editor?.dispatch({
changes: {
from: 0,
to: this.editor?.state.doc.length,
insert: newValue,
},
});
},
},
mounted() {
const extensions = [
Prec.highest(
keymap.of([...tabKeyMap(true), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
),
n8nLang(),
n8nAutocompletion(),
inputTheme({ rows: this.rows }),
history(),
expressionInputHandler(),
EditorView.lineWrapping,
editableConf.of(EditorView.editable.of(!this.isReadOnly)),
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
EditorView.domEventHandlers({
focus: () => {
this.$emit('focus');
},
}),
EditorView.updateListener.of((viewUpdate) => {
if (!this.editor) return;
onMounted(() => {
props.eventBus.on('drop', onDrop);
});
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;
highlighter.removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvableSegments);
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
setTimeout(() => {
try {
this.trackCompletion(viewUpdate, this.path);
} catch {}
});
}),
];
this.editor = new EditorView({
parent: this.$refs.root as HTMLDivElement,
state: EditorState.create({
doc: this.modelValue.startsWith('=') ? this.modelValue.slice(1) : this.modelValue,
extensions,
}),
});
this.editorState = this.editor.state;
highlighter.addColor(this.editor, this.resolvableSegments);
this.eventBus.on('drop', this.onDrop);
},
beforeUnmount() {
this.editor?.destroy();
this.eventBus.off('drop', this.onDrop);
},
methods: {
focus() {
this.editor?.focus();
},
setCursorPosition(pos: number) {
this.editor.dispatch({ selection: { anchor: pos, head: pos } });
},
async onDrop() {
await nextTick();
this.focus();
const END_OF_EXPRESSION = ' }}';
const value = this.editor.state.sliceDoc(0);
const cursorPosition = Math.max(value.lastIndexOf(END_OF_EXPRESSION), 0);
this.setCursorPosition(cursorPosition);
if (!this.ndvStore.isAutocompleteOnboarded) {
startCompletion(this.editor as EditorView);
}
},
},
onBeforeUnmount(() => {
props.eventBus.off('drop', onDrop);
});
</script>

View file

@ -90,7 +90,7 @@
:rows="getArgument('rows')"
:disable-expression-coloring="!isHtmlNode(node)"
:disable-expression-completions="!isHtmlNode(node)"
fill-parent
fullscreen
@update:model-value="valueChangedDebounced"
/>
<SqlEditor
@ -99,7 +99,7 @@
:dialect="getArgument('sqlDialect')"
:is-read-only="isReadOnly"
:rows="getArgument('rows')"
fill-parent
fullscreen
@update:model-value="valueChangedDebounced"
/>
<JsEditor

View file

@ -1,31 +1,35 @@
<template>
<div v-on-click-outside="onBlur" :class="$style.sqlEditor">
<div :class="$style.codemirror" ref="sqlEditor" data-test-id="sql-editor-container"></div>
<div :class="$style.sqlEditor">
<div ref="sqlEditor" :class="$style.codemirror" data-test-id="sql-editor-container"></div>
<slot name="suffix" />
<InlineExpressionEditorOutput
v-if="!fillParent"
v-if="!fullscreen"
:segments="segments"
:is-read-only="isReadOnly"
:visible="isFocused"
:visible="hasFocus"
:hovering-item-number="hoveringItemNumber"
/>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
import { expressionManager } from '@/mixins/expressionManager';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { useNDVStore } from '@/stores/ndv.store';
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';
import type { ViewUpdate } from '@codemirror/view';
import { Prec, type Line } from '@codemirror/state';
import {
EditorView,
dropCursor,
@ -34,7 +38,6 @@ import {
keymap,
lineNumbers,
} from '@codemirror/view';
import type { SQLDialect as SQLDialectType } from '@n8n/codemirror-lang-sql';
import {
Cassandra,
MSSQL,
@ -46,15 +49,8 @@ import {
StandardSQL,
keywordCompletionSource,
} from '@n8n/codemirror-lang-sql';
import { defineComponent } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
const SQL_DIALECTS = {
StandardSQL,
@ -67,170 +63,142 @@ const SQL_DIALECTS = {
PLSQL,
} as const;
type SQLEditorData = {
editor: EditorView | null;
editorState: EditorState | null;
isFocused: boolean;
skipSegments: string[];
expressionsDocsUrl: string;
type Props = {
modelValue: string;
dialect?: keyof typeof SQL_DIALECTS;
rows?: number;
isReadOnly?: boolean;
fullscreen?: boolean;
};
export default defineComponent({
name: 'SqlEditor',
components: {
InlineExpressionEditorOutput,
},
mixins: [expressionManager],
props: {
modelValue: {
type: String,
required: true,
},
dialect: {
type: String,
default: 'StandardSQL',
validator: (value: string) => {
return Object.keys(SQL_DIALECTS).includes(value);
},
},
isReadOnly: {
type: Boolean,
default: false,
},
fillParent: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 4,
},
},
data(): SQLEditorData {
return {
editor: null,
editorState: null,
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
isFocused: false,
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens'],
};
},
computed: {
doc(): string {
return this.editor?.state.doc.toString() ?? '';
},
hoveringItemNumber(): number {
return this.ndvStore.hoveringItemNumber;
},
sqlDialect(): SQLDialectType {
return SQL_DIALECTS[this.dialect as keyof typeof SQL_DIALECTS] ?? SQL_DIALECTS.StandardSQL;
},
extensions(): Extension[] {
const dialect = this.sqlDialect;
function sqlWithN8nLanguageSupport() {
return new LanguageSupport(dialect.language, [
dialect.language.data.of({
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
}),
n8nCompletionSources().map((source) => dialect.language.data.of(source)),
]);
}
const extensions = [
sqlWithN8nLanguageSupport(),
expressionInputHandler(),
codeNodeEditorTheme({
isReadOnly: this.isReadOnly,
maxHeight: this.fillParent ? '100%' : '40vh',
minHeight: '10vh',
rows: this.rows,
}),
lineNumbers(),
EditorView.lineWrapping,
EditorView.domEventHandlers({
focus: () => {
this.isFocused = true;
},
}),
EditorState.readOnly.of(this.isReadOnly),
EditorView.editable.of(!this.isReadOnly),
];
if (!this.isReadOnly) {
extensions.push(
history(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),
foldGutter(),
dropCursor(),
bracketMatching(),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!this.editor || !viewUpdate.docChanged) return;
// Force segments value update by keeping track of editor state
this.editorState = this.editor.state;
highlighter.removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvableSegments);
this.$emit('update:modelValue', this.editor?.state.doc.toString());
}),
);
}
return extensions;
},
},
mounted() {
if (!this.isReadOnly) codeNodeEditorEventBus.on('error-line-number', this.highlightLine);
const state = EditorState.create({ doc: this.modelValue, extensions: this.extensions });
this.editor = new EditorView({ parent: this.$refs.sqlEditor as HTMLDivElement, state });
this.editorState = this.editor.state;
highlighter.addColor(this.editor as EditorView, this.resolvableSegments);
},
methods: {
onBlur() {
this.isFocused = false;
},
line(lineNumber: number): Line | null {
try {
return this.editor?.state.doc.line(lineNumber) ?? null;
} catch {
return null;
}
},
highlightLine(lineNumber: number | 'final') {
if (!this.editor) return;
if (lineNumber === 'final') {
this.editor.dispatch({
selection: { anchor: this.modelValue.length },
});
return;
}
const line = this.line(lineNumber);
if (!line) return;
this.editor.dispatch({
selection: { anchor: line.from },
});
},
},
const props = withDefaults(defineProps<Props>(), {
dialect: 'StandardSQL',
rows: 4,
isReadOnly: false,
fullscreen: false,
});
const emit = defineEmits<{
(event: 'update:model-value', value: string): void;
}>();
const sqlEditor = ref<HTMLElement>();
const extensions = computed(() => {
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
function sqlWithN8nLanguageSupport() {
return new LanguageSupport(dialect.language, [
dialect.language.data.of({
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
}),
n8nCompletionSources().map((source) => dialect.language.data.of(source)),
]);
}
const baseExtensions = [
sqlWithN8nLanguageSupport(),
expressionInputHandler(),
codeNodeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh',
minHeight: '10vh',
rows: props.rows,
}),
lineNumbers(),
EditorView.lineWrapping,
];
if (!props.isReadOnly) {
return baseExtensions.concat([
history(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),
foldGutter(),
dropCursor(),
bracketMatching(),
]);
}
return baseExtensions;
});
const editorValue = ref(props.modelValue);
const {
editor,
segments: { all: segments },
readEditorValue,
hasFocus,
} = useExpressionEditor({
editorRef: sqlEditor,
editorValue,
extensions,
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens'],
isReadOnly: props.isReadOnly,
});
const ndvStore = useNDVStore();
const hoveringItemNumber = computed(() => {
return ndvStore.hoveringItemNumber;
});
watch(
() => props.modelValue,
(newValue) => {
editorValue.value = newValue;
},
);
watch(segments, () => {
emit('update:model-value', readEditorValue());
});
onMounted(() => {
codeNodeEditorEventBus.on('error-line-number', highlightLine);
if (props.fullscreen) {
focus();
}
});
onBeforeUnmount(() => {
codeNodeEditorEventBus.off('error-line-number', highlightLine);
emit('update:model-value', readEditorValue());
});
function line(lineNumber: number): Line | null {
try {
return editor.value?.state.doc.line(lineNumber) ?? null;
} catch {
return null;
}
}
function highlightLine(lineNumber: number | 'final') {
if (!editor.value) return;
if (lineNumber === 'final') {
editor.value.dispatch({
selection: { anchor: editor.value.state.doc.length },
});
return;
}
const lineToHighlight = line(lineNumber);
if (!lineToHighlight) return;
editor.value.dispatch({
selection: { anchor: lineToHighlight.from },
});
}
</script>
<style module lang="scss">

View file

@ -1,13 +1,21 @@
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import ExpressionEditorModalInput from '@/components/ExpressionEditorModal/ExpressionEditorModalInput.vue';
const renderComponent = createComponentRenderer(ExpressionEditorModalInput);
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
const originalRangeGetClientRects = Range.prototype.getClientRects;
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { waitFor } from '@testing-library/vue';
describe('ExpressionParameterInput', () => {
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
const originalRangeGetClientRects = Range.prototype.getClientRects;
const renderComponent = createComponentRenderer(ExpressionEditorModalInput);
let pinia: TestingPinia;
beforeEach(() => {
pinia = createTestingPinia();
setActivePinia(pinia);
});
beforeAll(() => {
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = () => ({
@ -33,7 +41,8 @@ describe('ExpressionParameterInput', () => {
},
});
await userEvent.type(getByRole('textbox'), 'test');
const textbox = await waitFor(() => getByRole('textbox'));
await userEvent.type(textbox, 'test');
expect(getByRole('textbox')).toHaveTextContent(expected);
});
});

View file

@ -1,22 +1,16 @@
import { createPinia, setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
const renderComponent = createComponentRenderer(ExpressionParameterInput);
let pinia: ReturnType<typeof createPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let ndvStore: ReturnType<typeof useNDVStore>;
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { setActivePinia } from 'pinia';
describe('ExpressionParameterInput', () => {
const renderComponent = createComponentRenderer(ExpressionParameterInput);
let pinia: TestingPinia;
beforeEach(() => {
pinia = createPinia();
pinia = createTestingPinia();
setActivePinia(pinia);
workflowsStore = useWorkflowsStore();
ndvStore = useNDVStore();
});
test.each([
@ -31,7 +25,7 @@ describe('ExpressionParameterInput', () => {
});
await userEvent.click(getByTestId('expander'));
expect(emitted().modalOpenerClick).toEqual(expected);
expect(emitted()['modal-opener-click']).toEqual(expected);
});
test('it should only emit blur when input had focus', async () => {

View file

@ -0,0 +1,93 @@
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { STORES } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import { renderComponent } from '@/__tests__/render';
import HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
import { userEvent } from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { htmlEditorEventBus } from '../../event-bus';
const DEFAULT_SETUP = {
props: {
modelValue: '<html><ul><li>one</li><li>two</li></ul></html>',
isReadOnly: false,
},
};
describe('HtmlEditor.vue', () => {
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
const originalRangeGetClientRects = Range.prototype.getClientRects;
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: SETTINGS_STORE_DEFAULT_STATE.settings,
},
},
});
setActivePinia(pinia);
beforeAll(() => {
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = () => ({
item: vi.fn(),
length: 0,
[Symbol.iterator]: vi.fn(),
});
});
afterAll(() => {
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect;
Range.prototype.getClientRects = originalRangeGetClientRects;
});
afterAll(() => {
vi.clearAllMocks();
});
it('renders simple html', async () => {
const { getByRole } = renderComponent(HtmlEditor, {
...DEFAULT_SETUP,
props: DEFAULT_SETUP.props,
});
await waitFor(() =>
expect(getByRole('textbox')).toHaveTextContent('<ul><li>one</li><li>two</li></ul>'),
);
});
it('formats html (and style/script tags)', async () => {
const unformattedHtml =
'<!DOCTYPE html><html><head> <meta charset="UTF-8" /> <title>My HTML document</title></head><body> <div class="container"> <h1>This is an H1 heading</h1> <h2>This is an H2 heading</h2> <p>This is a paragraph</p> </div> </body> <style>.container { background-color: #ffffff; text-align: center;}</style><script>console.log("Hello World!");</script></html>';
const { getByRole } = renderComponent(HtmlEditor, {
...DEFAULT_SETUP,
props: { ...DEFAULT_SETUP.props, modelValue: unformattedHtml },
});
let textbox = await waitFor(() => getByRole('textbox'));
expect(textbox.querySelectorAll('.cm-line').length).toBe(1);
htmlEditorEventBus.emit('format-html');
textbox = await waitFor(() => getByRole('textbox'));
await waitFor(() => expect(textbox.querySelectorAll('.cm-line').length).toBe(24));
});
it('emits update:model-value events', async () => {
const { emitted, getByRole } = renderComponent(HtmlEditor, {
...DEFAULT_SETUP,
props: DEFAULT_SETUP.props,
});
const textbox = await waitFor(() => getByRole('textbox'));
await userEvent.type(textbox, '<div>Content');
await waitFor(() =>
expect(emitted('update:model-value')).toEqual([
['<div>Content</div><html><ul><li>one</li><li>two</li></ul></html>'],
]),
);
});
});

View file

@ -1,50 +1,62 @@
import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { STORES } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
import { expressionManager } from '@/mixins/expressionManager';
import type { TargetItem } from '@/Interface';
import { renderComponent } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { useRouter } from 'vue-router';
const EXPRESSION_OUTPUT_TEST_ID = 'inline-expression-editor-output';
const RESOLVABLES: { [key: string]: string | number | boolean } = {
'{{ $json.schema }}': 'public',
'{{ $json.table }}': 'users',
'{{ $json.id }}': 'id',
'{{ $json.limit - 10 }}': 0,
'{{ $json.active }}': false,
};
const DEFAULT_SETUP = {
props: {
dialect: 'PostgreSQL',
isReadOnly: false,
},
global: {
plugins: [
createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: SETTINGS_STORE_DEFAULT_STATE.settings,
},
},
}),
],
},
};
describe('SQL Editor Preview Tests', () => {
beforeEach(() => {
vi.spyOn(expressionManager.methods, 'resolve').mockImplementation(
(resolvable: string, _targetItem?: TargetItem) => {
return { resolved: RESOLVABLES[resolvable] };
describe('SqlEditor.vue', () => {
const pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: SETTINGS_STORE_DEFAULT_STATE.settings,
},
);
[STORES.NDV]: {
activeNodeName: 'Test Node',
},
[STORES.WORKFLOWS]: {
workflow: {
nodes: [
{
id: '1',
typeVersion: 1,
name: 'Test Node',
position: [0, 0],
type: 'test',
parameters: {},
},
],
connections: {},
},
},
},
});
setActivePinia(pinia);
afterEach(() => {
const mockResolveExpression = () => {
const mock = vi.fn();
vi.spyOn(workflowHelpers, 'useWorkflowHelpers').mockReturnValueOnce({
...workflowHelpers.useWorkflowHelpers({ router: useRouter() }),
resolveExpression: mock,
});
return mock;
};
afterAll(() => {
vi.clearAllMocks();
});
@ -56,11 +68,14 @@ describe('SQL Editor Preview Tests', () => {
modelValue: 'SELECT * FROM users',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users');
await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users'),
);
});
it('renders basic query with expression', async () => {
mockResolveExpression().mockReturnValueOnce('users');
const { getByTestId } = renderComponent(SqlEditor, {
...DEFAULT_SETUP,
props: {
@ -68,11 +83,14 @@ describe('SQL Editor Preview Tests', () => {
modelValue: 'SELECT * FROM {{ $json.table }}',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users');
await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users'),
);
});
it('renders resolved expressions with dot between resolvables', async () => {
mockResolveExpression().mockReturnValueOnce('public.users');
const { getByTestId } = renderComponent(SqlEditor, {
...DEFAULT_SETUP,
props: {
@ -80,11 +98,19 @@ describe('SQL Editor Preview Tests', () => {
modelValue: 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM public.users');
await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users',
),
);
});
it('renders resolved expressions which resolve to 0', async () => {
mockResolveExpression()
.mockReturnValueOnce('public')
.mockReturnValueOnce('users')
.mockReturnValueOnce('id')
.mockReturnValueOnce(0);
const { getByTestId } = renderComponent(SqlEditor, {
...DEFAULT_SETUP,
props: {
@ -93,13 +119,19 @@ describe('SQL Editor Preview Tests', () => {
'SELECT * FROM {{ $json.schema }}.{{ $json.table }} WHERE {{ $json.id }} > {{ $json.limit - 10 }}',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0',
await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0',
),
);
});
it('keeps query formatting in rendered output', async () => {
mockResolveExpression()
.mockReturnValueOnce('public')
.mockReturnValueOnce('users')
.mockReturnValueOnce(0)
.mockReturnValueOnce(false);
const { getByTestId } = renderComponent(SqlEditor, {
...DEFAULT_SETUP,
props: {
@ -108,9 +140,10 @@ describe('SQL Editor Preview Tests', () => {
'SELECT * FROM {{ $json.schema }}.{{ $json.table }}\n WHERE id > {{ $json.limit - 10 }}\n AND active = {{ $json.active }};',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0 AND active = false;',
await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0 AND active = false;',
),
);
// Output should have the same number of lines as the input
expect(getByTestId('sql-editor-container').getElementsByClassName('cm-line').length).toEqual(

View file

@ -0,0 +1,111 @@
import { insertCompletionText, pickedCompletion } from '@codemirror/autocomplete';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { beforeEach, describe, vi } from 'vitest';
import { useAutocompleteTelemetry } from '../useAutocompleteTelemetry';
const trackSpy = vi.fn();
const setAutocompleteOnboardedSpy = vi.fn();
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn(() => ({ track: trackSpy })),
}));
vi.mock('@/stores/ndv.store', () => ({
useNDVStore: vi.fn(() => ({
activeNode: { type: 'n8n-nodes-base.test' },
setAutocompleteOnboarded: setAutocompleteOnboardedSpy,
})),
}));
vi.mock('@/stores/n8nRoot.store', () => ({
useRootStore: vi.fn(() => ({
instanceId: 'test-instance-id',
})),
}));
describe('useAutocompleteTelemetry', () => {
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
const originalRangeGetClientRects = Range.prototype.getClientRects;
beforeEach(() => {
setActivePinia(createTestingPinia());
});
afterEach(() => {
vi.clearAllMocks();
});
beforeAll(() => {
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = () => ({
item: vi.fn(),
length: 0,
[Symbol.iterator]: vi.fn(),
});
});
afterAll(() => {
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect;
Range.prototype.getClientRects = originalRangeGetClientRects;
});
const getEditor = (defaultDoc = '') => {
const extensionCompartment = new Compartment();
const state = EditorState.create({
doc: defaultDoc,
extensions: [extensionCompartment.of([])],
});
const editorRoot = document.createElement('div');
return {
editor: new EditorView({ parent: editorRoot, state }),
editorRoot,
compartment: extensionCompartment,
};
};
test('should track user autocomplete', async () => {
const { editor, compartment } = getEditor('$json.');
useAutocompleteTelemetry({
editor,
parameterPath: 'param',
compartment,
});
editor.dispatch({
...insertCompletionText(editor.state, 'foo', 6, 6),
annotations: pickedCompletion.of({ label: 'foo' }),
});
await waitFor(() =>
expect(trackSpy).toHaveBeenCalledWith('User autocompleted code', {
category: 'n/a',
context: '$json',
field_name: 'param',
field_type: 'expression',
inserted_text: 'foo',
instance_id: 'test-instance-id',
node_type: 'n8n-nodes-base.test',
}),
);
});
test('should mark user as onboarded on autocomplete', async () => {
const { editor, compartment } = getEditor();
useAutocompleteTelemetry({
editor,
parameterPath: 'param',
compartment,
});
editor.dispatch({
...insertCompletionText(editor.state, 'foo', 0, 0),
annotations: pickedCompletion.of({ label: 'foo' }),
});
await waitFor(() => expect(setAutocompleteOnboardedSpy).toHaveBeenCalled());
});
});

View file

@ -0,0 +1,268 @@
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import { EditorView } from '@codemirror/view';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { beforeEach, describe, vi } from 'vitest';
import { ref, toValue } from 'vue';
import { n8nLang } from '../../plugins/codemirror/n8nLang';
import { useExpressionEditor } from '../useExpressionEditor';
import { useRouter } from 'vue-router';
import { EditorSelection } from '@codemirror/state';
vi.mock('@/composables/useAutocompleteTelemetry', () => ({
useAutocompleteTelemetry: vi.fn(),
}));
vi.mock('@/stores/ndv.store', () => ({
useNDVStore: vi.fn(() => ({
activeNode: { type: 'n8n-nodes-base.test' },
})),
}));
describe('useExpressionEditor', () => {
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
const originalRangeGetClientRects = Range.prototype.getClientRects;
const mockResolveExpression = () => {
const mock = vi.fn();
vi.spyOn(workflowHelpers, 'useWorkflowHelpers').mockReturnValueOnce({
...workflowHelpers.useWorkflowHelpers({ router: useRouter() }),
resolveExpression: mock,
});
return mock;
};
beforeEach(() => {
setActivePinia(createTestingPinia());
});
afterEach(() => {
vi.clearAllMocks();
});
beforeAll(() => {
Range.prototype.getBoundingClientRect = vi.fn();
Range.prototype.getClientRects = () => ({
item: vi.fn(),
length: 0,
[Symbol.iterator]: vi.fn(),
});
});
afterAll(() => {
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect;
Range.prototype.getClientRects = originalRangeGetClientRects;
});
test('should create an editor', async () => {
const root = ref<HTMLElement>();
const { editor } = useExpressionEditor({
editorRef: root,
});
root.value = document.createElement('div');
await waitFor(() => expect(toValue(editor)).toBeInstanceOf(EditorView));
});
test('should calculate segments', async () => {
mockResolveExpression().mockReturnValueOnce(15);
const root = ref<HTMLElement>();
const { segments } = useExpressionEditor({
editorRef: root,
editorValue: 'before {{ $json.test.length }} after',
extensions: [n8nLang()],
});
root.value = document.createElement('div');
await waitFor(() => {
expect(toValue(segments.all)).toEqual([
{
from: 0,
kind: 'plaintext',
plaintext: 'before ',
to: 7,
},
{
error: null,
from: 7,
kind: 'resolvable',
resolvable: '{{ $json.test.length }}',
resolved: '15',
state: 'valid',
to: 30,
},
{
from: 30,
kind: 'plaintext',
plaintext: ' after',
to: 36,
},
]);
expect(toValue(segments.resolvable)).toEqual([
{
error: null,
from: 7,
kind: 'resolvable',
resolvable: '{{ $json.test.length }}',
resolved: '15',
state: 'valid',
to: 30,
},
]);
expect(toValue(segments.plaintext)).toEqual([
{
from: 0,
kind: 'plaintext',
plaintext: 'before ',
to: 7,
},
{
from: 30,
kind: 'plaintext',
plaintext: ' after',
to: 36,
},
]);
});
});
describe('readEditorValue()', () => {
test('should return the full editor value (unresolved)', async () => {
mockResolveExpression().mockReturnValueOnce(15);
const root = ref<HTMLElement>();
const { readEditorValue } = useExpressionEditor({
editorRef: root,
editorValue: 'before {{ $json.test.length }} after',
extensions: [n8nLang()],
});
root.value = document.createElement('div');
await waitFor(() =>
expect(readEditorValue()).toEqual('before {{ $json.test.length }} after'),
);
});
});
describe('setCursorPosition()', () => {
test('should set cursor position to number correctly', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const { editor, setCursorPosition } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
setCursorPosition(4);
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4)),
);
});
test('should set cursor position to end correctly', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const correctPosition = editorValue.length;
const { editor, setCursorPosition } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
setCursorPosition('end');
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(correctPosition)),
);
});
test('should set cursor position to last expression correctly', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text {{ $json.foo }} {{ $json.bar }} here';
const correctPosition = editorValue.indexOf('bar') + 'bar'.length;
const { editor, setCursorPosition } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [n8nLang()],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
setCursorPosition('lastExpression');
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(correctPosition)),
);
});
});
describe('select()', () => {
test('should select number range', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const { editor, select } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
select(4, 7);
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4, 7)),
);
});
test('should select until end', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const { editor, select } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
select(4, 'end');
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4, 9)),
);
});
});
describe('selectAll()', () => {
test('should select all', async () => {
const root = ref<HTMLElement>();
const editorValue = 'text here';
const { editor, selectAll } = useExpressionEditor({
editorRef: root,
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
selectAll();
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(0, 9)),
);
});
});
});

View file

@ -0,0 +1,114 @@
import { type MaybeRefOrGetter, computed, toValue, watchEffect } from 'vue';
import { ExpressionExtensions } from 'n8n-workflow';
import { EditorView, type ViewUpdate } from '@codemirror/view';
import { useNDVStore } from '@/stores/ndv.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useTelemetry } from '../composables/useTelemetry';
import type { Compartment } from '@codemirror/state';
import { debounce } from 'lodash-es';
export const useAutocompleteTelemetry = ({
editor: editorRef,
parameterPath,
compartment,
}: {
editor: MaybeRefOrGetter<EditorView | undefined>;
parameterPath: MaybeRefOrGetter<string>;
compartment: MaybeRefOrGetter<Compartment>;
}) => {
const ndvStore = useNDVStore();
const rootStore = useRootStore();
const telemetry = useTelemetry();
const expressionExtensionsCategories = computed(() => {
return ExpressionExtensions.reduce<Record<string, string | undefined>>((acc, cur) => {
for (const fnName of Object.keys(cur.functions)) {
acc[fnName] = cur.typeName;
}
return acc;
}, {});
});
function findCompletionBaseStartIndex(fromIndex: number) {
const editor = toValue(editorRef);
if (!editor) return -1;
const INDICATORS = [
' $', // proxy
'{ ', // primitive
];
const doc = editor.state.doc.toString();
for (let index = fromIndex; index > 0; index--) {
if (INDICATORS.some((indicator) => indicator === doc[index] + doc[index + 1])) {
return index + 1;
}
}
return -1;
}
function trackCompletion(viewUpdate: ViewUpdate, path: string) {
const editor = toValue(editorRef);
if (!editor) return;
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
if (!completionTx) return;
ndvStore.setAutocompleteOnboarded();
let completion = '';
let completionBase = '';
viewUpdate.changes.iterChanges((_: number, __: number, fromB: number, toB: number) => {
completion = toValue(editor).state.doc.slice(fromB, toB).toString();
const index = findCompletionBaseStartIndex(fromB);
completionBase = toValue(editor)
.state.doc.slice(index, fromB - 1)
.toString()
.trim();
});
const category = expressionExtensionsCategories.value[completion];
const payload = {
instance_id: rootStore.instanceId,
node_type: ndvStore.activeNode?.type,
field_name: path,
field_type: 'expression',
context: completionBase,
inserted_text: completion,
category: category ?? 'n/a', // only applicable if expression extension completion
};
telemetry.track('User autocompleted code', payload);
}
const safeTrackCompletion = (viewUpdate: ViewUpdate, path: string) => {
try {
trackCompletion(viewUpdate, path);
} catch {}
};
const debouncedTrackCompletion = debounce(safeTrackCompletion, 100);
watchEffect(() => {
const editor = toValue(editorRef);
if (!editor) return;
editor.dispatch({
effects: toValue(compartment).reconfigure([
EditorView.updateListener.of((viewUpdate) => {
if (!viewUpdate.docChanged || !editor) return;
debouncedTrackCompletion(viewUpdate, toValue(parameterPath));
}),
]),
});
});
};

View file

@ -0,0 +1,405 @@
import {
computed,
type MaybeRefOrGetter,
onBeforeUnmount,
ref,
watchEffect,
type Ref,
toValue,
watch,
} from 'vue';
import { ensureSyntaxTree } from '@codemirror/language';
import type { IDataObject } from 'n8n-workflow';
import { Expression, ExpressionExtensions } from 'n8n-workflow';
import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import type { TargetItem } from '@/Interface';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
import {
getExpressionErrorMessage,
getResolvableState,
isEmptyExpression,
} from '@/utils/expressions';
import { completionStatus } from '@codemirror/autocomplete';
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { EditorView, type ViewUpdate } from '@codemirror/view';
import { debounce, isEqual } from 'lodash-es';
import { useRouter } from 'vue-router';
import { useI18n } from '../composables/useI18n';
import { highlighter } from '../plugins/codemirror/resolvableHighlighter';
import { useWorkflowsStore } from '../stores/workflows.store';
import { useAutocompleteTelemetry } from './useAutocompleteTelemetry';
export const useExpressionEditor = ({
editorRef,
editorValue,
extensions = [],
additionalData = {},
skipSegments = [],
autocompleteTelemetry,
isReadOnly = false,
}: {
editorRef: Ref<HTMLElement | undefined>;
editorValue?: MaybeRefOrGetter<string>;
extensions?: MaybeRefOrGetter<Extension[]>;
additionalData?: MaybeRefOrGetter<IDataObject>;
skipSegments?: MaybeRefOrGetter<string[]>;
autocompleteTelemetry?: MaybeRefOrGetter<{ enabled: true; parameterPath: string }>;
isReadOnly?: MaybeRefOrGetter<boolean>;
}) => {
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
const i18n = useI18n();
const editor = ref<EditorView>();
const hasFocus = ref(false);
const segments = ref<Segment[]>([]);
const customExtensions = ref<Compartment>(new Compartment());
const readOnlyExtensions = ref<Compartment>(new Compartment());
const telemetryExtensions = ref<Compartment>(new Compartment());
const autocompleteStatus = ref<'pending' | 'active' | null>(null);
const updateSegments = (): void => {
const state = editor.value?.state;
if (!state) return;
const rawSegments: RawSegment[] = [];
const fullTree = ensureSyntaxTree(state, state.doc.length, EXPRESSION_EDITOR_PARSER_TIMEOUT);
if (fullTree === null) return;
const skip = ['Program', 'Script', 'Document', ...toValue(skipSegments)];
fullTree.cursor().iterate((node) => {
const text = state.sliceDoc(node.from, node.to);
if (skip.includes(node.type.name)) return;
const newSegment: RawSegment = {
from: node.from,
to: node.to,
text,
token: node.type.name === 'Resolvable' ? 'Resolvable' : 'Plaintext',
};
// Avoid duplicates
if (isEqual(newSegment, rawSegments.at(-1))) return;
rawSegments.push(newSegment);
});
segments.value = rawSegments.reduce<Segment[]>((acc, segment) => {
const { from, to, text, token } = segment;
if (token === 'Resolvable') {
const { resolved, error, fullError } = resolve(text, hoveringItem.value);
acc.push({
kind: 'resolvable',
from,
to,
resolvable: text,
// TODO:
// 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 ?? error, completionStatus !== null),
error: fullError,
});
return acc;
}
acc.push({ kind: 'plaintext', from, to, plaintext: text });
return acc;
}, []);
};
function readEditorValue(): string {
return editor.value?.state.doc.toString() ?? '';
}
function updateHighlighting(): void {
if (!editor.value) return;
highlighter.removeColor(editor.value, plaintextSegments.value);
highlighter.addColor(editor.value, resolvableSegments.value);
}
const debouncedUpdateSegments = debounce(updateSegments, 200);
function onEditorUpdate(viewUpdate: ViewUpdate) {
if (!viewUpdate.docChanged || !editor.value) return;
autocompleteStatus.value = completionStatus(viewUpdate.view.state);
debouncedUpdateSegments();
}
watch(editorRef, () => {
const parent = toValue(editorRef);
if (!parent) return;
const state = EditorState.create({
doc: toValue(editorValue),
extensions: [
customExtensions.value.of(toValue(extensions)),
readOnlyExtensions.value.of([
EditorState.readOnly.of(toValue(isReadOnly)),
EditorView.editable.of(!toValue(isReadOnly)),
]),
telemetryExtensions.value.of([]),
EditorView.updateListener.of(onEditorUpdate),
EditorView.focusChangeEffect.of((_, newHasFocus) => {
hasFocus.value = newHasFocus;
return null;
}),
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
],
});
if (editor.value) {
editor.value.destroy();
}
editor.value = new EditorView({ parent, state });
debouncedUpdateSegments();
});
watchEffect(() => {
if (editor.value) {
editor.value.dispatch({
effects: customExtensions.value.reconfigure(toValue(extensions)),
});
}
});
watchEffect(() => {
if (editor.value) {
editor.value.dispatch({
effects: readOnlyExtensions.value.reconfigure([
EditorState.readOnly.of(toValue(isReadOnly)),
EditorView.editable.of(!toValue(isReadOnly)),
]),
});
}
});
watchEffect(() => {
if (!editor.value) return;
const newValue = toValue(editorValue);
const currentValue = readEditorValue();
if (newValue === undefined || newValue === currentValue) return;
editor.value.dispatch({
changes: { from: 0, to: currentValue.length, insert: newValue },
});
});
watchEffect(() => {
const telemetry = toValue(autocompleteTelemetry);
if (!telemetry?.enabled) return;
useAutocompleteTelemetry({
editor,
parameterPath: telemetry.parameterPath,
compartment: telemetryExtensions,
});
});
onBeforeUnmount(() => {
editor.value?.destroy();
});
const expressionExtensionNames = computed<Set<string>>(() => {
return new Set(
ExpressionExtensions.reduce<string[]>((acc, cur) => {
return [...acc, ...Object.keys(cur.functions)];
}, []),
);
});
function isUncalledExpressionExtension(resolvable: string) {
const end = resolvable
.replace(/^{{|}}$/g, '')
.trim()
.split('.')
.pop();
return end !== undefined && expressionExtensionNames.value.has(end);
}
function resolve(resolvable: string, hoverItem: TargetItem | null) {
const result: { resolved: unknown; error: boolean; fullError: Error | null } = {
resolved: undefined,
error: false,
fullError: null,
};
try {
if (!ndvStore.activeNode) {
// e.g. credential modal
result.resolved = Expression.resolveWithoutWorkflow(resolvable, toValue(additionalData));
} else {
let opts;
if (ndvStore.isInputParentOfActiveNode) {
opts = {
targetItem: hoverItem ?? undefined,
inputNodeName: ndvStore.ndvInputNodeName,
inputRunIndex: ndvStore.ndvInputRunIndex,
inputBranchIndex: ndvStore.ndvInputBranchIndex,
additionalKeys: toValue(additionalData),
};
}
result.resolved = workflowHelpers.resolveExpression('=' + resolvable, undefined, opts);
}
} catch (error) {
result.resolved = `[${getExpressionErrorMessage(error)}]`;
result.error = true;
result.fullError = error;
}
if (result.resolved === '') {
result.resolved = i18n.baseText('expressionModalInput.empty');
}
if (result.resolved === undefined && isEmptyExpression(resolvable)) {
result.resolved = i18n.baseText('expressionModalInput.empty');
}
if (result.resolved === undefined) {
result.resolved = isUncalledExpressionExtension(resolvable)
? i18n.baseText('expressionEditor.uncalledFunction')
: i18n.baseText('expressionModalInput.undefined');
result.error = true;
}
if (typeof result.resolved === 'number' && isNaN(result.resolved)) {
result.resolved = i18n.baseText('expressionModalInput.null');
}
return result;
}
const hoveringItem = computed(() => {
return ndvStore.hoveringItem;
});
const resolvableSegments = computed<Resolvable[]>(() => {
return segments.value.filter((s): s is Resolvable => s.kind === 'resolvable');
});
const plaintextSegments = computed<Plaintext[]>(() => {
return segments.value.filter((s): s is Plaintext => s.kind === 'plaintext');
});
const htmlSegments = computed<Html[]>(() => {
return segments.value.filter((s): s is Html => s.kind !== 'resolvable');
});
/**
* Segments to display in the output of an expression editor.
*
* Some segments are not displayed when they are _part_ of the result,
* but displayed when they are the _entire_ result:
*
* - `This is a {{ [] }} test` displays as `This is a test`.
* - `{{ [] }}` displays as `[Array: []]`.
*
* Some segments display differently based on context:
*
* Date displays as
* - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result
* - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result
*
* Only needed in order to mimic behavior of `ParameterInputHint`.
*/
const displayableSegments = computed<Segment[]>(() => {
const cachedSegments = segments.value;
return cachedSegments
.map((s) => {
if (cachedSegments.length <= 1 || s.kind !== 'resolvable') return s;
if (typeof s.resolved === 'string' && /\[Object: "\d{4}-\d{2}-\d{2}T/.test(s.resolved)) {
const utcDateString = s.resolved.replace(/(\[Object: "|\"\])/g, '');
s.resolved = new Date(utcDateString).toString();
}
if (typeof s.resolved === 'string' && /\[Array:\s\[.+\]\]/.test(s.resolved)) {
s.resolved = s.resolved.replace(/(\[Array: \[|\])/g, '');
}
return s;
})
.filter((s) => {
if (
cachedSegments.length > 1 &&
s.kind === 'resolvable' &&
typeof s.resolved === 'string' &&
(s.resolved === '[Array: []]' ||
s.resolved === i18n.baseText('expressionModalInput.empty'))
) {
return false;
}
return true;
});
});
watch(
[
() => workflowsStore.getWorkflowExecution,
() => workflowsStore.getWorkflowRunData,
() => ndvStore.hoveringItemNumber,
],
debouncedUpdateSegments,
);
watch(resolvableSegments, updateHighlighting);
function setCursorPosition(pos: number | 'lastExpression' | 'end'): void {
if (pos === 'lastExpression') {
const END_OF_EXPRESSION = ' }}';
pos = Math.max(readEditorValue().lastIndexOf(END_OF_EXPRESSION), 0);
} else if (pos === 'end') {
pos = editor.value?.state.doc.length ?? 0;
}
editor.value?.dispatch({ selection: { head: pos, anchor: pos } });
}
function select(anchor: number, head: number | 'end' = 'end'): void {
editor.value?.dispatch({
selection: { anchor, head: head === 'end' ? editor.value?.state.doc.length ?? 0 : head },
});
}
const selectAll = () => select(0, 'end');
function focus(): void {
if (hasFocus.value) return;
editor.value?.focus();
}
return {
editor,
hasFocus,
segments: {
all: segments,
html: htmlSegments,
display: displayableSegments,
plaintext: plaintextSegments,
resolvable: resolvableSegments,
},
readEditorValue,
setCursorPosition,
select,
selectAll,
focus,
};
};

View file

@ -1,83 +0,0 @@
import { defineComponent } from 'vue';
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],
data() {
return {
editor: {} as EditorView,
};
},
computed: {
...mapStores(useNDVStore, useRootStore),
expressionExtensionsCategories() {
return ExpressionExtensions.reduce<Record<string, string | undefined>>((acc, cur) => {
for (const fnName of Object.keys(cur.functions)) {
acc[fnName] = cur.typeName;
}
return acc;
}, {});
},
},
methods: {
trackCompletion(viewUpdate: ViewUpdate, parameterPath: string) {
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
if (!completionTx) return;
this.ndvStore.setAutocompleteOnboarded();
let completion = '';
let completionBase = '';
viewUpdate.changes.iterChanges((_: number, __: number, fromB: number, toB: number) => {
completion = this.editor.state.doc.slice(fromB, toB).toString();
const index = this.findCompletionBaseStartIndex(fromB);
completionBase = this.editor.state.doc
.slice(index, fromB - 1)
.toString()
.trim();
});
const category = this.expressionExtensionsCategories[completion];
const payload = {
instance_id: this.rootStore.instanceId,
node_type: this.ndvStore.activeNode?.type,
field_name: parameterPath,
field_type: 'expression',
context: completionBase,
inserted_text: completion,
category: category ?? 'n/a', // only applicable if expression extension completion
};
this.$telemetry.track('User autocompleted code', payload);
},
findCompletionBaseStartIndex(fromIndex: number) {
const INDICATORS = [
' $', // proxy
'{ ', // primitive
];
const doc = this.editor.state.doc.toString();
for (let index = fromIndex; index > 0; index--) {
if (INDICATORS.some((indicator) => indicator === doc[index] + doc[index + 1])) {
return index + 1;
}
}
return -1;
},
},
});

View file

@ -1,266 +0,0 @@
import { mapStores } from 'pinia';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { ensureSyntaxTree } from '@codemirror/language';
import type { IDataObject } from 'n8n-workflow';
import { Expression, ExpressionExtensions } from 'n8n-workflow';
import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import type { TargetItem } from '@/Interface';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
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: {
targetItem: {
type: Object as PropType<TargetItem | null>,
},
additionalData: {
type: Object as PropType<IDataObject>,
default: () => ({}),
},
},
data(): {
editor: EditorView;
skipSegments: string[];
editorState: EditorState | undefined;
completionStatus: 'active' | 'pending' | null;
} {
return {
editor: {} as EditorView,
skipSegments: [],
completionStatus: null,
editorState: undefined,
};
},
computed: {
...mapStores(useNDVStore, useWorkflowsStore),
unresolvedExpression(): string {
return this.segments.reduce((acc, segment) => {
acc += segment.kind === 'resolvable' ? segment.resolvable : segment.plaintext;
return acc;
}, '=');
},
hoveringItem(): TargetItem | undefined {
return this.ndvStore.hoveringItem ?? undefined;
},
resolvableSegments(): Resolvable[] {
return this.segments.filter((s): s is Resolvable => s.kind === 'resolvable');
},
plaintextSegments(): Plaintext[] {
return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
},
expressionExtensionNames(): Set<string> {
return new Set(
ExpressionExtensions.reduce<string[]>((acc, cur) => {
return [...acc, ...Object.keys(cur.functions)];
}, []),
);
},
htmlSegments(): Html[] {
return this.segments.filter((s): s is Html => s.kind !== 'resolvable');
},
segments(): Segment[] {
const state = this.editorState as EditorState;
if (!state) return [];
const rawSegments: RawSegment[] = [];
const fullTree = ensureSyntaxTree(state, state.doc.length, EXPRESSION_EDITOR_PARSER_TIMEOUT);
if (fullTree === null) {
throw new Error(`Failed to parse expression: ${this.editorValue}`);
}
const skipSegments = ['Program', 'Script', 'Document', ...this.skipSegments];
fullTree.cursor().iterate((node) => {
const text = state.sliceDoc(node.from, node.to);
if (skipSegments.includes(node.type.name)) return;
const newSegment: RawSegment = {
from: node.from,
to: node.to,
text,
token: node.type.name === 'Resolvable' ? 'Resolvable' : 'Plaintext',
};
// Avoid duplicates
if (isEqual(newSegment, rawSegments.at(-1))) return;
rawSegments.push(newSegment);
});
return rawSegments.reduce<Segment[]>((acc, segment) => {
const { from, to, text, token } = segment;
if (token === 'Resolvable') {
const { resolved, error, fullError } = this.resolve(text, this.hoveringItem);
acc.push({
kind: 'resolvable',
from,
to,
resolvable: text,
// TODO:
// 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 ?? error, this.completionStatus !== null),
error: fullError,
});
return acc;
}
acc.push({ kind: 'plaintext', from, to, plaintext: text });
return acc;
}, []);
},
/**
* Segments to display in the output of an expression editor.
*
* Some segments are not displayed when they are _part_ of the result,
* but displayed when they are the _entire_ result:
*
* - `This is a {{ [] }} test` displays as `This is a test`.
* - `{{ [] }}` displays as `[Array: []]`.
*
* Some segments display differently based on context:
*
* Date displays as
* - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result
* - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result
*
* Only needed in order to mimic behavior of `ParameterInputHint`.
*/
displayableSegments(): Segment[] {
return this.segments
.map((s) => {
if (this.segments.length <= 1 || s.kind !== 'resolvable') return s;
if (typeof s.resolved === 'string' && /\[Object: "\d{4}-\d{2}-\d{2}T/.test(s.resolved)) {
const utcDateString = s.resolved.replace(/(\[Object: "|\"\])/g, '');
s.resolved = new Date(utcDateString).toString();
}
if (typeof s.resolved === 'string' && /\[Array:\s\[.+\]\]/.test(s.resolved)) {
s.resolved = s.resolved.replace(/(\[Array: \[|\])/g, '');
}
return s;
})
.filter((s) => {
if (
this.segments.length > 1 &&
s.kind === 'resolvable' &&
typeof s.resolved === 'string' &&
(s.resolved === '[Array: []]' ||
s.resolved === this.$locale.baseText('expressionModalInput.empty'))
) {
return false;
}
return true;
});
},
},
watch: {
targetItem() {
setTimeout(() => {
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
});
},
},
methods: {
isEmptyExpression(resolvable: string) {
return /\{\{\s*\}\}/.test(resolvable);
},
resolve(resolvable: string, targetItem?: TargetItem) {
const result: { resolved: unknown; error: boolean; fullError: Error | null } = {
resolved: undefined,
error: false,
fullError: null,
};
try {
const ndvStore = useNDVStore();
const workflowHelpers = useWorkflowHelpers({ router: this.$router });
if (!ndvStore.activeNode) {
// e.g. credential modal
result.resolved = Expression.resolveWithoutWorkflow(resolvable, this.additionalData);
} else {
let opts;
if (ndvStore.isInputParentOfActiveNode) {
opts = {
targetItem: targetItem ?? undefined,
inputNodeName: this.ndvStore.ndvInputNodeName,
inputRunIndex: this.ndvStore.ndvInputRunIndex,
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
additionalKeys: this.additionalData,
};
}
result.resolved = workflowHelpers.resolveExpression('=' + resolvable, undefined, opts);
}
} catch (error) {
result.resolved = `[${getExpressionErrorMessage(error)}]`;
result.error = true;
result.fullError = error;
}
if (result.resolved === '') {
result.resolved = this.$locale.baseText('expressionModalInput.empty');
}
if (result.resolved === undefined && this.isEmptyExpression(resolvable)) {
result.resolved = this.$locale.baseText('expressionModalInput.empty');
}
if (result.resolved === undefined) {
result.resolved = this.isUncalledExpressionExtension(resolvable)
? this.$locale.baseText('expressionEditor.uncalledFunction')
: this.$locale.baseText('expressionModalInput.undefined');
result.error = true;
}
if (typeof result.resolved === 'number' && isNaN(result.resolved)) {
result.resolved = this.$locale.baseText('expressionModalInput.null');
}
return result;
},
isUncalledExpressionExtension(resolvable: string) {
const end = resolvable
.replace(/^{{|}}$/g, '')
.trim()
.split('.')
.pop();
return end !== undefined && this.expressionExtensionNames.has(end);
},
},
});

View file

@ -8,6 +8,14 @@ export const isExpression = (expr: unknown) => {
return expr.startsWith('=');
};
export const isEmptyExpression = (expr: string) => {
return /\{\{\s*\}\}/.test(expr);
};
export const removeExpressionPrefix = (expr: string) => {
return expr.startsWith('=') ? expr.slice(1) : expr;
};
export const isTestableExpression = (expr: string) => {
return ExpressionParser.splitExpression(expr).every((c) => {
if (c.type === 'text') {