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
19 changed files with 1661 additions and 1126 deletions

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(