feat(editor): Refactor expression editors and mixins to composition API (#8894)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>'],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user