mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Refactor expression editors and mixins to composition API (#8894)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
parent
a10120f74e
commit
0c179e4e51
|
@ -19,6 +19,7 @@ describe('Data transformation expressions', () => {
|
||||||
const output = 'monday is TODAY';
|
const output = 'monday is TODAY';
|
||||||
|
|
||||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||||
|
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||||
ndv.actions.execute();
|
ndv.actions.execute();
|
||||||
ndv.getters.outputDataContainer().should('be.visible');
|
ndv.getters.outputDataContainer().should('be.visible');
|
||||||
ndv.getters.outputDataContainer().contains(output);
|
ndv.getters.outputDataContainer().contains(output);
|
||||||
|
@ -34,6 +35,7 @@ describe('Data transformation expressions', () => {
|
||||||
const output = 'hello@n8n.io false';
|
const output = 'hello@n8n.io false';
|
||||||
|
|
||||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||||
|
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||||
ndv.actions.execute();
|
ndv.actions.execute();
|
||||||
ndv.getters.outputDataContainer().should('be.visible');
|
ndv.getters.outputDataContainer().should('be.visible');
|
||||||
ndv.getters.outputDataContainer().contains(output);
|
ndv.getters.outputDataContainer().contains(output);
|
||||||
|
@ -49,6 +51,7 @@ describe('Data transformation expressions', () => {
|
||||||
const output = '9.12';
|
const output = '9.12';
|
||||||
|
|
||||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||||
|
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||||
ndv.actions.execute();
|
ndv.actions.execute();
|
||||||
ndv.getters.outputDataContainer().should('be.visible');
|
ndv.getters.outputDataContainer().should('be.visible');
|
||||||
ndv.getters.outputDataContainer().contains(output);
|
ndv.getters.outputDataContainer().contains(output);
|
||||||
|
@ -64,6 +67,7 @@ describe('Data transformation expressions', () => {
|
||||||
const output = 'hello@n8n.io false';
|
const output = 'hello@n8n.io false';
|
||||||
|
|
||||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||||
|
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||||
ndv.actions.execute();
|
ndv.actions.execute();
|
||||||
ndv.getters.outputDataContainer().should('be.visible');
|
ndv.getters.outputDataContainer().should('be.visible');
|
||||||
ndv.getters.outputDataContainer().contains(output);
|
ndv.getters.outputDataContainer().contains(output);
|
||||||
|
@ -78,6 +82,7 @@ describe('Data transformation expressions', () => {
|
||||||
const output = 'true 3';
|
const output = 'true 3';
|
||||||
|
|
||||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||||
|
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||||
ndv.actions.execute();
|
ndv.actions.execute();
|
||||||
ndv.getters.outputDataContainer().find('[class*=value_]').should('exist');
|
ndv.getters.outputDataContainer().find('[class*=value_]').should('exist');
|
||||||
ndv.getters.outputDataContainer().find('[class*=value_]').should('contain', output);
|
ndv.getters.outputDataContainer().find('[class*=value_]').should('contain', output);
|
||||||
|
@ -93,6 +98,7 @@ describe('Data transformation expressions', () => {
|
||||||
const output = '1 3';
|
const output = '1 3';
|
||||||
|
|
||||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||||
|
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||||
ndv.actions.execute();
|
ndv.actions.execute();
|
||||||
ndv.getters.outputDataContainer().find('[class*=value_]').should('exist');
|
ndv.getters.outputDataContainer().find('[class*=value_]').should('exist');
|
||||||
ndv.getters.outputDataContainer().find('[class*=value_]').should('contain', output);
|
ndv.getters.outputDataContainer().find('[class*=value_]').should('contain', output);
|
||||||
|
|
|
@ -39,6 +39,7 @@ export class NDV extends BasePage {
|
||||||
inputTbodyCell: (row: number, col: number) =>
|
inputTbodyCell: (row: number, col: number) =>
|
||||||
this.getters.inputTableRow(row).find('td').eq(col),
|
this.getters.inputTableRow(row).find('td').eq(col),
|
||||||
inlineExpressionEditorInput: () => cy.getByTestId('inline-expression-editor-input'),
|
inlineExpressionEditorInput: () => cy.getByTestId('inline-expression-editor-input'),
|
||||||
|
inlineExpressionEditorOutput: () => cy.getByTestId('inline-expression-editor-output'),
|
||||||
nodeParameters: () => cy.getByTestId('node-parameters'),
|
nodeParameters: () => cy.getByTestId('node-parameters'),
|
||||||
parameterInput: (parameterName: string) => cy.getByTestId(`parameter-input-${parameterName}`),
|
parameterInput: (parameterName: string) => cy.getByTestId(`parameter-input-${parameterName}`),
|
||||||
parameterInputIssues: (parameterName: string) =>
|
parameterInputIssues: (parameterName: string) =>
|
||||||
|
|
|
@ -2,53 +2,47 @@
|
||||||
<div ref="root" :class="$style.editor" @keydown.stop></div>
|
<div ref="root" :class="$style.editor" @keydown.stop></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
|
||||||
import { EditorState, Prec } from '@codemirror/state';
|
|
||||||
import { history } from '@codemirror/commands';
|
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 { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
|
||||||
import { inputTheme } from './theme';
|
|
||||||
import { forceParse } from '@/utils/forceParse';
|
import { forceParse } from '@/utils/forceParse';
|
||||||
import { completionStatus } from '@codemirror/autocomplete';
|
import { completionStatus } from '@codemirror/autocomplete';
|
||||||
|
import { inputTheme } from './theme';
|
||||||
|
|
||||||
import type { IVariableItemSelected } from '@/Interface';
|
import type { IVariableItemSelected } from '@/Interface';
|
||||||
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import {
|
import {
|
||||||
autocompleteKeyMap,
|
autocompleteKeyMap,
|
||||||
enterKeyMap,
|
enterKeyMap,
|
||||||
historyKeyMap,
|
historyKeyMap,
|
||||||
tabKeyMap,
|
tabKeyMap,
|
||||||
} from '@/plugins/codemirror/keymap';
|
} from '@/plugins/codemirror/keymap';
|
||||||
|
import type { Segment } from '@/types/expressions';
|
||||||
|
import { removeExpressionPrefix } from '@/utils/expressions';
|
||||||
|
|
||||||
export default defineComponent({
|
type Props = {
|
||||||
name: 'ExpressionEditorModalInput',
|
modelValue: string;
|
||||||
mixins: [expressionManager, completionManager],
|
path: string;
|
||||||
props: {
|
isReadOnly?: boolean;
|
||||||
modelValue: {
|
};
|
||||||
type: String,
|
|
||||||
required: true,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
},
|
isReadOnly: false,
|
||||||
path: {
|
});
|
||||||
type: String,
|
|
||||||
required: true,
|
const emit = defineEmits<{
|
||||||
},
|
(event: 'change', value: { value: string; segments: Segment[] }): void;
|
||||||
isReadOnly: {
|
(event: 'focus'): void;
|
||||||
type: Boolean,
|
(event: 'close'): void;
|
||||||
default: false,
|
}>();
|
||||||
},
|
|
||||||
},
|
const root = ref<HTMLElement>();
|
||||||
data() {
|
const extensions = computed(() => [
|
||||||
return {
|
|
||||||
editor: null as EditorView | null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
const extensions = [
|
|
||||||
inputTheme(),
|
inputTheme(),
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
keymap.of([
|
keymap.of([
|
||||||
|
@ -60,7 +54,7 @@ export default defineComponent({
|
||||||
any: (view, event) => {
|
any: (view, event) => {
|
||||||
if (event.key === 'Escape' && completionStatus(view.state) === null) {
|
if (event.key === 'Escape' && completionStatus(view.state) === null) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.$emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
@ -73,88 +67,77 @@ export default defineComponent({
|
||||||
history(),
|
history(),
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
EditorView.lineWrapping,
|
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.domEventHandlers({ scroll: forceParse }),
|
||||||
EditorView.updateListener.of((viewUpdate) => {
|
]);
|
||||||
if (!this.editor) return;
|
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||||
|
const {
|
||||||
this.completionStatus = completionStatus(viewUpdate.view.state);
|
editor: editorRef,
|
||||||
|
segments,
|
||||||
if (!viewUpdate.docChanged) return;
|
readEditorValue,
|
||||||
|
setCursorPosition,
|
||||||
this.editorState = this.editor.state;
|
hasFocus,
|
||||||
|
focus,
|
||||||
highlighter.removeColor(this.editor, this.plaintextSegments);
|
} = useExpressionEditor({
|
||||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
editorRef: root,
|
||||||
|
editorValue,
|
||||||
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,
|
extensions,
|
||||||
}),
|
isReadOnly: props.isReadOnly,
|
||||||
});
|
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
|
||||||
|
});
|
||||||
|
|
||||||
this.editorState = this.editor.state;
|
watch(
|
||||||
this.editor.focus();
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
editorValue.value = removeExpressionPrefix(newValue);
|
||||||
|
|
||||||
this.editor.dispatch({
|
|
||||||
selection: { anchor: this.editor.state.doc.length },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$emit('change', {
|
|
||||||
value: this.unresolvedExpression,
|
|
||||||
segments: this.displayableSegments,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
);
|
||||||
this.editor?.destroy();
|
|
||||||
},
|
watch(segments.display, (newSegments) => {
|
||||||
methods: {
|
emit('change', {
|
||||||
itemSelected({ variable }: IVariableItemSelected) {
|
value: '=' + readEditorValue(),
|
||||||
if (!this.editor || this.isReadOnly) return;
|
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 OPEN_MARKER = '{{';
|
||||||
const CLOSE_MARKER = '}}';
|
const CLOSE_MARKER = '}}';
|
||||||
|
|
||||||
const { doc, selection } = this.editor.state;
|
const { selection, doc } = editor.state;
|
||||||
const { head } = selection.main;
|
const { head } = selection.main;
|
||||||
|
|
||||||
const isInsideResolvable =
|
const isInsideResolvable =
|
||||||
doc.toString().slice(0, head).includes(OPEN_MARKER) &&
|
editor.state.sliceDoc(0, head).includes(OPEN_MARKER) &&
|
||||||
doc.toString().slice(head, doc.length).includes(CLOSE_MARKER);
|
editor.state.sliceDoc(head, doc.length).includes(CLOSE_MARKER);
|
||||||
|
|
||||||
const insert = isInsideResolvable
|
const insert = isInsideResolvable ? variable : [OPEN_MARKER, variable, CLOSE_MARKER].join(' ');
|
||||||
? variable
|
|
||||||
: [OPEN_MARKER, variable, CLOSE_MARKER].join(' ');
|
|
||||||
|
|
||||||
this.editor.dispatch({
|
editor.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
from: head,
|
from: head,
|
||||||
insert,
|
insert,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
|
||||||
},
|
focus();
|
||||||
});
|
setCursorPosition(head + insert.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ itemSelected });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
|
|
@ -17,11 +17,10 @@
|
||||||
<InlineExpressionEditorInput
|
<InlineExpressionEditorInput
|
||||||
ref="inlineInput"
|
ref="inlineInput"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
|
:path="path"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:target-item="hoveringItem"
|
|
||||||
:rows="rows"
|
:rows="rows"
|
||||||
:additional-data="additionalExpressionData"
|
:additional-data="additionalExpressionData"
|
||||||
:path="path"
|
|
||||||
:event-bus="eventBus"
|
:event-bus="eventBus"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
|
@ -36,7 +35,7 @@
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
:class="$style['expression-editor-modal-opener']"
|
:class="$style['expression-editor-modal-opener']"
|
||||||
data-test-id="expander"
|
data-test-id="expander"
|
||||||
@click="$emit('modalOpenerClick')"
|
@click="$emit('modal-opener-click')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<InlineExpressionEditorOutput
|
<InlineExpressionEditorOutput
|
||||||
|
@ -62,7 +61,6 @@ import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
|
||||||
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
||||||
|
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
import type { TargetItem } from '@/Interface';
|
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { type EventBus, createEventBus } from 'n8n-design-system/utils';
|
import { type EventBus, createEventBus } from 'n8n-design-system/utils';
|
||||||
|
@ -79,9 +77,11 @@ export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
path: {
|
path: {
|
||||||
type: String,
|
type: String,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
isReadOnly: {
|
isReadOnly: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
@ -104,6 +104,7 @@ export default defineComponent({
|
||||||
default: () => createEventBus(),
|
default: () => createEventBus(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ['focus', 'blur', 'update:model-value', 'modal-opener-click'],
|
||||||
setup() {
|
setup() {
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
return { callDebounced };
|
return { callDebounced };
|
||||||
|
@ -119,9 +120,6 @@ export default defineComponent({
|
||||||
hoveringItemNumber(): number {
|
hoveringItemNumber(): number {
|
||||||
return this.ndvStore.hoveringItemNumber;
|
return this.ndvStore.hoveringItemNumber;
|
||||||
},
|
},
|
||||||
hoveringItem(): TargetItem | null {
|
|
||||||
return this.ndvStore.getHoveringItem;
|
|
||||||
},
|
|
||||||
isDragging(): boolean {
|
isDragging(): boolean {
|
||||||
return this.ndvStore.isDraggableDragging;
|
return this.ndvStore.isDraggableDragging;
|
||||||
},
|
},
|
||||||
|
@ -141,9 +139,9 @@ export default defineComponent({
|
||||||
|
|
||||||
this.$emit('focus');
|
this.$emit('focus');
|
||||||
},
|
},
|
||||||
onBlur(event: FocusEvent | KeyboardEvent) {
|
onBlur(event?: FocusEvent | KeyboardEvent) {
|
||||||
if (
|
if (
|
||||||
event.target instanceof Element &&
|
event?.target instanceof Element &&
|
||||||
Array.from(event.target.classList).some((_class) => _class.includes('resizer'))
|
Array.from(event.target.classList).some((_class) => _class.includes('resizer'))
|
||||||
) {
|
) {
|
||||||
return; // prevent blur on resizing
|
return; // prevent blur on resizing
|
||||||
|
@ -169,16 +167,13 @@ export default defineComponent({
|
||||||
this.$telemetry.track('User closed Expression Editor', telemetryPayload);
|
this.$telemetry.track('User closed Expression Editor', telemetryPayload);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onChange(value: { value: string; segments: Segment[] }) {
|
onChange({ value, segments }: { value: string; segments: Segment[] }) {
|
||||||
void this.callDebounced(this.onChangeDebounced, { debounceTime: 100, trailing: true }, value);
|
|
||||||
},
|
|
||||||
onChangeDebounced({ value, segments }: { value: string; segments: Segment[] }) {
|
|
||||||
this.segments = segments;
|
this.segments = segments;
|
||||||
|
|
||||||
if (this.isDragging) return;
|
if (this.isDragging) return;
|
||||||
if (value === '=' + this.modelValue) return; // prevent report on change of target item
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { history } from '@codemirror/commands';
|
import { history } from '@codemirror/commands';
|
||||||
import {
|
import {
|
||||||
LanguageSupport,
|
LanguageSupport,
|
||||||
|
@ -14,33 +14,25 @@ import {
|
||||||
foldGutter,
|
foldGutter,
|
||||||
indentOnInput,
|
indentOnInput,
|
||||||
} from '@codemirror/language';
|
} from '@codemirror/language';
|
||||||
import type { Extension } from '@codemirror/state';
|
import { Prec } from '@codemirror/state';
|
||||||
import { EditorState, Prec } from '@codemirror/state';
|
|
||||||
import type { ViewUpdate } from '@codemirror/view';
|
|
||||||
import {
|
import {
|
||||||
EditorView,
|
|
||||||
dropCursor,
|
dropCursor,
|
||||||
highlightActiveLine,
|
highlightActiveLine,
|
||||||
highlightActiveLineGutter,
|
highlightActiveLineGutter,
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { autoCloseTags, html, htmlLanguage } from 'codemirror-lang-html-n8n';
|
|
||||||
import { format } from 'prettier';
|
import { format } from 'prettier';
|
||||||
import jsParser from 'prettier/plugins/babel';
|
import jsParser from 'prettier/plugins/babel';
|
||||||
import * as estree from 'prettier/plugins/estree';
|
import * as estree from 'prettier/plugins/estree';
|
||||||
import htmlParser from 'prettier/plugins/html';
|
import htmlParser from 'prettier/plugins/html';
|
||||||
import cssParser from 'prettier/plugins/postcss';
|
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 { htmlEditorEventBus } from '@/event-bus';
|
||||||
import { expressionManager } from '@/mixins/expressionManager';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
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 {
|
import {
|
||||||
autocompleteKeyMap,
|
autocompleteKeyMap,
|
||||||
enterKeyMap,
|
enterKeyMap,
|
||||||
|
@ -48,60 +40,37 @@ import {
|
||||||
tabKeyMap,
|
tabKeyMap,
|
||||||
} from '@/plugins/codemirror/keymap';
|
} from '@/plugins/codemirror/keymap';
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
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({
|
type Props = {
|
||||||
name: 'HtmlEditor',
|
modelValue: string;
|
||||||
mixins: [expressionManager],
|
rows?: number;
|
||||||
props: {
|
isReadOnly?: boolean;
|
||||||
modelValue: {
|
fullscreen?: boolean;
|
||||||
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();
|
|
||||||
},
|
|
||||||
|
|
||||||
extensions(): Extension[] {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
function htmlWithCompletions() {
|
rows: 4,
|
||||||
return new LanguageSupport(
|
isReadOnly: false,
|
||||||
htmlLanguage,
|
fullscreen: false,
|
||||||
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:model-value', value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const htmlEditor = ref<HTMLElement>();
|
||||||
|
const editorValue = ref<string>(props.modelValue);
|
||||||
|
const extensions = computed(() => [
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
this.disableExpressionCompletions ? html() : htmlWithCompletions(),
|
new LanguageSupport(
|
||||||
|
htmlLanguage,
|
||||||
|
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
|
||||||
|
),
|
||||||
autoCloseTags,
|
autoCloseTags,
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
|
@ -109,10 +78,10 @@ export default defineComponent({
|
||||||
),
|
),
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
codeNodeEditorTheme({
|
codeNodeEditorTheme({
|
||||||
isReadOnly: this.isReadOnly,
|
isReadOnly: props.isReadOnly,
|
||||||
maxHeight: this.fillParent ? '100%' : '40vh',
|
maxHeight: props.fullscreen ? '100%' : '40vh',
|
||||||
minHeight: '20vh',
|
minHeight: '20vh',
|
||||||
rows: this.rows,
|
rows: props.rows,
|
||||||
highlightColors: 'html',
|
highlightColors: 'html',
|
||||||
}),
|
}),
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
|
@ -122,32 +91,26 @@ export default defineComponent({
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
EditorView.editable.of(!this.isReadOnly),
|
]);
|
||||||
EditorState.readOnly.of(this.isReadOnly),
|
const {
|
||||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
editor: editorRef,
|
||||||
if (!this.editor) return;
|
segments,
|
||||||
|
readEditorValue,
|
||||||
|
} = useExpressionEditor({
|
||||||
|
editorRef: htmlEditor,
|
||||||
|
editorValue,
|
||||||
|
extensions,
|
||||||
|
});
|
||||||
|
|
||||||
this.completionStatus = completionStatus(viewUpdate.view.state);
|
const sections = computed(() => {
|
||||||
|
const editor = toValue(editorRef);
|
||||||
|
if (!editor) return [];
|
||||||
|
const { state } = editor;
|
||||||
|
|
||||||
if (!viewUpdate.docChanged) return;
|
const fullTree = ensureSyntaxTree(state, state.doc.length);
|
||||||
|
|
||||||
// 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());
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
sections(): Section[] {
|
|
||||||
const { state } = this.editor;
|
|
||||||
|
|
||||||
const fullTree = ensureSyntaxTree(this.editor.state, this.doc.length);
|
|
||||||
|
|
||||||
if (fullTree === null) {
|
if (fullTree === null) {
|
||||||
throw new Error(`Failed to parse syntax tree for: ${this.doc}`);
|
throw new Error('Failed to parse syntax tree');
|
||||||
}
|
}
|
||||||
|
|
||||||
let documentRange: Range = [-1, -1];
|
let documentRange: Range = [-1, -1];
|
||||||
|
@ -193,52 +156,24 @@ export default defineComponent({
|
||||||
return [...styleSections, ...scriptSections, ...htmlSections].sort(
|
return [...styleSections, ...scriptSections, ...htmlSections].sort(
|
||||||
(a, b) => a.range[0] - b.range[0],
|
(a, b) => a.range[0] - b.range[0],
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
function isMissingHtmlTags() {
|
||||||
htmlEditorEventBus.on('format-html', this.format);
|
const zerothSection = sections.value.at(0);
|
||||||
|
|
||||||
let doc = this.modelValue;
|
|
||||||
|
|
||||||
if (this.modelValue === '' && this.rows > 0) {
|
|
||||||
doc = '\n'.repeat(this.rows - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = EditorState.create({ doc, extensions: this.extensions });
|
|
||||||
|
|
||||||
this.editor = new EditorView({ parent: this.root(), state });
|
|
||||||
this.editorState = this.editor.state;
|
|
||||||
|
|
||||||
this.getHighlighter()?.addColor(this.editor, this.resolvableSegments);
|
|
||||||
},
|
|
||||||
|
|
||||||
beforeUnmount() {
|
|
||||||
htmlEditorEventBus.off('format-html', this.format);
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
root() {
|
|
||||||
const rootRef = this.$refs.htmlEditor as HTMLDivElement | undefined;
|
|
||||||
if (!rootRef) {
|
|
||||||
throw new Error('Expected div with ref "htmlEditor"');
|
|
||||||
}
|
|
||||||
|
|
||||||
return rootRef;
|
|
||||||
},
|
|
||||||
|
|
||||||
isMissingHtmlTags() {
|
|
||||||
const zerothSection = this.sections.at(0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!zerothSection?.content.trim().startsWith('<html') &&
|
!zerothSection?.content.trim().startsWith('<html') &&
|
||||||
!zerothSection?.content.trim().endsWith('</html>')
|
!zerothSection?.content.trim().endsWith('</html>')
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
|
||||||
async format() {
|
async function formatHtml() {
|
||||||
if (this.sections.length === 1 && this.isMissingHtmlTags()) {
|
const editor = toValue(editorRef);
|
||||||
const zerothSection = this.sections.at(0) as Section;
|
if (!editor) return;
|
||||||
|
|
||||||
|
const sectionToFormat = sections.value;
|
||||||
|
if (sectionToFormat.length === 1 && isMissingHtmlTags()) {
|
||||||
|
const zerothSection = sectionToFormat.at(0) as Section;
|
||||||
|
|
||||||
const formatted = (
|
const formatted = (
|
||||||
await format(zerothSection.content, {
|
await format(zerothSection.content, {
|
||||||
|
@ -247,14 +182,14 @@ export default defineComponent({
|
||||||
})
|
})
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
return this.editor.dispatch({
|
return editor.dispatch({
|
||||||
changes: { from: 0, to: this.doc.length, insert: formatted },
|
changes: { from: 0, to: editor.state.doc.length, insert: formatted },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatted = [];
|
const formatted = [];
|
||||||
|
|
||||||
for (const { kind, content } of this.sections) {
|
for (const { kind, content } of sections.value) {
|
||||||
if (kind === 'style') {
|
if (kind === 'style') {
|
||||||
const formattedStyle = await format(content, {
|
const formattedStyle = await format(content, {
|
||||||
parser: 'css',
|
parser: 'css',
|
||||||
|
@ -294,17 +229,22 @@ export default defineComponent({
|
||||||
|
|
||||||
if (formatted.length === 0) return;
|
if (formatted.length === 0) return;
|
||||||
|
|
||||||
this.editor.dispatch({
|
editor.dispatch({
|
||||||
changes: { from: 0, to: this.doc.length, insert: formatted.join('\n\n') },
|
changes: { from: 0, to: editor.state.doc.length, insert: formatted.join('\n\n') },
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
|
|
||||||
getHighlighter() {
|
watch(segments.display, () => {
|
||||||
if (this.disableExpressionColoring) return;
|
emit('update:model-value', readEditorValue());
|
||||||
|
});
|
||||||
|
|
||||||
return highlighter;
|
onMounted(() => {
|
||||||
},
|
htmlEditorEventBus.on('format-html', formatHtml);
|
||||||
},
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
htmlEditorEventBus.off('format-html', formatHtml);
|
||||||
|
emit('update:model-value', readEditorValue());
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -2,16 +2,14 @@
|
||||||
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div>
|
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { completionStatus, startCompletion } from '@codemirror/autocomplete';
|
import { startCompletion } from '@codemirror/autocomplete';
|
||||||
import { history } from '@codemirror/commands';
|
import { history } from '@codemirror/commands';
|
||||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
import { Prec } from '@codemirror/state';
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
import type { PropType } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
|
||||||
import { defineComponent, nextTick } from 'vue';
|
|
||||||
|
|
||||||
import { completionManager } from '@/mixins/completionManager';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import { expressionManager } from '@/mixins/expressionManager';
|
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||||
import {
|
import {
|
||||||
autocompleteKeyMap,
|
autocompleteKeyMap,
|
||||||
|
@ -20,152 +18,110 @@ import {
|
||||||
tabKeyMap,
|
tabKeyMap,
|
||||||
} from '@/plugins/codemirror/keymap';
|
} from '@/plugins/codemirror/keymap';
|
||||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
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 { createEventBus, type EventBus } from 'n8n-design-system/utils';
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
import { inputTheme } from './theme';
|
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({
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
name: 'InlineExpressionEditorInput',
|
rows: 5,
|
||||||
mixins: [completionManager, expressionManager],
|
isReadonly: false,
|
||||||
props: {
|
additionalData: () => ({}),
|
||||||
modelValue: {
|
eventBus: () => createEventBus(),
|
||||||
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(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useNDVStore),
|
|
||||||
},
|
|
||||||
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;
|
const emit = defineEmits<{
|
||||||
|
(event: 'change', value: { value: string; segments: Segment[] }): void;
|
||||||
|
(event: 'focus'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
// manual update on external change, e.g. from expression modal or mapping drop
|
const ndvStore = useNDVStore();
|
||||||
|
|
||||||
this.editor?.dispatch({
|
const root = ref<HTMLElement>();
|
||||||
changes: {
|
const extensions = computed(() => [
|
||||||
from: 0,
|
|
||||||
to: this.editor?.state.doc.length,
|
|
||||||
insert: newValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
const extensions = [
|
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
keymap.of([...tabKeyMap(true), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
|
keymap.of([...tabKeyMap(true), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
|
||||||
),
|
),
|
||||||
n8nLang(),
|
n8nLang(),
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
inputTheme({ rows: this.rows }),
|
inputTheme({ rows: props.rows }),
|
||||||
history(),
|
history(),
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
editableConf.of(EditorView.editable.of(!this.isReadOnly)),
|
]);
|
||||||
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
|
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||||
EditorView.domEventHandlers({
|
const {
|
||||||
focus: () => {
|
editor: editorRef,
|
||||||
this.$emit('focus');
|
segments,
|
||||||
},
|
readEditorValue,
|
||||||
}),
|
setCursorPosition,
|
||||||
EditorView.updateListener.of((viewUpdate) => {
|
hasFocus,
|
||||||
if (!this.editor) return;
|
focus,
|
||||||
|
} = useExpressionEditor({
|
||||||
this.completionStatus = completionStatus(viewUpdate.view.state);
|
editorRef: root,
|
||||||
|
editorValue,
|
||||||
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,
|
extensions,
|
||||||
}),
|
isReadOnly: props.isReadonly,
|
||||||
});
|
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
|
||||||
|
additionalData: props.additionalData,
|
||||||
|
});
|
||||||
|
|
||||||
this.editorState = this.editor.state;
|
defineExpose({
|
||||||
|
focus: () => {
|
||||||
|
setCursorPosition('lastExpression');
|
||||||
|
focus();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
async function onDrop() {
|
||||||
|
const editor = toValue(editorRef);
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
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();
|
await nextTick();
|
||||||
this.focus();
|
focus();
|
||||||
|
|
||||||
const END_OF_EXPRESSION = ' }}';
|
setCursorPosition('lastExpression');
|
||||||
const value = this.editor.state.sliceDoc(0);
|
|
||||||
const cursorPosition = Math.max(value.lastIndexOf(END_OF_EXPRESSION), 0);
|
|
||||||
|
|
||||||
this.setCursorPosition(cursorPosition);
|
if (!ndvStore.isAutocompleteOnboarded) {
|
||||||
|
startCompletion(editor);
|
||||||
if (!this.ndvStore.isAutocompleteOnboarded) {
|
|
||||||
startCompletion(this.editor as EditorView);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
props.eventBus.on('drop', onDrop);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
props.eventBus.off('drop', onDrop);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -90,7 +90,7 @@
|
||||||
:rows="getArgument('rows')"
|
:rows="getArgument('rows')"
|
||||||
:disable-expression-coloring="!isHtmlNode(node)"
|
:disable-expression-coloring="!isHtmlNode(node)"
|
||||||
:disable-expression-completions="!isHtmlNode(node)"
|
:disable-expression-completions="!isHtmlNode(node)"
|
||||||
fill-parent
|
fullscreen
|
||||||
@update:model-value="valueChangedDebounced"
|
@update:model-value="valueChangedDebounced"
|
||||||
/>
|
/>
|
||||||
<SqlEditor
|
<SqlEditor
|
||||||
|
@ -99,7 +99,7 @@
|
||||||
:dialect="getArgument('sqlDialect')"
|
:dialect="getArgument('sqlDialect')"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:rows="getArgument('rows')"
|
:rows="getArgument('rows')"
|
||||||
fill-parent
|
fullscreen
|
||||||
@update:model-value="valueChangedDebounced"
|
@update:model-value="valueChangedDebounced"
|
||||||
/>
|
/>
|
||||||
<JsEditor
|
<JsEditor
|
||||||
|
|
|
@ -1,31 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-on-click-outside="onBlur" :class="$style.sqlEditor">
|
<div :class="$style.sqlEditor">
|
||||||
<div :class="$style.codemirror" ref="sqlEditor" data-test-id="sql-editor-container"></div>
|
<div ref="sqlEditor" :class="$style.codemirror" data-test-id="sql-editor-container"></div>
|
||||||
<slot name="suffix" />
|
<slot name="suffix" />
|
||||||
<InlineExpressionEditorOutput
|
<InlineExpressionEditorOutput
|
||||||
v-if="!fillParent"
|
v-if="!fullscreen"
|
||||||
:segments="segments"
|
:segments="segments"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:visible="isFocused"
|
:visible="hasFocus"
|
||||||
:hovering-item-number="hoveringItemNumber"
|
:hovering-item-number="hoveringItemNumber"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
|
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
|
||||||
import { EXPRESSIONS_DOCS_URL } from '@/constants';
|
|
||||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||||
import { expressionManager } from '@/mixins/expressionManager';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
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 { ifNotIn } from '@codemirror/autocomplete';
|
||||||
import { history, toggleComment } from '@codemirror/commands';
|
import { history, toggleComment } from '@codemirror/commands';
|
||||||
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
|
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
|
||||||
import { type Extension, type Line, Prec } from '@codemirror/state';
|
import { Prec, type Line } from '@codemirror/state';
|
||||||
import { EditorState } from '@codemirror/state';
|
|
||||||
import type { ViewUpdate } from '@codemirror/view';
|
|
||||||
import {
|
import {
|
||||||
EditorView,
|
EditorView,
|
||||||
dropCursor,
|
dropCursor,
|
||||||
|
@ -34,7 +38,6 @@ import {
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import type { SQLDialect as SQLDialectType } from '@n8n/codemirror-lang-sql';
|
|
||||||
import {
|
import {
|
||||||
Cassandra,
|
Cassandra,
|
||||||
MSSQL,
|
MSSQL,
|
||||||
|
@ -46,15 +49,8 @@ import {
|
||||||
StandardSQL,
|
StandardSQL,
|
||||||
keywordCompletionSource,
|
keywordCompletionSource,
|
||||||
} from '@n8n/codemirror-lang-sql';
|
} from '@n8n/codemirror-lang-sql';
|
||||||
import { defineComponent } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||||
import {
|
|
||||||
autocompleteKeyMap,
|
|
||||||
enterKeyMap,
|
|
||||||
historyKeyMap,
|
|
||||||
tabKeyMap,
|
|
||||||
} from '@/plugins/codemirror/keymap';
|
|
||||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
|
||||||
|
|
||||||
const SQL_DIALECTS = {
|
const SQL_DIALECTS = {
|
||||||
StandardSQL,
|
StandardSQL,
|
||||||
|
@ -67,67 +63,28 @@ const SQL_DIALECTS = {
|
||||||
PLSQL,
|
PLSQL,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type SQLEditorData = {
|
type Props = {
|
||||||
editor: EditorView | null;
|
modelValue: string;
|
||||||
editorState: EditorState | null;
|
dialect?: keyof typeof SQL_DIALECTS;
|
||||||
isFocused: boolean;
|
rows?: number;
|
||||||
skipSegments: string[];
|
isReadOnly?: boolean;
|
||||||
expressionsDocsUrl: string;
|
fullscreen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
name: 'SqlEditor',
|
dialect: 'StandardSQL',
|
||||||
components: {
|
rows: 4,
|
||||||
InlineExpressionEditorOutput,
|
isReadOnly: false,
|
||||||
},
|
fullscreen: false,
|
||||||
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;
|
|
||||||
|
|
||||||
|
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() {
|
function sqlWithN8nLanguageSupport() {
|
||||||
return new LanguageSupport(dialect.language, [
|
return new LanguageSupport(dialect.language, [
|
||||||
dialect.language.data.of({
|
dialect.language.data.of({
|
||||||
|
@ -137,28 +94,21 @@ export default defineComponent({
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensions = [
|
const baseExtensions = [
|
||||||
sqlWithN8nLanguageSupport(),
|
sqlWithN8nLanguageSupport(),
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
codeNodeEditorTheme({
|
codeNodeEditorTheme({
|
||||||
isReadOnly: this.isReadOnly,
|
isReadOnly: props.isReadOnly,
|
||||||
maxHeight: this.fillParent ? '100%' : '40vh',
|
maxHeight: props.fullscreen ? '100%' : '40vh',
|
||||||
minHeight: '10vh',
|
minHeight: '10vh',
|
||||||
rows: this.rows,
|
rows: props.rows,
|
||||||
}),
|
}),
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.domEventHandlers({
|
|
||||||
focus: () => {
|
|
||||||
this.isFocused = true;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
EditorState.readOnly.of(this.isReadOnly),
|
|
||||||
EditorView.editable.of(!this.isReadOnly),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!this.isReadOnly) {
|
if (!props.isReadOnly) {
|
||||||
extensions.push(
|
return baseExtensions.concat([
|
||||||
history(),
|
history(),
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
keymap.of([
|
keymap.of([
|
||||||
|
@ -176,61 +126,79 @@ export default defineComponent({
|
||||||
foldGutter(),
|
foldGutter(),
|
||||||
dropCursor(),
|
dropCursor(),
|
||||||
bracketMatching(),
|
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;
|
return baseExtensions;
|
||||||
},
|
});
|
||||||
},
|
const editorValue = ref(props.modelValue);
|
||||||
mounted() {
|
const {
|
||||||
if (!this.isReadOnly) codeNodeEditorEventBus.on('error-line-number', this.highlightLine);
|
editor,
|
||||||
|
segments: { all: segments },
|
||||||
|
readEditorValue,
|
||||||
|
hasFocus,
|
||||||
|
} = useExpressionEditor({
|
||||||
|
editorRef: sqlEditor,
|
||||||
|
editorValue,
|
||||||
|
extensions,
|
||||||
|
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens'],
|
||||||
|
isReadOnly: props.isReadOnly,
|
||||||
|
});
|
||||||
|
const ndvStore = useNDVStore();
|
||||||
|
|
||||||
const state = EditorState.create({ doc: this.modelValue, extensions: this.extensions });
|
const hoveringItemNumber = computed(() => {
|
||||||
|
return ndvStore.hoveringItemNumber;
|
||||||
|
});
|
||||||
|
|
||||||
this.editor = new EditorView({ parent: this.$refs.sqlEditor as HTMLDivElement, state });
|
watch(
|
||||||
this.editorState = this.editor.state;
|
() => props.modelValue,
|
||||||
highlighter.addColor(this.editor as EditorView, this.resolvableSegments);
|
(newValue) => {
|
||||||
|
editorValue.value = newValue;
|
||||||
},
|
},
|
||||||
methods: {
|
);
|
||||||
onBlur() {
|
|
||||||
this.isFocused = false;
|
watch(segments, () => {
|
||||||
},
|
emit('update:model-value', readEditorValue());
|
||||||
line(lineNumber: number): Line | null {
|
});
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
return this.editor?.state.doc.line(lineNumber) ?? null;
|
return editor.value?.state.doc.line(lineNumber) ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
highlightLine(lineNumber: number | 'final') {
|
|
||||||
if (!this.editor) return;
|
function highlightLine(lineNumber: number | 'final') {
|
||||||
|
if (!editor.value) return;
|
||||||
|
|
||||||
if (lineNumber === 'final') {
|
if (lineNumber === 'final') {
|
||||||
this.editor.dispatch({
|
editor.value.dispatch({
|
||||||
selection: { anchor: this.modelValue.length },
|
selection: { anchor: editor.value.state.doc.length },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const line = this.line(lineNumber);
|
const lineToHighlight = line(lineNumber);
|
||||||
|
|
||||||
if (!line) return;
|
if (!lineToHighlight) return;
|
||||||
|
|
||||||
this.editor.dispatch({
|
editor.value.dispatch({
|
||||||
selection: { anchor: line.from },
|
selection: { anchor: lineToHighlight.from },
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
|
|
|
@ -1,13 +1,21 @@
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import ExpressionEditorModalInput from '@/components/ExpressionEditorModal/ExpressionEditorModalInput.vue';
|
import ExpressionEditorModalInput from '@/components/ExpressionEditorModal/ExpressionEditorModalInput.vue';
|
||||||
|
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
|
||||||
const renderComponent = createComponentRenderer(ExpressionEditorModalInput);
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
|
|
||||||
const originalRangeGetClientRects = Range.prototype.getClientRects;
|
|
||||||
|
|
||||||
describe('ExpressionParameterInput', () => {
|
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(() => {
|
beforeAll(() => {
|
||||||
Range.prototype.getBoundingClientRect = vi.fn();
|
Range.prototype.getBoundingClientRect = vi.fn();
|
||||||
Range.prototype.getClientRects = () => ({
|
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);
|
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 { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
|
||||||
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
|
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
|
||||||
|
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
|
||||||
const renderComponent = createComponentRenderer(ExpressionParameterInput);
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
let pinia: ReturnType<typeof createPinia>;
|
|
||||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
|
||||||
let ndvStore: ReturnType<typeof useNDVStore>;
|
|
||||||
|
|
||||||
describe('ExpressionParameterInput', () => {
|
describe('ExpressionParameterInput', () => {
|
||||||
|
const renderComponent = createComponentRenderer(ExpressionParameterInput);
|
||||||
|
let pinia: TestingPinia;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pinia = createPinia();
|
pinia = createTestingPinia();
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
workflowsStore = useWorkflowsStore();
|
|
||||||
ndvStore = useNDVStore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
|
@ -31,7 +25,7 @@ describe('ExpressionParameterInput', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await userEvent.click(getByTestId('expander'));
|
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 () => {
|
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 { STORES } from '@/constants';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
|
||||||
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
|
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
|
||||||
import { expressionManager } from '@/mixins/expressionManager';
|
|
||||||
import type { TargetItem } from '@/Interface';
|
|
||||||
import { renderComponent } from '@/__tests__/render';
|
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 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 = {
|
const DEFAULT_SETUP = {
|
||||||
props: {
|
props: {
|
||||||
dialect: 'PostgreSQL',
|
dialect: 'PostgreSQL',
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
},
|
},
|
||||||
global: {
|
};
|
||||||
plugins: [
|
|
||||||
createTestingPinia({
|
describe('SqlEditor.vue', () => {
|
||||||
|
const pinia = createTestingPinia({
|
||||||
initialState: {
|
initialState: {
|
||||||
[STORES.SETTINGS]: {
|
[STORES.SETTINGS]: {
|
||||||
settings: SETTINGS_STORE_DEFAULT_STATE.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);
|
||||||
|
|
||||||
describe('SQL Editor Preview Tests', () => {
|
const mockResolveExpression = () => {
|
||||||
beforeEach(() => {
|
const mock = vi.fn();
|
||||||
vi.spyOn(expressionManager.methods, 'resolve').mockImplementation(
|
vi.spyOn(workflowHelpers, 'useWorkflowHelpers').mockReturnValueOnce({
|
||||||
(resolvable: string, _targetItem?: TargetItem) => {
|
...workflowHelpers.useWorkflowHelpers({ router: useRouter() }),
|
||||||
return { resolved: RESOLVABLES[resolvable] };
|
resolveExpression: mock,
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
return mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -56,11 +68,14 @@ describe('SQL Editor Preview Tests', () => {
|
||||||
modelValue: 'SELECT * FROM users',
|
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 () => {
|
it('renders basic query with expression', async () => {
|
||||||
|
mockResolveExpression().mockReturnValueOnce('users');
|
||||||
const { getByTestId } = renderComponent(SqlEditor, {
|
const { getByTestId } = renderComponent(SqlEditor, {
|
||||||
...DEFAULT_SETUP,
|
...DEFAULT_SETUP,
|
||||||
props: {
|
props: {
|
||||||
|
@ -68,11 +83,14 @@ describe('SQL Editor Preview Tests', () => {
|
||||||
modelValue: 'SELECT * FROM {{ $json.table }}',
|
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 () => {
|
it('renders resolved expressions with dot between resolvables', async () => {
|
||||||
|
mockResolveExpression().mockReturnValueOnce('public.users');
|
||||||
const { getByTestId } = renderComponent(SqlEditor, {
|
const { getByTestId } = renderComponent(SqlEditor, {
|
||||||
...DEFAULT_SETUP,
|
...DEFAULT_SETUP,
|
||||||
props: {
|
props: {
|
||||||
|
@ -80,11 +98,19 @@ describe('SQL Editor Preview Tests', () => {
|
||||||
modelValue: 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}',
|
modelValue: 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await waitAllPromises();
|
await waitFor(() =>
|
||||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM public.users');
|
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
||||||
|
'SELECT * FROM public.users',
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders resolved expressions which resolve to 0', async () => {
|
it('renders resolved expressions which resolve to 0', async () => {
|
||||||
|
mockResolveExpression()
|
||||||
|
.mockReturnValueOnce('public')
|
||||||
|
.mockReturnValueOnce('users')
|
||||||
|
.mockReturnValueOnce('id')
|
||||||
|
.mockReturnValueOnce(0);
|
||||||
const { getByTestId } = renderComponent(SqlEditor, {
|
const { getByTestId } = renderComponent(SqlEditor, {
|
||||||
...DEFAULT_SETUP,
|
...DEFAULT_SETUP,
|
||||||
props: {
|
props: {
|
||||||
|
@ -93,13 +119,19 @@ describe('SQL Editor Preview Tests', () => {
|
||||||
'SELECT * FROM {{ $json.schema }}.{{ $json.table }} WHERE {{ $json.id }} > {{ $json.limit - 10 }}',
|
'SELECT * FROM {{ $json.schema }}.{{ $json.table }} WHERE {{ $json.id }} > {{ $json.limit - 10 }}',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await waitAllPromises();
|
await waitFor(() =>
|
||||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
||||||
'SELECT * FROM public.users WHERE id > 0',
|
'SELECT * FROM public.users WHERE id > 0',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps query formatting in rendered output', async () => {
|
it('keeps query formatting in rendered output', async () => {
|
||||||
|
mockResolveExpression()
|
||||||
|
.mockReturnValueOnce('public')
|
||||||
|
.mockReturnValueOnce('users')
|
||||||
|
.mockReturnValueOnce(0)
|
||||||
|
.mockReturnValueOnce(false);
|
||||||
const { getByTestId } = renderComponent(SqlEditor, {
|
const { getByTestId } = renderComponent(SqlEditor, {
|
||||||
...DEFAULT_SETUP,
|
...DEFAULT_SETUP,
|
||||||
props: {
|
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 }};',
|
'SELECT * FROM {{ $json.schema }}.{{ $json.table }}\n WHERE id > {{ $json.limit - 10 }}\n AND active = {{ $json.active }};',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await waitAllPromises();
|
await waitFor(() =>
|
||||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
||||||
'SELECT * FROM public.users WHERE id > 0 AND active = false;',
|
'SELECT * FROM public.users WHERE id > 0 AND active = false;',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// Output should have the same number of lines as the input
|
// Output should have the same number of lines as the input
|
||||||
expect(getByTestId('sql-editor-container').getElementsByClassName('cm-line').length).toEqual(
|
expect(getByTestId('sql-editor-container').getElementsByClassName('cm-line').length).toEqual(
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { insertCompletionText, pickedCompletion } from '@codemirror/autocomplete';
|
||||||
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { beforeEach, describe, vi } from 'vitest';
|
||||||
|
import { useAutocompleteTelemetry } from '../useAutocompleteTelemetry';
|
||||||
|
|
||||||
|
const trackSpy = vi.fn();
|
||||||
|
const setAutocompleteOnboardedSpy = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/composables/useTelemetry', () => ({
|
||||||
|
useTelemetry: vi.fn(() => ({ track: trackSpy })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/ndv.store', () => ({
|
||||||
|
useNDVStore: vi.fn(() => ({
|
||||||
|
activeNode: { type: 'n8n-nodes-base.test' },
|
||||||
|
setAutocompleteOnboarded: setAutocompleteOnboardedSpy,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/n8nRoot.store', () => ({
|
||||||
|
useRootStore: vi.fn(() => ({
|
||||||
|
instanceId: 'test-instance-id',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useAutocompleteTelemetry', () => {
|
||||||
|
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
|
||||||
|
const originalRangeGetClientRects = Range.prototype.getClientRects;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Range.prototype.getBoundingClientRect = vi.fn();
|
||||||
|
Range.prototype.getClientRects = () => ({
|
||||||
|
item: vi.fn(),
|
||||||
|
length: 0,
|
||||||
|
[Symbol.iterator]: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect;
|
||||||
|
Range.prototype.getClientRects = originalRangeGetClientRects;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getEditor = (defaultDoc = '') => {
|
||||||
|
const extensionCompartment = new Compartment();
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: defaultDoc,
|
||||||
|
extensions: [extensionCompartment.of([])],
|
||||||
|
});
|
||||||
|
const editorRoot = document.createElement('div');
|
||||||
|
return {
|
||||||
|
editor: new EditorView({ parent: editorRoot, state }),
|
||||||
|
editorRoot,
|
||||||
|
compartment: extensionCompartment,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
test('should track user autocomplete', async () => {
|
||||||
|
const { editor, compartment } = getEditor('$json.');
|
||||||
|
useAutocompleteTelemetry({
|
||||||
|
editor,
|
||||||
|
parameterPath: 'param',
|
||||||
|
compartment,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.dispatch({
|
||||||
|
...insertCompletionText(editor.state, 'foo', 6, 6),
|
||||||
|
annotations: pickedCompletion.of({ label: 'foo' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(trackSpy).toHaveBeenCalledWith('User autocompleted code', {
|
||||||
|
category: 'n/a',
|
||||||
|
context: '$json',
|
||||||
|
field_name: 'param',
|
||||||
|
field_type: 'expression',
|
||||||
|
inserted_text: 'foo',
|
||||||
|
instance_id: 'test-instance-id',
|
||||||
|
node_type: 'n8n-nodes-base.test',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should mark user as onboarded on autocomplete', async () => {
|
||||||
|
const { editor, compartment } = getEditor();
|
||||||
|
useAutocompleteTelemetry({
|
||||||
|
editor,
|
||||||
|
parameterPath: 'param',
|
||||||
|
compartment,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.dispatch({
|
||||||
|
...insertCompletionText(editor.state, 'foo', 0, 0),
|
||||||
|
annotations: pickedCompletion.of({ label: 'foo' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(setAutocompleteOnboardedSpy).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,268 @@
|
||||||
|
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { beforeEach, describe, vi } from 'vitest';
|
||||||
|
import { ref, toValue } from 'vue';
|
||||||
|
import { n8nLang } from '../../plugins/codemirror/n8nLang';
|
||||||
|
import { useExpressionEditor } from '../useExpressionEditor';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { EditorSelection } from '@codemirror/state';
|
||||||
|
|
||||||
|
vi.mock('@/composables/useAutocompleteTelemetry', () => ({
|
||||||
|
useAutocompleteTelemetry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/ndv.store', () => ({
|
||||||
|
useNDVStore: vi.fn(() => ({
|
||||||
|
activeNode: { type: 'n8n-nodes-base.test' },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useExpressionEditor', () => {
|
||||||
|
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
|
||||||
|
const originalRangeGetClientRects = Range.prototype.getClientRects;
|
||||||
|
|
||||||
|
const mockResolveExpression = () => {
|
||||||
|
const mock = vi.fn();
|
||||||
|
vi.spyOn(workflowHelpers, 'useWorkflowHelpers').mockReturnValueOnce({
|
||||||
|
...workflowHelpers.useWorkflowHelpers({ router: useRouter() }),
|
||||||
|
resolveExpression: mock,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Range.prototype.getBoundingClientRect = vi.fn();
|
||||||
|
Range.prototype.getClientRects = () => ({
|
||||||
|
item: vi.fn(),
|
||||||
|
length: 0,
|
||||||
|
[Symbol.iterator]: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect;
|
||||||
|
Range.prototype.getClientRects = originalRangeGetClientRects;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create an editor', async () => {
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const { editor } = useExpressionEditor({
|
||||||
|
editorRef: root,
|
||||||
|
});
|
||||||
|
|
||||||
|
root.value = document.createElement('div');
|
||||||
|
|
||||||
|
await waitFor(() => expect(toValue(editor)).toBeInstanceOf(EditorView));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should calculate segments', async () => {
|
||||||
|
mockResolveExpression().mockReturnValueOnce(15);
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const { segments } = useExpressionEditor({
|
||||||
|
editorRef: root,
|
||||||
|
editorValue: 'before {{ $json.test.length }} after',
|
||||||
|
extensions: [n8nLang()],
|
||||||
|
});
|
||||||
|
|
||||||
|
root.value = document.createElement('div');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toValue(segments.all)).toEqual([
|
||||||
|
{
|
||||||
|
from: 0,
|
||||||
|
kind: 'plaintext',
|
||||||
|
plaintext: 'before ',
|
||||||
|
to: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
error: null,
|
||||||
|
from: 7,
|
||||||
|
kind: 'resolvable',
|
||||||
|
resolvable: '{{ $json.test.length }}',
|
||||||
|
resolved: '15',
|
||||||
|
state: 'valid',
|
||||||
|
to: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 30,
|
||||||
|
kind: 'plaintext',
|
||||||
|
plaintext: ' after',
|
||||||
|
to: 36,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(toValue(segments.resolvable)).toEqual([
|
||||||
|
{
|
||||||
|
error: null,
|
||||||
|
from: 7,
|
||||||
|
kind: 'resolvable',
|
||||||
|
resolvable: '{{ $json.test.length }}',
|
||||||
|
resolved: '15',
|
||||||
|
state: 'valid',
|
||||||
|
to: 30,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(toValue(segments.plaintext)).toEqual([
|
||||||
|
{
|
||||||
|
from: 0,
|
||||||
|
kind: 'plaintext',
|
||||||
|
plaintext: 'before ',
|
||||||
|
to: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 30,
|
||||||
|
kind: 'plaintext',
|
||||||
|
plaintext: ' after',
|
||||||
|
to: 36,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readEditorValue()', () => {
|
||||||
|
test('should return the full editor value (unresolved)', async () => {
|
||||||
|
mockResolveExpression().mockReturnValueOnce(15);
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const { readEditorValue } = useExpressionEditor({
|
||||||
|
editorRef: root,
|
||||||
|
editorValue: 'before {{ $json.test.length }} after',
|
||||||
|
extensions: [n8nLang()],
|
||||||
|
});
|
||||||
|
|
||||||
|
root.value = document.createElement('div');
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(readEditorValue()).toEqual('before {{ $json.test.length }} after'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setCursorPosition()', () => {
|
||||||
|
test('should set cursor position to number correctly', async () => {
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editorValue = 'text here';
|
||||||
|
const { editor, setCursorPosition } = useExpressionEditor({
|
||||||
|
editorRef: root,
|
||||||
|
editorValue,
|
||||||
|
extensions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
root.value = document.createElement('div');
|
||||||
|
await waitFor(() => toValue(editor));
|
||||||
|
setCursorPosition(4);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set cursor position to end correctly', async () => {
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editorValue = 'text here';
|
||||||
|
const correctPosition = editorValue.length;
|
||||||
|
const { editor, setCursorPosition } = useExpressionEditor({
|
||||||
|
editorRef: root,
|
||||||
|
editorValue,
|
||||||
|
extensions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
root.value = document.createElement('div');
|
||||||
|
await waitFor(() => toValue(editor));
|
||||||
|
setCursorPosition('end');
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(correctPosition)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set cursor position to last expression correctly', async () => {
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editorValue = 'text {{ $json.foo }} {{ $json.bar }} here';
|
||||||
|
const correctPosition = editorValue.indexOf('bar') + 'bar'.length;
|
||||||
|
const { editor, setCursorPosition } = useExpressionEditor({
|
||||||
|
editorRef: root,
|
||||||
|
editorValue,
|
||||||
|
extensions: [n8nLang()],
|
||||||
|
});
|
||||||
|
|
||||||
|
root.value = document.createElement('div');
|
||||||
|
await waitFor(() => toValue(editor));
|
||||||
|
setCursorPosition('lastExpression');
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(correctPosition)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('select()', () => {
|
||||||
|
test('should select number range', async () => {
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editorValue = 'text here';
|
||||||
|
const { editor, select } = useExpressionEditor({
|
||||||
|
editorRef: root,
|
||||||
|
editorValue,
|
||||||
|
extensions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
root.value = document.createElement('div');
|
||||||
|
await waitFor(() => toValue(editor));
|
||||||
|
select(4, 7);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4, 7)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should select until end', async () => {
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editorValue = 'text here';
|
||||||
|
const { editor, select } = useExpressionEditor({
|
||||||
|
editorRef: root,
|
||||||
|
editorValue,
|
||||||
|
extensions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
root.value = document.createElement('div');
|
||||||
|
await waitFor(() => toValue(editor));
|
||||||
|
select(4, 'end');
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4, 9)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectAll()', () => {
|
||||||
|
test('should select all', async () => {
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editorValue = 'text here';
|
||||||
|
const { editor, selectAll } = useExpressionEditor({
|
||||||
|
editorRef: root,
|
||||||
|
editorValue,
|
||||||
|
extensions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
root.value = document.createElement('div');
|
||||||
|
await waitFor(() => toValue(editor));
|
||||||
|
selectAll();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(0, 9)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
114
packages/editor-ui/src/composables/useAutocompleteTelemetry.ts
Normal file
114
packages/editor-ui/src/composables/useAutocompleteTelemetry.ts
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import { type MaybeRefOrGetter, computed, toValue, watchEffect } from 'vue';
|
||||||
|
import { ExpressionExtensions } from 'n8n-workflow';
|
||||||
|
import { EditorView, type ViewUpdate } from '@codemirror/view';
|
||||||
|
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||||
|
import { useTelemetry } from '../composables/useTelemetry';
|
||||||
|
import type { Compartment } from '@codemirror/state';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
|
export const useAutocompleteTelemetry = ({
|
||||||
|
editor: editorRef,
|
||||||
|
parameterPath,
|
||||||
|
compartment,
|
||||||
|
}: {
|
||||||
|
editor: MaybeRefOrGetter<EditorView | undefined>;
|
||||||
|
parameterPath: MaybeRefOrGetter<string>;
|
||||||
|
compartment: MaybeRefOrGetter<Compartment>;
|
||||||
|
}) => {
|
||||||
|
const ndvStore = useNDVStore();
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
|
const expressionExtensionsCategories = computed(() => {
|
||||||
|
return ExpressionExtensions.reduce<Record<string, string | undefined>>((acc, cur) => {
|
||||||
|
for (const fnName of Object.keys(cur.functions)) {
|
||||||
|
acc[fnName] = cur.typeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
function findCompletionBaseStartIndex(fromIndex: number) {
|
||||||
|
const editor = toValue(editorRef);
|
||||||
|
|
||||||
|
if (!editor) return -1;
|
||||||
|
|
||||||
|
const INDICATORS = [
|
||||||
|
' $', // proxy
|
||||||
|
'{ ', // primitive
|
||||||
|
];
|
||||||
|
|
||||||
|
const doc = editor.state.doc.toString();
|
||||||
|
|
||||||
|
for (let index = fromIndex; index > 0; index--) {
|
||||||
|
if (INDICATORS.some((indicator) => indicator === doc[index] + doc[index + 1])) {
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackCompletion(viewUpdate: ViewUpdate, path: string) {
|
||||||
|
const editor = toValue(editorRef);
|
||||||
|
|
||||||
|
if (!editor) return;
|
||||||
|
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
|
||||||
|
|
||||||
|
if (!completionTx) return;
|
||||||
|
|
||||||
|
ndvStore.setAutocompleteOnboarded();
|
||||||
|
|
||||||
|
let completion = '';
|
||||||
|
let completionBase = '';
|
||||||
|
|
||||||
|
viewUpdate.changes.iterChanges((_: number, __: number, fromB: number, toB: number) => {
|
||||||
|
completion = toValue(editor).state.doc.slice(fromB, toB).toString();
|
||||||
|
|
||||||
|
const index = findCompletionBaseStartIndex(fromB);
|
||||||
|
|
||||||
|
completionBase = toValue(editor)
|
||||||
|
.state.doc.slice(index, fromB - 1)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
const category = expressionExtensionsCategories.value[completion];
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
instance_id: rootStore.instanceId,
|
||||||
|
node_type: ndvStore.activeNode?.type,
|
||||||
|
field_name: path,
|
||||||
|
field_type: 'expression',
|
||||||
|
context: completionBase,
|
||||||
|
inserted_text: completion,
|
||||||
|
category: category ?? 'n/a', // only applicable if expression extension completion
|
||||||
|
};
|
||||||
|
|
||||||
|
telemetry.track('User autocompleted code', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeTrackCompletion = (viewUpdate: ViewUpdate, path: string) => {
|
||||||
|
try {
|
||||||
|
trackCompletion(viewUpdate, path);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
const debouncedTrackCompletion = debounce(safeTrackCompletion, 100);
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const editor = toValue(editorRef);
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
editor.dispatch({
|
||||||
|
effects: toValue(compartment).reconfigure([
|
||||||
|
EditorView.updateListener.of((viewUpdate) => {
|
||||||
|
if (!viewUpdate.docChanged || !editor) return;
|
||||||
|
debouncedTrackCompletion(viewUpdate, toValue(parameterPath));
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
405
packages/editor-ui/src/composables/useExpressionEditor.ts
Normal file
405
packages/editor-ui/src/composables/useExpressionEditor.ts
Normal file
|
@ -0,0 +1,405 @@
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
type MaybeRefOrGetter,
|
||||||
|
onBeforeUnmount,
|
||||||
|
ref,
|
||||||
|
watchEffect,
|
||||||
|
type Ref,
|
||||||
|
toValue,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import { ensureSyntaxTree } from '@codemirror/language';
|
||||||
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
import { Expression, ExpressionExtensions } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
|
||||||
|
import type { TargetItem } from '@/Interface';
|
||||||
|
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||||
|
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
|
||||||
|
import {
|
||||||
|
getExpressionErrorMessage,
|
||||||
|
getResolvableState,
|
||||||
|
isEmptyExpression,
|
||||||
|
} from '@/utils/expressions';
|
||||||
|
import { completionStatus } from '@codemirror/autocomplete';
|
||||||
|
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||||
|
import { EditorView, type ViewUpdate } from '@codemirror/view';
|
||||||
|
import { debounce, isEqual } from 'lodash-es';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from '../composables/useI18n';
|
||||||
|
import { highlighter } from '../plugins/codemirror/resolvableHighlighter';
|
||||||
|
import { useWorkflowsStore } from '../stores/workflows.store';
|
||||||
|
import { useAutocompleteTelemetry } from './useAutocompleteTelemetry';
|
||||||
|
|
||||||
|
export const useExpressionEditor = ({
|
||||||
|
editorRef,
|
||||||
|
editorValue,
|
||||||
|
extensions = [],
|
||||||
|
additionalData = {},
|
||||||
|
skipSegments = [],
|
||||||
|
autocompleteTelemetry,
|
||||||
|
isReadOnly = false,
|
||||||
|
}: {
|
||||||
|
editorRef: Ref<HTMLElement | undefined>;
|
||||||
|
editorValue?: MaybeRefOrGetter<string>;
|
||||||
|
extensions?: MaybeRefOrGetter<Extension[]>;
|
||||||
|
additionalData?: MaybeRefOrGetter<IDataObject>;
|
||||||
|
skipSegments?: MaybeRefOrGetter<string[]>;
|
||||||
|
autocompleteTelemetry?: MaybeRefOrGetter<{ enabled: true; parameterPath: string }>;
|
||||||
|
isReadOnly?: MaybeRefOrGetter<boolean>;
|
||||||
|
}) => {
|
||||||
|
const ndvStore = useNDVStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
|
const i18n = useI18n();
|
||||||
|
const editor = ref<EditorView>();
|
||||||
|
const hasFocus = ref(false);
|
||||||
|
const segments = ref<Segment[]>([]);
|
||||||
|
const customExtensions = ref<Compartment>(new Compartment());
|
||||||
|
const readOnlyExtensions = ref<Compartment>(new Compartment());
|
||||||
|
const telemetryExtensions = ref<Compartment>(new Compartment());
|
||||||
|
const autocompleteStatus = ref<'pending' | 'active' | null>(null);
|
||||||
|
|
||||||
|
const updateSegments = (): void => {
|
||||||
|
const state = editor.value?.state;
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
const rawSegments: RawSegment[] = [];
|
||||||
|
|
||||||
|
const fullTree = ensureSyntaxTree(state, state.doc.length, EXPRESSION_EDITOR_PARSER_TIMEOUT);
|
||||||
|
|
||||||
|
if (fullTree === null) return;
|
||||||
|
|
||||||
|
const skip = ['Program', 'Script', 'Document', ...toValue(skipSegments)];
|
||||||
|
|
||||||
|
fullTree.cursor().iterate((node) => {
|
||||||
|
const text = state.sliceDoc(node.from, node.to);
|
||||||
|
|
||||||
|
if (skip.includes(node.type.name)) return;
|
||||||
|
|
||||||
|
const newSegment: RawSegment = {
|
||||||
|
from: node.from,
|
||||||
|
to: node.to,
|
||||||
|
text,
|
||||||
|
token: node.type.name === 'Resolvable' ? 'Resolvable' : 'Plaintext',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Avoid duplicates
|
||||||
|
if (isEqual(newSegment, rawSegments.at(-1))) return;
|
||||||
|
|
||||||
|
rawSegments.push(newSegment);
|
||||||
|
});
|
||||||
|
|
||||||
|
segments.value = rawSegments.reduce<Segment[]>((acc, segment) => {
|
||||||
|
const { from, to, text, token } = segment;
|
||||||
|
|
||||||
|
if (token === 'Resolvable') {
|
||||||
|
const { resolved, error, fullError } = resolve(text, hoveringItem.value);
|
||||||
|
acc.push({
|
||||||
|
kind: 'resolvable',
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
resolvable: text,
|
||||||
|
// TODO:
|
||||||
|
// For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor
|
||||||
|
// This fixes that but as as TODO we should figure out why this is happening
|
||||||
|
resolved: String(resolved),
|
||||||
|
state: getResolvableState(fullError ?? error, completionStatus !== null),
|
||||||
|
error: fullError,
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.push({ kind: 'plaintext', from, to, plaintext: text });
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
|
function readEditorValue(): string {
|
||||||
|
return editor.value?.state.doc.toString() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHighlighting(): void {
|
||||||
|
if (!editor.value) return;
|
||||||
|
highlighter.removeColor(editor.value, plaintextSegments.value);
|
||||||
|
highlighter.addColor(editor.value, resolvableSegments.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedUpdateSegments = debounce(updateSegments, 200);
|
||||||
|
function onEditorUpdate(viewUpdate: ViewUpdate) {
|
||||||
|
if (!viewUpdate.docChanged || !editor.value) return;
|
||||||
|
|
||||||
|
autocompleteStatus.value = completionStatus(viewUpdate.view.state);
|
||||||
|
|
||||||
|
debouncedUpdateSegments();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(editorRef, () => {
|
||||||
|
const parent = toValue(editorRef);
|
||||||
|
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: toValue(editorValue),
|
||||||
|
extensions: [
|
||||||
|
customExtensions.value.of(toValue(extensions)),
|
||||||
|
readOnlyExtensions.value.of([
|
||||||
|
EditorState.readOnly.of(toValue(isReadOnly)),
|
||||||
|
EditorView.editable.of(!toValue(isReadOnly)),
|
||||||
|
]),
|
||||||
|
telemetryExtensions.value.of([]),
|
||||||
|
EditorView.updateListener.of(onEditorUpdate),
|
||||||
|
EditorView.focusChangeEffect.of((_, newHasFocus) => {
|
||||||
|
hasFocus.value = newHasFocus;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editor.value) {
|
||||||
|
editor.value.destroy();
|
||||||
|
}
|
||||||
|
editor.value = new EditorView({ parent, state });
|
||||||
|
debouncedUpdateSegments();
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (editor.value) {
|
||||||
|
editor.value.dispatch({
|
||||||
|
effects: customExtensions.value.reconfigure(toValue(extensions)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (editor.value) {
|
||||||
|
editor.value.dispatch({
|
||||||
|
effects: readOnlyExtensions.value.reconfigure([
|
||||||
|
EditorState.readOnly.of(toValue(isReadOnly)),
|
||||||
|
EditorView.editable.of(!toValue(isReadOnly)),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!editor.value) return;
|
||||||
|
|
||||||
|
const newValue = toValue(editorValue);
|
||||||
|
const currentValue = readEditorValue();
|
||||||
|
if (newValue === undefined || newValue === currentValue) return;
|
||||||
|
|
||||||
|
editor.value.dispatch({
|
||||||
|
changes: { from: 0, to: currentValue.length, insert: newValue },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const telemetry = toValue(autocompleteTelemetry);
|
||||||
|
if (!telemetry?.enabled) return;
|
||||||
|
|
||||||
|
useAutocompleteTelemetry({
|
||||||
|
editor,
|
||||||
|
parameterPath: telemetry.parameterPath,
|
||||||
|
compartment: telemetryExtensions,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
editor.value?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const expressionExtensionNames = computed<Set<string>>(() => {
|
||||||
|
return new Set(
|
||||||
|
ExpressionExtensions.reduce<string[]>((acc, cur) => {
|
||||||
|
return [...acc, ...Object.keys(cur.functions)];
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isUncalledExpressionExtension(resolvable: string) {
|
||||||
|
const end = resolvable
|
||||||
|
.replace(/^{{|}}$/g, '')
|
||||||
|
.trim()
|
||||||
|
.split('.')
|
||||||
|
.pop();
|
||||||
|
|
||||||
|
return end !== undefined && expressionExtensionNames.value.has(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(resolvable: string, hoverItem: TargetItem | null) {
|
||||||
|
const result: { resolved: unknown; error: boolean; fullError: Error | null } = {
|
||||||
|
resolved: undefined,
|
||||||
|
error: false,
|
||||||
|
fullError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!ndvStore.activeNode) {
|
||||||
|
// e.g. credential modal
|
||||||
|
result.resolved = Expression.resolveWithoutWorkflow(resolvable, toValue(additionalData));
|
||||||
|
} else {
|
||||||
|
let opts;
|
||||||
|
if (ndvStore.isInputParentOfActiveNode) {
|
||||||
|
opts = {
|
||||||
|
targetItem: hoverItem ?? undefined,
|
||||||
|
inputNodeName: ndvStore.ndvInputNodeName,
|
||||||
|
inputRunIndex: ndvStore.ndvInputRunIndex,
|
||||||
|
inputBranchIndex: ndvStore.ndvInputBranchIndex,
|
||||||
|
additionalKeys: toValue(additionalData),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
result.resolved = workflowHelpers.resolveExpression('=' + resolvable, undefined, opts);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result.resolved = `[${getExpressionErrorMessage(error)}]`;
|
||||||
|
result.error = true;
|
||||||
|
result.fullError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.resolved === '') {
|
||||||
|
result.resolved = i18n.baseText('expressionModalInput.empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.resolved === undefined && isEmptyExpression(resolvable)) {
|
||||||
|
result.resolved = i18n.baseText('expressionModalInput.empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.resolved === undefined) {
|
||||||
|
result.resolved = isUncalledExpressionExtension(resolvable)
|
||||||
|
? i18n.baseText('expressionEditor.uncalledFunction')
|
||||||
|
: i18n.baseText('expressionModalInput.undefined');
|
||||||
|
|
||||||
|
result.error = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof result.resolved === 'number' && isNaN(result.resolved)) {
|
||||||
|
result.resolved = i18n.baseText('expressionModalInput.null');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoveringItem = computed(() => {
|
||||||
|
return ndvStore.hoveringItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvableSegments = computed<Resolvable[]>(() => {
|
||||||
|
return segments.value.filter((s): s is Resolvable => s.kind === 'resolvable');
|
||||||
|
});
|
||||||
|
|
||||||
|
const plaintextSegments = computed<Plaintext[]>(() => {
|
||||||
|
return segments.value.filter((s): s is Plaintext => s.kind === 'plaintext');
|
||||||
|
});
|
||||||
|
|
||||||
|
const htmlSegments = computed<Html[]>(() => {
|
||||||
|
return segments.value.filter((s): s is Html => s.kind !== 'resolvable');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segments to display in the output of an expression editor.
|
||||||
|
*
|
||||||
|
* Some segments are not displayed when they are _part_ of the result,
|
||||||
|
* but displayed when they are the _entire_ result:
|
||||||
|
*
|
||||||
|
* - `This is a {{ [] }} test` displays as `This is a test`.
|
||||||
|
* - `{{ [] }}` displays as `[Array: []]`.
|
||||||
|
*
|
||||||
|
* Some segments display differently based on context:
|
||||||
|
*
|
||||||
|
* Date displays as
|
||||||
|
* - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result
|
||||||
|
* - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result
|
||||||
|
*
|
||||||
|
* Only needed in order to mimic behavior of `ParameterInputHint`.
|
||||||
|
*/
|
||||||
|
const displayableSegments = computed<Segment[]>(() => {
|
||||||
|
const cachedSegments = segments.value;
|
||||||
|
return cachedSegments
|
||||||
|
.map((s) => {
|
||||||
|
if (cachedSegments.length <= 1 || s.kind !== 'resolvable') return s;
|
||||||
|
|
||||||
|
if (typeof s.resolved === 'string' && /\[Object: "\d{4}-\d{2}-\d{2}T/.test(s.resolved)) {
|
||||||
|
const utcDateString = s.resolved.replace(/(\[Object: "|\"\])/g, '');
|
||||||
|
s.resolved = new Date(utcDateString).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof s.resolved === 'string' && /\[Array:\s\[.+\]\]/.test(s.resolved)) {
|
||||||
|
s.resolved = s.resolved.replace(/(\[Array: \[|\])/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
})
|
||||||
|
.filter((s) => {
|
||||||
|
if (
|
||||||
|
cachedSegments.length > 1 &&
|
||||||
|
s.kind === 'resolvable' &&
|
||||||
|
typeof s.resolved === 'string' &&
|
||||||
|
(s.resolved === '[Array: []]' ||
|
||||||
|
s.resolved === i18n.baseText('expressionModalInput.empty'))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => workflowsStore.getWorkflowExecution,
|
||||||
|
() => workflowsStore.getWorkflowRunData,
|
||||||
|
() => ndvStore.hoveringItemNumber,
|
||||||
|
],
|
||||||
|
debouncedUpdateSegments,
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(resolvableSegments, updateHighlighting);
|
||||||
|
|
||||||
|
function setCursorPosition(pos: number | 'lastExpression' | 'end'): void {
|
||||||
|
if (pos === 'lastExpression') {
|
||||||
|
const END_OF_EXPRESSION = ' }}';
|
||||||
|
pos = Math.max(readEditorValue().lastIndexOf(END_OF_EXPRESSION), 0);
|
||||||
|
} else if (pos === 'end') {
|
||||||
|
pos = editor.value?.state.doc.length ?? 0;
|
||||||
|
}
|
||||||
|
editor.value?.dispatch({ selection: { head: pos, anchor: pos } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(anchor: number, head: number | 'end' = 'end'): void {
|
||||||
|
editor.value?.dispatch({
|
||||||
|
selection: { anchor, head: head === 'end' ? editor.value?.state.doc.length ?? 0 : head },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = () => select(0, 'end');
|
||||||
|
|
||||||
|
function focus(): void {
|
||||||
|
if (hasFocus.value) return;
|
||||||
|
editor.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
editor,
|
||||||
|
hasFocus,
|
||||||
|
segments: {
|
||||||
|
all: segments,
|
||||||
|
html: htmlSegments,
|
||||||
|
display: displayableSegments,
|
||||||
|
plaintext: plaintextSegments,
|
||||||
|
resolvable: resolvableSegments,
|
||||||
|
},
|
||||||
|
readEditorValue,
|
||||||
|
setCursorPosition,
|
||||||
|
select,
|
||||||
|
selectAll,
|
||||||
|
focus,
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,83 +0,0 @@
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { ExpressionExtensions } from 'n8n-workflow';
|
|
||||||
import type { EditorView, ViewUpdate } from '@codemirror/view';
|
|
||||||
|
|
||||||
import { expressionManager } from './expressionManager';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
|
||||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
|
||||||
|
|
||||||
export const completionManager = defineComponent({
|
|
||||||
mixins: [expressionManager],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
editor: {} as EditorView,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useNDVStore, useRootStore),
|
|
||||||
expressionExtensionsCategories() {
|
|
||||||
return ExpressionExtensions.reduce<Record<string, string | undefined>>((acc, cur) => {
|
|
||||||
for (const fnName of Object.keys(cur.functions)) {
|
|
||||||
acc[fnName] = cur.typeName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
trackCompletion(viewUpdate: ViewUpdate, parameterPath: string) {
|
|
||||||
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
|
|
||||||
|
|
||||||
if (!completionTx) return;
|
|
||||||
|
|
||||||
this.ndvStore.setAutocompleteOnboarded();
|
|
||||||
|
|
||||||
let completion = '';
|
|
||||||
let completionBase = '';
|
|
||||||
|
|
||||||
viewUpdate.changes.iterChanges((_: number, __: number, fromB: number, toB: number) => {
|
|
||||||
completion = this.editor.state.doc.slice(fromB, toB).toString();
|
|
||||||
|
|
||||||
const index = this.findCompletionBaseStartIndex(fromB);
|
|
||||||
|
|
||||||
completionBase = this.editor.state.doc
|
|
||||||
.slice(index, fromB - 1)
|
|
||||||
.toString()
|
|
||||||
.trim();
|
|
||||||
});
|
|
||||||
|
|
||||||
const category = this.expressionExtensionsCategories[completion];
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
instance_id: this.rootStore.instanceId,
|
|
||||||
node_type: this.ndvStore.activeNode?.type,
|
|
||||||
field_name: parameterPath,
|
|
||||||
field_type: 'expression',
|
|
||||||
context: completionBase,
|
|
||||||
inserted_text: completion,
|
|
||||||
category: category ?? 'n/a', // only applicable if expression extension completion
|
|
||||||
};
|
|
||||||
|
|
||||||
this.$telemetry.track('User autocompleted code', payload);
|
|
||||||
},
|
|
||||||
|
|
||||||
findCompletionBaseStartIndex(fromIndex: number) {
|
|
||||||
const INDICATORS = [
|
|
||||||
' $', // proxy
|
|
||||||
'{ ', // primitive
|
|
||||||
];
|
|
||||||
|
|
||||||
const doc = this.editor.state.doc.toString();
|
|
||||||
|
|
||||||
for (let index = fromIndex; index > 0; index--) {
|
|
||||||
if (INDICATORS.some((indicator) => indicator === doc[index] + doc[index + 1])) {
|
|
||||||
return index + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,266 +0,0 @@
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
|
|
||||||
import { ensureSyntaxTree } from '@codemirror/language';
|
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
|
||||||
import { Expression, ExpressionExtensions } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
|
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
|
||||||
|
|
||||||
import type { TargetItem } from '@/Interface';
|
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
|
|
||||||
import type { EditorView } from '@codemirror/view';
|
|
||||||
import { isEqual } from 'lodash-es';
|
|
||||||
import { getExpressionErrorMessage, getResolvableState } from '@/utils/expressions';
|
|
||||||
import type { EditorState } from '@codemirror/state';
|
|
||||||
|
|
||||||
export const expressionManager = defineComponent({
|
|
||||||
props: {
|
|
||||||
targetItem: {
|
|
||||||
type: Object as PropType<TargetItem | null>,
|
|
||||||
},
|
|
||||||
additionalData: {
|
|
||||||
type: Object as PropType<IDataObject>,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data(): {
|
|
||||||
editor: EditorView;
|
|
||||||
skipSegments: string[];
|
|
||||||
editorState: EditorState | undefined;
|
|
||||||
completionStatus: 'active' | 'pending' | null;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
editor: {} as EditorView,
|
|
||||||
skipSegments: [],
|
|
||||||
completionStatus: null,
|
|
||||||
editorState: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useNDVStore, useWorkflowsStore),
|
|
||||||
|
|
||||||
unresolvedExpression(): string {
|
|
||||||
return this.segments.reduce((acc, segment) => {
|
|
||||||
acc += segment.kind === 'resolvable' ? segment.resolvable : segment.plaintext;
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, '=');
|
|
||||||
},
|
|
||||||
|
|
||||||
hoveringItem(): TargetItem | undefined {
|
|
||||||
return this.ndvStore.hoveringItem ?? undefined;
|
|
||||||
},
|
|
||||||
|
|
||||||
resolvableSegments(): Resolvable[] {
|
|
||||||
return this.segments.filter((s): s is Resolvable => s.kind === 'resolvable');
|
|
||||||
},
|
|
||||||
|
|
||||||
plaintextSegments(): Plaintext[] {
|
|
||||||
return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
|
|
||||||
},
|
|
||||||
|
|
||||||
expressionExtensionNames(): Set<string> {
|
|
||||||
return new Set(
|
|
||||||
ExpressionExtensions.reduce<string[]>((acc, cur) => {
|
|
||||||
return [...acc, ...Object.keys(cur.functions)];
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
htmlSegments(): Html[] {
|
|
||||||
return this.segments.filter((s): s is Html => s.kind !== 'resolvable');
|
|
||||||
},
|
|
||||||
|
|
||||||
segments(): Segment[] {
|
|
||||||
const state = this.editorState as EditorState;
|
|
||||||
if (!state) return [];
|
|
||||||
|
|
||||||
const rawSegments: RawSegment[] = [];
|
|
||||||
|
|
||||||
const fullTree = ensureSyntaxTree(state, state.doc.length, EXPRESSION_EDITOR_PARSER_TIMEOUT);
|
|
||||||
|
|
||||||
if (fullTree === null) {
|
|
||||||
throw new Error(`Failed to parse expression: ${this.editorValue}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const skipSegments = ['Program', 'Script', 'Document', ...this.skipSegments];
|
|
||||||
|
|
||||||
fullTree.cursor().iterate((node) => {
|
|
||||||
const text = state.sliceDoc(node.from, node.to);
|
|
||||||
|
|
||||||
if (skipSegments.includes(node.type.name)) return;
|
|
||||||
|
|
||||||
const newSegment: RawSegment = {
|
|
||||||
from: node.from,
|
|
||||||
to: node.to,
|
|
||||||
text,
|
|
||||||
token: node.type.name === 'Resolvable' ? 'Resolvable' : 'Plaintext',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Avoid duplicates
|
|
||||||
if (isEqual(newSegment, rawSegments.at(-1))) return;
|
|
||||||
|
|
||||||
rawSegments.push(newSegment);
|
|
||||||
});
|
|
||||||
|
|
||||||
return rawSegments.reduce<Segment[]>((acc, segment) => {
|
|
||||||
const { from, to, text, token } = segment;
|
|
||||||
|
|
||||||
if (token === 'Resolvable') {
|
|
||||||
const { resolved, error, fullError } = this.resolve(text, this.hoveringItem);
|
|
||||||
acc.push({
|
|
||||||
kind: 'resolvable',
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
resolvable: text,
|
|
||||||
// TODO:
|
|
||||||
// For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor
|
|
||||||
// This fixes that but as as TODO we should figure out why this is happening
|
|
||||||
resolved: String(resolved),
|
|
||||||
state: getResolvableState(fullError ?? error, this.completionStatus !== null),
|
|
||||||
error: fullError,
|
|
||||||
});
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.push({ kind: 'plaintext', from, to, plaintext: text });
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Segments to display in the output of an expression editor.
|
|
||||||
*
|
|
||||||
* Some segments are not displayed when they are _part_ of the result,
|
|
||||||
* but displayed when they are the _entire_ result:
|
|
||||||
*
|
|
||||||
* - `This is a {{ [] }} test` displays as `This is a test`.
|
|
||||||
* - `{{ [] }}` displays as `[Array: []]`.
|
|
||||||
*
|
|
||||||
* Some segments display differently based on context:
|
|
||||||
*
|
|
||||||
* Date displays as
|
|
||||||
* - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result
|
|
||||||
* - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result
|
|
||||||
*
|
|
||||||
* Only needed in order to mimic behavior of `ParameterInputHint`.
|
|
||||||
*/
|
|
||||||
displayableSegments(): Segment[] {
|
|
||||||
return this.segments
|
|
||||||
.map((s) => {
|
|
||||||
if (this.segments.length <= 1 || s.kind !== 'resolvable') return s;
|
|
||||||
|
|
||||||
if (typeof s.resolved === 'string' && /\[Object: "\d{4}-\d{2}-\d{2}T/.test(s.resolved)) {
|
|
||||||
const utcDateString = s.resolved.replace(/(\[Object: "|\"\])/g, '');
|
|
||||||
s.resolved = new Date(utcDateString).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof s.resolved === 'string' && /\[Array:\s\[.+\]\]/.test(s.resolved)) {
|
|
||||||
s.resolved = s.resolved.replace(/(\[Array: \[|\])/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return s;
|
|
||||||
})
|
|
||||||
.filter((s) => {
|
|
||||||
if (
|
|
||||||
this.segments.length > 1 &&
|
|
||||||
s.kind === 'resolvable' &&
|
|
||||||
typeof s.resolved === 'string' &&
|
|
||||||
(s.resolved === '[Array: []]' ||
|
|
||||||
s.resolved === this.$locale.baseText('expressionModalInput.empty'))
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
targetItem() {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.$emit('change', {
|
|
||||||
value: this.unresolvedExpression,
|
|
||||||
segments: this.displayableSegments,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
isEmptyExpression(resolvable: string) {
|
|
||||||
return /\{\{\s*\}\}/.test(resolvable);
|
|
||||||
},
|
|
||||||
|
|
||||||
resolve(resolvable: string, targetItem?: TargetItem) {
|
|
||||||
const result: { resolved: unknown; error: boolean; fullError: Error | null } = {
|
|
||||||
resolved: undefined,
|
|
||||||
error: false,
|
|
||||||
fullError: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ndvStore = useNDVStore();
|
|
||||||
const workflowHelpers = useWorkflowHelpers({ router: this.$router });
|
|
||||||
if (!ndvStore.activeNode) {
|
|
||||||
// e.g. credential modal
|
|
||||||
result.resolved = Expression.resolveWithoutWorkflow(resolvable, this.additionalData);
|
|
||||||
} else {
|
|
||||||
let opts;
|
|
||||||
if (ndvStore.isInputParentOfActiveNode) {
|
|
||||||
opts = {
|
|
||||||
targetItem: targetItem ?? undefined,
|
|
||||||
inputNodeName: this.ndvStore.ndvInputNodeName,
|
|
||||||
inputRunIndex: this.ndvStore.ndvInputRunIndex,
|
|
||||||
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
|
|
||||||
additionalKeys: this.additionalData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
result.resolved = workflowHelpers.resolveExpression('=' + resolvable, undefined, opts);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
result.resolved = `[${getExpressionErrorMessage(error)}]`;
|
|
||||||
result.error = true;
|
|
||||||
result.fullError = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.resolved === '') {
|
|
||||||
result.resolved = this.$locale.baseText('expressionModalInput.empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.resolved === undefined && this.isEmptyExpression(resolvable)) {
|
|
||||||
result.resolved = this.$locale.baseText('expressionModalInput.empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.resolved === undefined) {
|
|
||||||
result.resolved = this.isUncalledExpressionExtension(resolvable)
|
|
||||||
? this.$locale.baseText('expressionEditor.uncalledFunction')
|
|
||||||
: this.$locale.baseText('expressionModalInput.undefined');
|
|
||||||
|
|
||||||
result.error = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof result.resolved === 'number' && isNaN(result.resolved)) {
|
|
||||||
result.resolved = this.$locale.baseText('expressionModalInput.null');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
|
|
||||||
isUncalledExpressionExtension(resolvable: string) {
|
|
||||||
const end = resolvable
|
|
||||||
.replace(/^{{|}}$/g, '')
|
|
||||||
.trim()
|
|
||||||
.split('.')
|
|
||||||
.pop();
|
|
||||||
|
|
||||||
return end !== undefined && this.expressionExtensionNames.has(end);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -8,6 +8,14 @@ export const isExpression = (expr: unknown) => {
|
||||||
return expr.startsWith('=');
|
return expr.startsWith('=');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isEmptyExpression = (expr: string) => {
|
||||||
|
return /\{\{\s*\}\}/.test(expr);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeExpressionPrefix = (expr: string) => {
|
||||||
|
return expr.startsWith('=') ? expr.slice(1) : expr;
|
||||||
|
};
|
||||||
|
|
||||||
export const isTestableExpression = (expr: string) => {
|
export const isTestableExpression = (expr: string) => {
|
||||||
return ExpressionParser.splitExpression(expr).every((c) => {
|
return ExpressionParser.splitExpression(expr).every((c) => {
|
||||||
if (c.type === 'text') {
|
if (c.type === 'text') {
|
||||||
|
|
Loading…
Reference in a new issue