mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-23 10:32: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';
|
||||
|
||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||
ndv.actions.execute();
|
||||
ndv.getters.outputDataContainer().should('be.visible');
|
||||
ndv.getters.outputDataContainer().contains(output);
|
||||
|
@ -34,6 +35,7 @@ describe('Data transformation expressions', () => {
|
|||
const output = 'hello@n8n.io false';
|
||||
|
||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||
ndv.actions.execute();
|
||||
ndv.getters.outputDataContainer().should('be.visible');
|
||||
ndv.getters.outputDataContainer().contains(output);
|
||||
|
@ -49,6 +51,7 @@ describe('Data transformation expressions', () => {
|
|||
const output = '9.12';
|
||||
|
||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||
ndv.actions.execute();
|
||||
ndv.getters.outputDataContainer().should('be.visible');
|
||||
ndv.getters.outputDataContainer().contains(output);
|
||||
|
@ -64,6 +67,7 @@ describe('Data transformation expressions', () => {
|
|||
const output = 'hello@n8n.io false';
|
||||
|
||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||
ndv.actions.execute();
|
||||
ndv.getters.outputDataContainer().should('be.visible');
|
||||
ndv.getters.outputDataContainer().contains(output);
|
||||
|
@ -78,6 +82,7 @@ describe('Data transformation expressions', () => {
|
|||
const output = 'true 3';
|
||||
|
||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||
ndv.actions.execute();
|
||||
ndv.getters.outputDataContainer().find('[class*=value_]').should('exist');
|
||||
ndv.getters.outputDataContainer().find('[class*=value_]').should('contain', output);
|
||||
|
@ -93,6 +98,7 @@ describe('Data transformation expressions', () => {
|
|||
const output = '1 3';
|
||||
|
||||
ndv.getters.inlineExpressionEditorInput().clear().type(input);
|
||||
ndv.getters.inlineExpressionEditorOutput().should('have.text', output);
|
||||
ndv.actions.execute();
|
||||
ndv.getters.outputDataContainer().find('[class*=value_]').should('exist');
|
||||
ndv.getters.outputDataContainer().find('[class*=value_]').should('contain', output);
|
||||
|
|
|
@ -39,6 +39,7 @@ export class NDV extends BasePage {
|
|||
inputTbodyCell: (row: number, col: number) =>
|
||||
this.getters.inputTableRow(row).find('td').eq(col),
|
||||
inlineExpressionEditorInput: () => cy.getByTestId('inline-expression-editor-input'),
|
||||
inlineExpressionEditorOutput: () => cy.getByTestId('inline-expression-editor-output'),
|
||||
nodeParameters: () => cy.getByTestId('node-parameters'),
|
||||
parameterInput: (parameterName: string) => cy.getByTestId(`parameter-input-${parameterName}`),
|
||||
parameterInputIssues: (parameterName: string) =>
|
||||
|
|
|
@ -2,159 +2,142 @@
|
|||
<div ref="root" :class="$style.editor" @keydown.stop></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { EditorState, Prec } from '@codemirror/state';
|
||||
<script setup lang="ts">
|
||||
import { history } from '@codemirror/commands';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { computed, onMounted, ref, toValue, watch } from 'vue';
|
||||
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import { completionManager } from '@/mixins/completionManager';
|
||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { inputTheme } from './theme';
|
||||
import { forceParse } from '@/utils/forceParse';
|
||||
import { completionStatus } from '@codemirror/autocomplete';
|
||||
import { inputTheme } from './theme';
|
||||
|
||||
import type { IVariableItemSelected } from '@/Interface';
|
||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||
import {
|
||||
autocompleteKeyMap,
|
||||
enterKeyMap,
|
||||
historyKeyMap,
|
||||
tabKeyMap,
|
||||
} from '@/plugins/codemirror/keymap';
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import { removeExpressionPrefix } from '@/utils/expressions';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExpressionEditorModalInput',
|
||||
mixins: [expressionManager, completionManager],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editor: null as EditorView | null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const extensions = [
|
||||
inputTheme(),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
...tabKeyMap(),
|
||||
...historyKeyMap,
|
||||
...enterKeyMap,
|
||||
...autocompleteKeyMap,
|
||||
{
|
||||
any: (view, event) => {
|
||||
if (event.key === 'Escape' && completionStatus(view.state) === null) {
|
||||
event.stopPropagation();
|
||||
this.$emit('close');
|
||||
}
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
path: string;
|
||||
isReadOnly?: boolean;
|
||||
};
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
n8nLang(),
|
||||
n8nAutocompletion(),
|
||||
history(),
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.editable.of(!this.isReadOnly),
|
||||
EditorState.readOnly.of(this.isReadOnly),
|
||||
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
|
||||
EditorView.domEventHandlers({ scroll: forceParse }),
|
||||
EditorView.updateListener.of((viewUpdate) => {
|
||||
if (!this.editor) return;
|
||||
|
||||
this.completionStatus = completionStatus(viewUpdate.view.state);
|
||||
|
||||
if (!viewUpdate.docChanged) return;
|
||||
|
||||
this.editorState = this.editor.state;
|
||||
|
||||
highlighter.removeColor(this.editor, this.plaintextSegments);
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
setTimeout(() => {
|
||||
this.editor?.focus(); // prevent blur on paste
|
||||
try {
|
||||
this.trackCompletion(viewUpdate, this.path);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.$emit('change', {
|
||||
value: this.unresolvedExpression,
|
||||
segments: this.displayableSegments,
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
this.editor = new EditorView({
|
||||
parent: this.$refs.root as HTMLDivElement,
|
||||
state: EditorState.create({
|
||||
doc: this.modelValue.startsWith('=') ? this.modelValue.slice(1) : this.modelValue,
|
||||
extensions,
|
||||
}),
|
||||
});
|
||||
|
||||
this.editorState = this.editor.state;
|
||||
this.editor.focus();
|
||||
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
this.editor.dispatch({
|
||||
selection: { anchor: this.editor.state.doc.length },
|
||||
});
|
||||
|
||||
this.$emit('change', {
|
||||
value: this.unresolvedExpression,
|
||||
segments: this.displayableSegments,
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.editor?.destroy();
|
||||
},
|
||||
methods: {
|
||||
itemSelected({ variable }: IVariableItemSelected) {
|
||||
if (!this.editor || this.isReadOnly) return;
|
||||
|
||||
const OPEN_MARKER = '{{';
|
||||
const CLOSE_MARKER = '}}';
|
||||
|
||||
const { doc, selection } = this.editor.state;
|
||||
const { head } = selection.main;
|
||||
|
||||
const isInsideResolvable =
|
||||
doc.toString().slice(0, head).includes(OPEN_MARKER) &&
|
||||
doc.toString().slice(head, doc.length).includes(CLOSE_MARKER);
|
||||
|
||||
const insert = isInsideResolvable
|
||||
? variable
|
||||
: [OPEN_MARKER, variable, CLOSE_MARKER].join(' ');
|
||||
|
||||
this.editor.dispatch({
|
||||
changes: {
|
||||
from: head,
|
||||
insert,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isReadOnly: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'change', value: { value: string; segments: Segment[] }): void;
|
||||
(event: 'focus'): void;
|
||||
(event: 'close'): void;
|
||||
}>();
|
||||
|
||||
const root = ref<HTMLElement>();
|
||||
const extensions = computed(() => [
|
||||
inputTheme(),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
...tabKeyMap(),
|
||||
...historyKeyMap,
|
||||
...enterKeyMap,
|
||||
...autocompleteKeyMap,
|
||||
{
|
||||
any: (view, event) => {
|
||||
if (event.key === 'Escape' && completionStatus(view.state) === null) {
|
||||
event.stopPropagation();
|
||||
emit('close');
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
n8nLang(),
|
||||
n8nAutocompletion(),
|
||||
history(),
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.domEventHandlers({ scroll: forceParse }),
|
||||
]);
|
||||
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||
const {
|
||||
editor: editorRef,
|
||||
segments,
|
||||
readEditorValue,
|
||||
setCursorPosition,
|
||||
hasFocus,
|
||||
focus,
|
||||
} = useExpressionEditor({
|
||||
editorRef: root,
|
||||
editorValue,
|
||||
extensions,
|
||||
isReadOnly: props.isReadOnly,
|
||||
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
editorValue.value = removeExpressionPrefix(newValue);
|
||||
},
|
||||
);
|
||||
|
||||
watch(segments.display, (newSegments) => {
|
||||
emit('change', {
|
||||
value: '=' + readEditorValue(),
|
||||
segments: newSegments,
|
||||
});
|
||||
});
|
||||
|
||||
watch(hasFocus, (focused) => {
|
||||
if (focused) {
|
||||
emit('focus');
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
focus();
|
||||
});
|
||||
|
||||
function itemSelected({ variable }: IVariableItemSelected) {
|
||||
const editor = toValue(editorRef);
|
||||
|
||||
if (!editor || props.isReadOnly) return;
|
||||
|
||||
const OPEN_MARKER = '{{';
|
||||
const CLOSE_MARKER = '}}';
|
||||
|
||||
const { selection, doc } = editor.state;
|
||||
const { head } = selection.main;
|
||||
|
||||
const isInsideResolvable =
|
||||
editor.state.sliceDoc(0, head).includes(OPEN_MARKER) &&
|
||||
editor.state.sliceDoc(head, doc.length).includes(CLOSE_MARKER);
|
||||
|
||||
const insert = isInsideResolvable ? variable : [OPEN_MARKER, variable, CLOSE_MARKER].join(' ');
|
||||
|
||||
editor.dispatch({
|
||||
changes: {
|
||||
from: head,
|
||||
insert,
|
||||
},
|
||||
});
|
||||
|
||||
focus();
|
||||
setCursorPosition(head + insert.length);
|
||||
}
|
||||
|
||||
defineExpose({ itemSelected });
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
@ -17,11 +17,10 @@
|
|||
<InlineExpressionEditorInput
|
||||
ref="inlineInput"
|
||||
:model-value="modelValue"
|
||||
:path="path"
|
||||
:is-read-only="isReadOnly"
|
||||
:target-item="hoveringItem"
|
||||
:rows="rows"
|
||||
:additional-data="additionalExpressionData"
|
||||
:path="path"
|
||||
:event-bus="eventBus"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
|
@ -36,7 +35,7 @@
|
|||
size="xsmall"
|
||||
:class="$style['expression-editor-modal-opener']"
|
||||
data-test-id="expander"
|
||||
@click="$emit('modalOpenerClick')"
|
||||
@click="$emit('modal-opener-click')"
|
||||
/>
|
||||
</div>
|
||||
<InlineExpressionEditorOutput
|
||||
|
@ -62,7 +61,6 @@ import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
|
|||
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
||||
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import type { TargetItem } from '@/Interface';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { type EventBus, createEventBus } from 'n8n-design-system/utils';
|
||||
|
@ -79,9 +77,11 @@ export default defineComponent({
|
|||
props: {
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
|
@ -104,6 +104,7 @@ export default defineComponent({
|
|||
default: () => createEventBus(),
|
||||
},
|
||||
},
|
||||
emits: ['focus', 'blur', 'update:model-value', 'modal-opener-click'],
|
||||
setup() {
|
||||
const { callDebounced } = useDebounce();
|
||||
return { callDebounced };
|
||||
|
@ -119,9 +120,6 @@ export default defineComponent({
|
|||
hoveringItemNumber(): number {
|
||||
return this.ndvStore.hoveringItemNumber;
|
||||
},
|
||||
hoveringItem(): TargetItem | null {
|
||||
return this.ndvStore.getHoveringItem;
|
||||
},
|
||||
isDragging(): boolean {
|
||||
return this.ndvStore.isDraggableDragging;
|
||||
},
|
||||
|
@ -141,9 +139,9 @@ export default defineComponent({
|
|||
|
||||
this.$emit('focus');
|
||||
},
|
||||
onBlur(event: FocusEvent | KeyboardEvent) {
|
||||
onBlur(event?: FocusEvent | KeyboardEvent) {
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event?.target instanceof Element &&
|
||||
Array.from(event.target.classList).some((_class) => _class.includes('resizer'))
|
||||
) {
|
||||
return; // prevent blur on resizing
|
||||
|
@ -169,16 +167,13 @@ export default defineComponent({
|
|||
this.$telemetry.track('User closed Expression Editor', telemetryPayload);
|
||||
}
|
||||
},
|
||||
onChange(value: { value: string; segments: Segment[] }) {
|
||||
void this.callDebounced(this.onChangeDebounced, { debounceTime: 100, trailing: true }, value);
|
||||
},
|
||||
onChangeDebounced({ value, segments }: { value: string; segments: Segment[] }) {
|
||||
onChange({ value, segments }: { value: string; segments: Segment[] }) {
|
||||
this.segments = segments;
|
||||
|
||||
if (this.isDragging) return;
|
||||
if (value === '=' + this.modelValue) return; // prevent report on change of target item
|
||||
|
||||
this.$emit('update:modelValue', value);
|
||||
this.$emit('update:model-value', value);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { history } from '@codemirror/commands';
|
||||
import {
|
||||
LanguageSupport,
|
||||
|
@ -14,33 +14,25 @@ import {
|
|||
foldGutter,
|
||||
indentOnInput,
|
||||
} from '@codemirror/language';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { EditorState, Prec } from '@codemirror/state';
|
||||
import type { ViewUpdate } from '@codemirror/view';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import {
|
||||
EditorView,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { autoCloseTags, html, htmlLanguage } from 'codemirror-lang-html-n8n';
|
||||
import { format } from 'prettier';
|
||||
import jsParser from 'prettier/plugins/babel';
|
||||
import * as estree from 'prettier/plugins/estree';
|
||||
import htmlParser from 'prettier/plugins/html';
|
||||
import cssParser from 'prettier/plugins/postcss';
|
||||
import { defineComponent } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
|
||||
|
||||
import { htmlEditorEventBus } from '@/event-bus';
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||
import type { Range, Section } from './types';
|
||||
import { nonTakenRanges } from './utils';
|
||||
import {
|
||||
autocompleteKeyMap,
|
||||
enterKeyMap,
|
||||
|
@ -48,263 +40,211 @@ import {
|
|||
tabKeyMap,
|
||||
} from '@/plugins/codemirror/keymap';
|
||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||
import { completionStatus } from '@codemirror/autocomplete';
|
||||
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
|
||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||
import type { Range, Section } from './types';
|
||||
import { nonTakenRanges } from './utils';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'HtmlEditor',
|
||||
mixins: [expressionManager],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fillParent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
disableExpressionColoring: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableExpressionCompletions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editor: null as EditorView | null,
|
||||
editorState: null as EditorState | null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
doc(): string {
|
||||
return this.editor.state.doc.toString();
|
||||
},
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
rows?: number;
|
||||
isReadOnly?: boolean;
|
||||
fullscreen?: boolean;
|
||||
};
|
||||
|
||||
extensions(): Extension[] {
|
||||
function htmlWithCompletions() {
|
||||
return new LanguageSupport(
|
||||
htmlLanguage,
|
||||
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
|
||||
);
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rows: 4,
|
||||
isReadOnly: false,
|
||||
fullscreen: false,
|
||||
});
|
||||
|
||||
return [
|
||||
bracketMatching(),
|
||||
n8nAutocompletion(),
|
||||
this.disableExpressionCompletions ? html() : htmlWithCompletions(),
|
||||
autoCloseTags,
|
||||
expressionInputHandler(),
|
||||
Prec.highest(
|
||||
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
|
||||
),
|
||||
indentOnInput(),
|
||||
codeNodeEditorTheme({
|
||||
isReadOnly: this.isReadOnly,
|
||||
maxHeight: this.fillParent ? '100%' : '40vh',
|
||||
minHeight: '20vh',
|
||||
rows: this.rows,
|
||||
highlightColors: 'html',
|
||||
}),
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
dropCursor(),
|
||||
indentOnInput(),
|
||||
highlightActiveLine(),
|
||||
EditorView.editable.of(!this.isReadOnly),
|
||||
EditorState.readOnly.of(this.isReadOnly),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (!this.editor) return;
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:model-value', value: string): void;
|
||||
}>();
|
||||
|
||||
this.completionStatus = completionStatus(viewUpdate.view.state);
|
||||
const htmlEditor = ref<HTMLElement>();
|
||||
const editorValue = ref<string>(props.modelValue);
|
||||
const extensions = computed(() => [
|
||||
bracketMatching(),
|
||||
n8nAutocompletion(),
|
||||
new LanguageSupport(
|
||||
htmlLanguage,
|
||||
n8nCompletionSources().map((source) => htmlLanguage.data.of(source)),
|
||||
),
|
||||
autoCloseTags,
|
||||
expressionInputHandler(),
|
||||
Prec.highest(
|
||||
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
|
||||
),
|
||||
indentOnInput(),
|
||||
codeNodeEditorTheme({
|
||||
isReadOnly: props.isReadOnly,
|
||||
maxHeight: props.fullscreen ? '100%' : '40vh',
|
||||
minHeight: '20vh',
|
||||
rows: props.rows,
|
||||
highlightColors: 'html',
|
||||
}),
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
dropCursor(),
|
||||
indentOnInput(),
|
||||
highlightActiveLine(),
|
||||
]);
|
||||
const {
|
||||
editor: editorRef,
|
||||
segments,
|
||||
readEditorValue,
|
||||
} = useExpressionEditor({
|
||||
editorRef: htmlEditor,
|
||||
editorValue,
|
||||
extensions,
|
||||
});
|
||||
|
||||
if (!viewUpdate.docChanged) return;
|
||||
const sections = computed(() => {
|
||||
const editor = toValue(editorRef);
|
||||
if (!editor) return [];
|
||||
const { state } = editor;
|
||||
|
||||
// Force segments value update by keeping track of editor state
|
||||
this.editorState = this.editor.state;
|
||||
highlighter.removeColor(this.editor, this.plaintextSegments);
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
const fullTree = ensureSyntaxTree(state, state.doc.length);
|
||||
|
||||
this.$emit('update:modelValue', this.editor?.state.doc.toString());
|
||||
}),
|
||||
];
|
||||
},
|
||||
if (fullTree === null) {
|
||||
throw new Error('Failed to parse syntax tree');
|
||||
}
|
||||
|
||||
sections(): Section[] {
|
||||
const { state } = this.editor;
|
||||
let documentRange: Range = [-1, -1];
|
||||
const styleRanges: Range[] = [];
|
||||
const scriptRanges: Range[] = [];
|
||||
|
||||
const fullTree = ensureSyntaxTree(this.editor.state, this.doc.length);
|
||||
|
||||
if (fullTree === null) {
|
||||
throw new Error(`Failed to parse syntax tree for: ${this.doc}`);
|
||||
}
|
||||
|
||||
let documentRange: Range = [-1, -1];
|
||||
const styleRanges: Range[] = [];
|
||||
const scriptRanges: Range[] = [];
|
||||
|
||||
fullTree.cursor().iterate((node) => {
|
||||
if (node.type.name === 'Document') {
|
||||
documentRange = [node.from, node.to];
|
||||
}
|
||||
|
||||
if (node.type.name === 'StyleSheet') {
|
||||
styleRanges.push([node.from - '<style>'.length, node.to + '</style>'.length]);
|
||||
}
|
||||
|
||||
if (node.type.name === 'Script') {
|
||||
scriptRanges.push([node.from - '<script>'.length, node.to + ('<' + '/script>').length]);
|
||||
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
|
||||
}
|
||||
});
|
||||
|
||||
const htmlRanges = nonTakenRanges(documentRange, [...styleRanges, ...scriptRanges]);
|
||||
|
||||
const styleSections: Section[] = styleRanges.map(([start, end]) => ({
|
||||
kind: 'style' as const,
|
||||
range: [start, end],
|
||||
content: state.sliceDoc(start, end).replace(/<\/?style>/g, ''),
|
||||
}));
|
||||
|
||||
const scriptSections: Section[] = scriptRanges.map(([start, end]) => ({
|
||||
kind: 'script' as const,
|
||||
range: [start, end],
|
||||
content: state.sliceDoc(start, end).replace(/<\/?script>/g, ''),
|
||||
}));
|
||||
|
||||
const htmlSections: Section[] = htmlRanges.map(([start, end]) => ({
|
||||
kind: 'html' as const,
|
||||
range: [start, end] as Range,
|
||||
content: state.sliceDoc(start, end).replace(/<\/html>/g, ''),
|
||||
// opening tag may contain attributes, e.g. <html lang="en">
|
||||
}));
|
||||
|
||||
return [...styleSections, ...scriptSections, ...htmlSections].sort(
|
||||
(a, b) => a.range[0] - b.range[0],
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
htmlEditorEventBus.on('format-html', this.format);
|
||||
|
||||
let doc = this.modelValue;
|
||||
|
||||
if (this.modelValue === '' && this.rows > 0) {
|
||||
doc = '\n'.repeat(this.rows - 1);
|
||||
fullTree.cursor().iterate((node) => {
|
||||
if (node.type.name === 'Document') {
|
||||
documentRange = [node.from, node.to];
|
||||
}
|
||||
|
||||
const state = EditorState.create({ doc, extensions: this.extensions });
|
||||
if (node.type.name === 'StyleSheet') {
|
||||
styleRanges.push([node.from - '<style>'.length, node.to + '</style>'.length]);
|
||||
}
|
||||
|
||||
this.editor = new EditorView({ parent: this.root(), state });
|
||||
this.editorState = this.editor.state;
|
||||
if (node.type.name === 'Script') {
|
||||
scriptRanges.push([node.from - '<script>'.length, node.to + ('<' + '/script>').length]);
|
||||
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
|
||||
}
|
||||
});
|
||||
|
||||
this.getHighlighter()?.addColor(this.editor, this.resolvableSegments);
|
||||
},
|
||||
const htmlRanges = nonTakenRanges(documentRange, [...styleRanges, ...scriptRanges]);
|
||||
|
||||
beforeUnmount() {
|
||||
htmlEditorEventBus.off('format-html', this.format);
|
||||
},
|
||||
const styleSections: Section[] = styleRanges.map(([start, end]) => ({
|
||||
kind: 'style' as const,
|
||||
range: [start, end],
|
||||
content: state.sliceDoc(start, end).replace(/<\/?style>/g, ''),
|
||||
}));
|
||||
|
||||
methods: {
|
||||
root() {
|
||||
const rootRef = this.$refs.htmlEditor as HTMLDivElement | undefined;
|
||||
if (!rootRef) {
|
||||
throw new Error('Expected div with ref "htmlEditor"');
|
||||
}
|
||||
const scriptSections: Section[] = scriptRanges.map(([start, end]) => ({
|
||||
kind: 'script' as const,
|
||||
range: [start, end],
|
||||
content: state.sliceDoc(start, end).replace(/<\/?script>/g, ''),
|
||||
}));
|
||||
|
||||
return rootRef;
|
||||
},
|
||||
const htmlSections: Section[] = htmlRanges.map(([start, end]) => ({
|
||||
kind: 'html' as const,
|
||||
range: [start, end] as Range,
|
||||
content: state.sliceDoc(start, end).replace(/<\/html>/g, ''),
|
||||
// opening tag may contain attributes, e.g. <html lang="en">
|
||||
}));
|
||||
|
||||
isMissingHtmlTags() {
|
||||
const zerothSection = this.sections.at(0);
|
||||
return [...styleSections, ...scriptSections, ...htmlSections].sort(
|
||||
(a, b) => a.range[0] - b.range[0],
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
!zerothSection?.content.trim().startsWith('<html') &&
|
||||
!zerothSection?.content.trim().endsWith('</html>')
|
||||
);
|
||||
},
|
||||
function isMissingHtmlTags() {
|
||||
const zerothSection = sections.value.at(0);
|
||||
|
||||
async format() {
|
||||
if (this.sections.length === 1 && this.isMissingHtmlTags()) {
|
||||
const zerothSection = this.sections.at(0) as Section;
|
||||
return (
|
||||
!zerothSection?.content.trim().startsWith('<html') &&
|
||||
!zerothSection?.content.trim().endsWith('</html>')
|
||||
);
|
||||
}
|
||||
|
||||
const formatted = (
|
||||
await format(zerothSection.content, {
|
||||
parser: 'html',
|
||||
plugins: [htmlParser],
|
||||
})
|
||||
).trim();
|
||||
async function formatHtml() {
|
||||
const editor = toValue(editorRef);
|
||||
if (!editor) return;
|
||||
|
||||
return this.editor.dispatch({
|
||||
changes: { from: 0, to: this.doc.length, insert: formatted },
|
||||
});
|
||||
}
|
||||
const sectionToFormat = sections.value;
|
||||
if (sectionToFormat.length === 1 && isMissingHtmlTags()) {
|
||||
const zerothSection = sectionToFormat.at(0) as Section;
|
||||
|
||||
const formatted = [];
|
||||
const formatted = (
|
||||
await format(zerothSection.content, {
|
||||
parser: 'html',
|
||||
plugins: [htmlParser],
|
||||
})
|
||||
).trim();
|
||||
|
||||
for (const { kind, content } of this.sections) {
|
||||
if (kind === 'style') {
|
||||
const formattedStyle = await format(content, {
|
||||
parser: 'css',
|
||||
plugins: [cssParser],
|
||||
});
|
||||
return editor.dispatch({
|
||||
changes: { from: 0, to: editor.state.doc.length, insert: formatted },
|
||||
});
|
||||
}
|
||||
|
||||
formatted.push(`<style>\n${formattedStyle}</style>`);
|
||||
}
|
||||
const formatted = [];
|
||||
|
||||
if (kind === 'script') {
|
||||
const formattedScript = await format(content, {
|
||||
parser: 'babel',
|
||||
plugins: [jsParser, estree],
|
||||
});
|
||||
|
||||
formatted.push(`<script>\n${formattedScript}<` + '/script>');
|
||||
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
|
||||
}
|
||||
|
||||
if (kind === 'html') {
|
||||
const match = content.match(/(?<pre>[\s\S]*<html[\s\S]*?>)(?<rest>[\s\S]*)/);
|
||||
|
||||
if (!match?.groups?.pre || !match.groups?.rest) continue;
|
||||
|
||||
// Prettier cannot format pre-HTML section, e.g. <!DOCTYPE html>, so keep as is
|
||||
|
||||
const { pre, rest } = match.groups;
|
||||
|
||||
const formattedRest = await format(rest, {
|
||||
parser: 'html',
|
||||
plugins: [htmlParser],
|
||||
});
|
||||
|
||||
formatted.push(`${pre}\n${formattedRest}</html>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (formatted.length === 0) return;
|
||||
|
||||
this.editor.dispatch({
|
||||
changes: { from: 0, to: this.doc.length, insert: formatted.join('\n\n') },
|
||||
for (const { kind, content } of sections.value) {
|
||||
if (kind === 'style') {
|
||||
const formattedStyle = await format(content, {
|
||||
parser: 'css',
|
||||
plugins: [cssParser],
|
||||
});
|
||||
},
|
||||
|
||||
getHighlighter() {
|
||||
if (this.disableExpressionColoring) return;
|
||||
formatted.push(`<style>\n${formattedStyle}</style>`);
|
||||
}
|
||||
|
||||
return highlighter;
|
||||
},
|
||||
},
|
||||
if (kind === 'script') {
|
||||
const formattedScript = await format(content, {
|
||||
parser: 'babel',
|
||||
plugins: [jsParser, estree],
|
||||
});
|
||||
|
||||
formatted.push(`<script>\n${formattedScript}<` + '/script>');
|
||||
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
|
||||
}
|
||||
|
||||
if (kind === 'html') {
|
||||
const match = content.match(/(?<pre>[\s\S]*<html[\s\S]*?>)(?<rest>[\s\S]*)/);
|
||||
|
||||
if (!match?.groups?.pre || !match.groups?.rest) continue;
|
||||
|
||||
// Prettier cannot format pre-HTML section, e.g. <!DOCTYPE html>, so keep as is
|
||||
|
||||
const { pre, rest } = match.groups;
|
||||
|
||||
const formattedRest = await format(rest, {
|
||||
parser: 'html',
|
||||
plugins: [htmlParser],
|
||||
});
|
||||
|
||||
formatted.push(`${pre}\n${formattedRest}</html>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (formatted.length === 0) return;
|
||||
|
||||
editor.dispatch({
|
||||
changes: { from: 0, to: editor.state.doc.length, insert: formatted.join('\n\n') },
|
||||
});
|
||||
}
|
||||
|
||||
watch(segments.display, () => {
|
||||
emit('update:model-value', readEditorValue());
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
htmlEditorEventBus.on('format-html', formatHtml);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
htmlEditorEventBus.off('format-html', formatHtml);
|
||||
emit('update:model-value', readEditorValue());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -2,16 +2,14 @@
|
|||
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { completionStatus, startCompletion } from '@codemirror/autocomplete';
|
||||
<script setup lang="ts">
|
||||
import { startCompletion } from '@codemirror/autocomplete';
|
||||
import { history } from '@codemirror/commands';
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent, nextTick } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
|
||||
|
||||
import { completionManager } from '@/mixins/completionManager';
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||
import {
|
||||
autocompleteKeyMap,
|
||||
|
@ -20,152 +18,110 @@ import {
|
|||
tabKeyMap,
|
||||
} from '@/plugins/codemirror/keymap';
|
||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import { removeExpressionPrefix } from '@/utils/expressions';
|
||||
import { createEventBus, type EventBus } from 'n8n-design-system/utils';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { inputTheme } from './theme';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { mapStores } from 'pinia';
|
||||
|
||||
const editableConf = new Compartment();
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
path: string;
|
||||
rows?: number;
|
||||
isReadonly?: boolean;
|
||||
additionalData?: IDataObject;
|
||||
eventBus?: EventBus;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'InlineExpressionEditorInput',
|
||||
mixins: [completionManager, expressionManager],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
additionalData: {
|
||||
type: Object as PropType<IDataObject>,
|
||||
default: () => ({}),
|
||||
},
|
||||
eventBus: {
|
||||
type: Object as PropType<EventBus>,
|
||||
default: () => createEventBus(),
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rows: 5,
|
||||
isReadonly: false,
|
||||
additionalData: () => ({}),
|
||||
eventBus: () => createEventBus(),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'change', value: { value: string; segments: Segment[] }): void;
|
||||
(event: 'focus'): void;
|
||||
}>();
|
||||
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
const root = ref<HTMLElement>();
|
||||
const extensions = computed(() => [
|
||||
Prec.highest(
|
||||
keymap.of([...tabKeyMap(true), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
|
||||
),
|
||||
n8nLang(),
|
||||
n8nAutocompletion(),
|
||||
inputTheme({ rows: props.rows }),
|
||||
history(),
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
]);
|
||||
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||
const {
|
||||
editor: editorRef,
|
||||
segments,
|
||||
readEditorValue,
|
||||
setCursorPosition,
|
||||
hasFocus,
|
||||
focus,
|
||||
} = useExpressionEditor({
|
||||
editorRef: root,
|
||||
editorValue,
|
||||
extensions,
|
||||
isReadOnly: props.isReadonly,
|
||||
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
|
||||
additionalData: props.additionalData,
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus: () => {
|
||||
setCursorPosition('lastExpression');
|
||||
focus();
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore),
|
||||
});
|
||||
|
||||
async function onDrop() {
|
||||
const editor = toValue(editorRef);
|
||||
if (!editor) return;
|
||||
|
||||
await nextTick();
|
||||
focus();
|
||||
|
||||
setCursorPosition('lastExpression');
|
||||
|
||||
if (!ndvStore.isAutocompleteOnboarded) {
|
||||
startCompletion(editor);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
editorValue.value = removeExpressionPrefix(newValue);
|
||||
},
|
||||
watch: {
|
||||
isReadOnly(newValue: boolean) {
|
||||
this.editor?.dispatch({
|
||||
effects: editableConf.reconfigure(EditorView.editable.of(!newValue)),
|
||||
});
|
||||
},
|
||||
modelValue(newValue) {
|
||||
const isInternalChange = newValue === this.editor?.state.doc.toString();
|
||||
);
|
||||
|
||||
if (isInternalChange) return;
|
||||
watch(segments.display, (newSegments) => {
|
||||
emit('change', {
|
||||
value: '=' + readEditorValue(),
|
||||
segments: newSegments,
|
||||
});
|
||||
});
|
||||
|
||||
// manual update on external change, e.g. from expression modal or mapping drop
|
||||
watch(hasFocus, (focused) => {
|
||||
if (focused) emit('focus');
|
||||
});
|
||||
|
||||
this.editor?.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: this.editor?.state.doc.length,
|
||||
insert: newValue,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const extensions = [
|
||||
Prec.highest(
|
||||
keymap.of([...tabKeyMap(true), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
|
||||
),
|
||||
n8nLang(),
|
||||
n8nAutocompletion(),
|
||||
inputTheme({ rows: this.rows }),
|
||||
history(),
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
editableConf.of(EditorView.editable.of(!this.isReadOnly)),
|
||||
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
this.$emit('focus');
|
||||
},
|
||||
}),
|
||||
EditorView.updateListener.of((viewUpdate) => {
|
||||
if (!this.editor) return;
|
||||
onMounted(() => {
|
||||
props.eventBus.on('drop', onDrop);
|
||||
});
|
||||
|
||||
this.completionStatus = completionStatus(viewUpdate.view.state);
|
||||
|
||||
if (!viewUpdate.docChanged) return;
|
||||
|
||||
// Force segments value update by keeping track of editor state
|
||||
this.editorState = this.editor.state;
|
||||
highlighter.removeColor(this.editor, this.plaintextSegments);
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
this.$emit('change', {
|
||||
value: this.unresolvedExpression,
|
||||
segments: this.displayableSegments,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.trackCompletion(viewUpdate, this.path);
|
||||
} catch {}
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
this.editor = new EditorView({
|
||||
parent: this.$refs.root as HTMLDivElement,
|
||||
state: EditorState.create({
|
||||
doc: this.modelValue.startsWith('=') ? this.modelValue.slice(1) : this.modelValue,
|
||||
extensions,
|
||||
}),
|
||||
});
|
||||
|
||||
this.editorState = this.editor.state;
|
||||
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
this.eventBus.on('drop', this.onDrop);
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.editor?.destroy();
|
||||
this.eventBus.off('drop', this.onDrop);
|
||||
},
|
||||
methods: {
|
||||
focus() {
|
||||
this.editor?.focus();
|
||||
},
|
||||
setCursorPosition(pos: number) {
|
||||
this.editor.dispatch({ selection: { anchor: pos, head: pos } });
|
||||
},
|
||||
async onDrop() {
|
||||
await nextTick();
|
||||
this.focus();
|
||||
|
||||
const END_OF_EXPRESSION = ' }}';
|
||||
const value = this.editor.state.sliceDoc(0);
|
||||
const cursorPosition = Math.max(value.lastIndexOf(END_OF_EXPRESSION), 0);
|
||||
|
||||
this.setCursorPosition(cursorPosition);
|
||||
|
||||
if (!this.ndvStore.isAutocompleteOnboarded) {
|
||||
startCompletion(this.editor as EditorView);
|
||||
}
|
||||
},
|
||||
},
|
||||
onBeforeUnmount(() => {
|
||||
props.eventBus.off('drop', onDrop);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -90,7 +90,7 @@
|
|||
:rows="getArgument('rows')"
|
||||
:disable-expression-coloring="!isHtmlNode(node)"
|
||||
:disable-expression-completions="!isHtmlNode(node)"
|
||||
fill-parent
|
||||
fullscreen
|
||||
@update:model-value="valueChangedDebounced"
|
||||
/>
|
||||
<SqlEditor
|
||||
|
@ -99,7 +99,7 @@
|
|||
:dialect="getArgument('sqlDialect')"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="getArgument('rows')"
|
||||
fill-parent
|
||||
fullscreen
|
||||
@update:model-value="valueChangedDebounced"
|
||||
/>
|
||||
<JsEditor
|
||||
|
|
|
@ -1,31 +1,35 @@
|
|||
<template>
|
||||
<div v-on-click-outside="onBlur" :class="$style.sqlEditor">
|
||||
<div :class="$style.codemirror" ref="sqlEditor" data-test-id="sql-editor-container"></div>
|
||||
<div :class="$style.sqlEditor">
|
||||
<div ref="sqlEditor" :class="$style.codemirror" data-test-id="sql-editor-container"></div>
|
||||
<slot name="suffix" />
|
||||
<InlineExpressionEditorOutput
|
||||
v-if="!fillParent"
|
||||
v-if="!fullscreen"
|
||||
:segments="segments"
|
||||
:is-read-only="isReadOnly"
|
||||
:visible="isFocused"
|
||||
:visible="hasFocus"
|
||||
:hovering-item-number="hoveringItemNumber"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
|
||||
import { EXPRESSIONS_DOCS_URL } from '@/constants';
|
||||
import { codeNodeEditorEventBus } from '@/event-bus';
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
|
||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import {
|
||||
autocompleteKeyMap,
|
||||
enterKeyMap,
|
||||
historyKeyMap,
|
||||
tabKeyMap,
|
||||
} from '@/plugins/codemirror/keymap';
|
||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { ifNotIn } from '@codemirror/autocomplete';
|
||||
import { history, toggleComment } from '@codemirror/commands';
|
||||
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
|
||||
import { type Extension, type Line, Prec } from '@codemirror/state';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import type { ViewUpdate } from '@codemirror/view';
|
||||
import { Prec, type Line } from '@codemirror/state';
|
||||
import {
|
||||
EditorView,
|
||||
dropCursor,
|
||||
|
@ -34,7 +38,6 @@ import {
|
|||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import type { SQLDialect as SQLDialectType } from '@n8n/codemirror-lang-sql';
|
||||
import {
|
||||
Cassandra,
|
||||
MSSQL,
|
||||
|
@ -46,15 +49,8 @@ import {
|
|||
StandardSQL,
|
||||
keywordCompletionSource,
|
||||
} from '@n8n/codemirror-lang-sql';
|
||||
import { defineComponent } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
|
||||
import {
|
||||
autocompleteKeyMap,
|
||||
enterKeyMap,
|
||||
historyKeyMap,
|
||||
tabKeyMap,
|
||||
} from '@/plugins/codemirror/keymap';
|
||||
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
|
||||
|
||||
const SQL_DIALECTS = {
|
||||
StandardSQL,
|
||||
|
@ -67,170 +63,142 @@ const SQL_DIALECTS = {
|
|||
PLSQL,
|
||||
} as const;
|
||||
|
||||
type SQLEditorData = {
|
||||
editor: EditorView | null;
|
||||
editorState: EditorState | null;
|
||||
isFocused: boolean;
|
||||
skipSegments: string[];
|
||||
expressionsDocsUrl: string;
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
dialect?: keyof typeof SQL_DIALECTS;
|
||||
rows?: number;
|
||||
isReadOnly?: boolean;
|
||||
fullscreen?: boolean;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SqlEditor',
|
||||
components: {
|
||||
InlineExpressionEditorOutput,
|
||||
},
|
||||
mixins: [expressionManager],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
dialect: {
|
||||
type: String,
|
||||
default: 'StandardSQL',
|
||||
validator: (value: string) => {
|
||||
return Object.keys(SQL_DIALECTS).includes(value);
|
||||
},
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fillParent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
},
|
||||
data(): SQLEditorData {
|
||||
return {
|
||||
editor: null,
|
||||
editorState: null,
|
||||
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
|
||||
isFocused: false,
|
||||
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens'],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
doc(): string {
|
||||
return this.editor?.state.doc.toString() ?? '';
|
||||
},
|
||||
hoveringItemNumber(): number {
|
||||
return this.ndvStore.hoveringItemNumber;
|
||||
},
|
||||
sqlDialect(): SQLDialectType {
|
||||
return SQL_DIALECTS[this.dialect as keyof typeof SQL_DIALECTS] ?? SQL_DIALECTS.StandardSQL;
|
||||
},
|
||||
extensions(): Extension[] {
|
||||
const dialect = this.sqlDialect;
|
||||
|
||||
function sqlWithN8nLanguageSupport() {
|
||||
return new LanguageSupport(dialect.language, [
|
||||
dialect.language.data.of({
|
||||
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
|
||||
}),
|
||||
n8nCompletionSources().map((source) => dialect.language.data.of(source)),
|
||||
]);
|
||||
}
|
||||
|
||||
const extensions = [
|
||||
sqlWithN8nLanguageSupport(),
|
||||
expressionInputHandler(),
|
||||
codeNodeEditorTheme({
|
||||
isReadOnly: this.isReadOnly,
|
||||
maxHeight: this.fillParent ? '100%' : '40vh',
|
||||
minHeight: '10vh',
|
||||
rows: this.rows,
|
||||
}),
|
||||
lineNumbers(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
this.isFocused = true;
|
||||
},
|
||||
}),
|
||||
EditorState.readOnly.of(this.isReadOnly),
|
||||
EditorView.editable.of(!this.isReadOnly),
|
||||
];
|
||||
|
||||
if (!this.isReadOnly) {
|
||||
extensions.push(
|
||||
history(),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
...tabKeyMap(),
|
||||
...enterKeyMap,
|
||||
...historyKeyMap,
|
||||
...autocompleteKeyMap,
|
||||
{ key: 'Mod-/', run: toggleComment },
|
||||
]),
|
||||
),
|
||||
n8nAutocompletion(),
|
||||
indentOnInput(),
|
||||
highlightActiveLine(),
|
||||
highlightActiveLineGutter(),
|
||||
foldGutter(),
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (!this.editor || !viewUpdate.docChanged) return;
|
||||
|
||||
// Force segments value update by keeping track of editor state
|
||||
this.editorState = this.editor.state;
|
||||
highlighter.removeColor(this.editor, this.plaintextSegments);
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
this.$emit('update:modelValue', this.editor?.state.doc.toString());
|
||||
}),
|
||||
);
|
||||
}
|
||||
return extensions;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (!this.isReadOnly) codeNodeEditorEventBus.on('error-line-number', this.highlightLine);
|
||||
|
||||
const state = EditorState.create({ doc: this.modelValue, extensions: this.extensions });
|
||||
|
||||
this.editor = new EditorView({ parent: this.$refs.sqlEditor as HTMLDivElement, state });
|
||||
this.editorState = this.editor.state;
|
||||
highlighter.addColor(this.editor as EditorView, this.resolvableSegments);
|
||||
},
|
||||
methods: {
|
||||
onBlur() {
|
||||
this.isFocused = false;
|
||||
},
|
||||
line(lineNumber: number): Line | null {
|
||||
try {
|
||||
return this.editor?.state.doc.line(lineNumber) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
highlightLine(lineNumber: number | 'final') {
|
||||
if (!this.editor) return;
|
||||
|
||||
if (lineNumber === 'final') {
|
||||
this.editor.dispatch({
|
||||
selection: { anchor: this.modelValue.length },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const line = this.line(lineNumber);
|
||||
|
||||
if (!line) return;
|
||||
|
||||
this.editor.dispatch({
|
||||
selection: { anchor: line.from },
|
||||
});
|
||||
},
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
dialect: 'StandardSQL',
|
||||
rows: 4,
|
||||
isReadOnly: false,
|
||||
fullscreen: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:model-value', value: string): void;
|
||||
}>();
|
||||
|
||||
const sqlEditor = ref<HTMLElement>();
|
||||
const extensions = computed(() => {
|
||||
const dialect = SQL_DIALECTS[props.dialect] ?? SQL_DIALECTS.StandardSQL;
|
||||
function sqlWithN8nLanguageSupport() {
|
||||
return new LanguageSupport(dialect.language, [
|
||||
dialect.language.data.of({
|
||||
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
|
||||
}),
|
||||
n8nCompletionSources().map((source) => dialect.language.data.of(source)),
|
||||
]);
|
||||
}
|
||||
|
||||
const baseExtensions = [
|
||||
sqlWithN8nLanguageSupport(),
|
||||
expressionInputHandler(),
|
||||
codeNodeEditorTheme({
|
||||
isReadOnly: props.isReadOnly,
|
||||
maxHeight: props.fullscreen ? '100%' : '40vh',
|
||||
minHeight: '10vh',
|
||||
rows: props.rows,
|
||||
}),
|
||||
lineNumbers(),
|
||||
EditorView.lineWrapping,
|
||||
];
|
||||
|
||||
if (!props.isReadOnly) {
|
||||
return baseExtensions.concat([
|
||||
history(),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
...tabKeyMap(),
|
||||
...enterKeyMap,
|
||||
...historyKeyMap,
|
||||
...autocompleteKeyMap,
|
||||
{ key: 'Mod-/', run: toggleComment },
|
||||
]),
|
||||
),
|
||||
n8nAutocompletion(),
|
||||
indentOnInput(),
|
||||
highlightActiveLine(),
|
||||
highlightActiveLineGutter(),
|
||||
foldGutter(),
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
]);
|
||||
}
|
||||
return baseExtensions;
|
||||
});
|
||||
const editorValue = ref(props.modelValue);
|
||||
const {
|
||||
editor,
|
||||
segments: { all: segments },
|
||||
readEditorValue,
|
||||
hasFocus,
|
||||
} = useExpressionEditor({
|
||||
editorRef: sqlEditor,
|
||||
editorValue,
|
||||
extensions,
|
||||
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens'],
|
||||
isReadOnly: props.isReadOnly,
|
||||
});
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
const hoveringItemNumber = computed(() => {
|
||||
return ndvStore.hoveringItemNumber;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
editorValue.value = newValue;
|
||||
},
|
||||
);
|
||||
|
||||
watch(segments, () => {
|
||||
emit('update:model-value', readEditorValue());
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
codeNodeEditorEventBus.on('error-line-number', highlightLine);
|
||||
|
||||
if (props.fullscreen) {
|
||||
focus();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
codeNodeEditorEventBus.off('error-line-number', highlightLine);
|
||||
emit('update:model-value', readEditorValue());
|
||||
});
|
||||
|
||||
function line(lineNumber: number): Line | null {
|
||||
try {
|
||||
return editor.value?.state.doc.line(lineNumber) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function highlightLine(lineNumber: number | 'final') {
|
||||
if (!editor.value) return;
|
||||
|
||||
if (lineNumber === 'final') {
|
||||
editor.value.dispatch({
|
||||
selection: { anchor: editor.value.state.doc.length },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lineToHighlight = line(lineNumber);
|
||||
|
||||
if (!lineToHighlight) return;
|
||||
|
||||
editor.value.dispatch({
|
||||
selection: { anchor: lineToHighlight.from },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ExpressionEditorModalInput from '@/components/ExpressionEditorModal/ExpressionEditorModalInput.vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(ExpressionEditorModalInput);
|
||||
|
||||
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
|
||||
const originalRangeGetClientRects = Range.prototype.getClientRects;
|
||||
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
|
||||
describe('ExpressionParameterInput', () => {
|
||||
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
|
||||
const originalRangeGetClientRects = Range.prototype.getClientRects;
|
||||
const renderComponent = createComponentRenderer(ExpressionEditorModalInput);
|
||||
let pinia: TestingPinia;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
Range.prototype.getBoundingClientRect = vi.fn();
|
||||
Range.prototype.getClientRects = () => ({
|
||||
|
@ -33,7 +41,8 @@ describe('ExpressionParameterInput', () => {
|
|||
},
|
||||
});
|
||||
|
||||
await userEvent.type(getByRole('textbox'), 'test');
|
||||
const textbox = await waitFor(() => getByRole('textbox'));
|
||||
await userEvent.type(textbox, 'test');
|
||||
expect(getByRole('textbox')).toHaveTextContent(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,22 +1,16 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(ExpressionParameterInput);
|
||||
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
let ndvStore: ReturnType<typeof useNDVStore>;
|
||||
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { setActivePinia } from 'pinia';
|
||||
|
||||
describe('ExpressionParameterInput', () => {
|
||||
const renderComponent = createComponentRenderer(ExpressionParameterInput);
|
||||
let pinia: TestingPinia;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia();
|
||||
pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
workflowsStore = useWorkflowsStore();
|
||||
ndvStore = useNDVStore();
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
@ -31,7 +25,7 @@ describe('ExpressionParameterInput', () => {
|
|||
});
|
||||
|
||||
await userEvent.click(getByTestId('expander'));
|
||||
expect(emitted().modalOpenerClick).toEqual(expected);
|
||||
expect(emitted()['modal-opener-click']).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it should only emit blur when input had focus', async () => {
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { STORES } from '@/constants';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { htmlEditorEventBus } from '../../event-bus';
|
||||
|
||||
const DEFAULT_SETUP = {
|
||||
props: {
|
||||
modelValue: '<html><ul><li>one</li><li>two</li></ul></html>',
|
||||
isReadOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
describe('HtmlEditor.vue', () => {
|
||||
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect;
|
||||
const originalRangeGetClientRects = Range.prototype.getClientRects;
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: SETTINGS_STORE_DEFAULT_STATE.settings,
|
||||
},
|
||||
},
|
||||
});
|
||||
setActivePinia(pinia);
|
||||
|
||||
beforeAll(() => {
|
||||
Range.prototype.getBoundingClientRect = vi.fn();
|
||||
Range.prototype.getClientRects = () => ({
|
||||
item: vi.fn(),
|
||||
length: 0,
|
||||
[Symbol.iterator]: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect;
|
||||
Range.prototype.getClientRects = originalRangeGetClientRects;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders simple html', async () => {
|
||||
const { getByRole } = renderComponent(HtmlEditor, {
|
||||
...DEFAULT_SETUP,
|
||||
props: DEFAULT_SETUP.props,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByRole('textbox')).toHaveTextContent('<ul><li>one</li><li>two</li></ul>'),
|
||||
);
|
||||
});
|
||||
|
||||
it('formats html (and style/script tags)', async () => {
|
||||
const unformattedHtml =
|
||||
'<!DOCTYPE html><html><head> <meta charset="UTF-8" /> <title>My HTML document</title></head><body> <div class="container"> <h1>This is an H1 heading</h1> <h2>This is an H2 heading</h2> <p>This is a paragraph</p> </div> </body> <style>.container { background-color: #ffffff; text-align: center;}</style><script>console.log("Hello World!");</script></html>';
|
||||
const { getByRole } = renderComponent(HtmlEditor, {
|
||||
...DEFAULT_SETUP,
|
||||
props: { ...DEFAULT_SETUP.props, modelValue: unformattedHtml },
|
||||
});
|
||||
|
||||
let textbox = await waitFor(() => getByRole('textbox'));
|
||||
expect(textbox.querySelectorAll('.cm-line').length).toBe(1);
|
||||
|
||||
htmlEditorEventBus.emit('format-html');
|
||||
textbox = await waitFor(() => getByRole('textbox'));
|
||||
|
||||
await waitFor(() => expect(textbox.querySelectorAll('.cm-line').length).toBe(24));
|
||||
});
|
||||
|
||||
it('emits update:model-value events', async () => {
|
||||
const { emitted, getByRole } = renderComponent(HtmlEditor, {
|
||||
...DEFAULT_SETUP,
|
||||
props: DEFAULT_SETUP.props,
|
||||
});
|
||||
|
||||
const textbox = await waitFor(() => getByRole('textbox'));
|
||||
await userEvent.type(textbox, '<div>Content');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(emitted('update:model-value')).toEqual([
|
||||
['<div>Content</div><html><ul><li>one</li><li>two</li></ul></html>'],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,50 +1,62 @@
|
|||
import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
|
||||
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
|
||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { STORES } from '@/constants';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import type { TargetItem } from '@/Interface';
|
||||
import { renderComponent } from '@/__tests__/render';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const EXPRESSION_OUTPUT_TEST_ID = 'inline-expression-editor-output';
|
||||
|
||||
const RESOLVABLES: { [key: string]: string | number | boolean } = {
|
||||
'{{ $json.schema }}': 'public',
|
||||
'{{ $json.table }}': 'users',
|
||||
'{{ $json.id }}': 'id',
|
||||
'{{ $json.limit - 10 }}': 0,
|
||||
'{{ $json.active }}': false,
|
||||
};
|
||||
|
||||
const DEFAULT_SETUP = {
|
||||
props: {
|
||||
dialect: 'PostgreSQL',
|
||||
isReadOnly: false,
|
||||
},
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: SETTINGS_STORE_DEFAULT_STATE.settings,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('SQL Editor Preview Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(expressionManager.methods, 'resolve').mockImplementation(
|
||||
(resolvable: string, _targetItem?: TargetItem) => {
|
||||
return { resolved: RESOLVABLES[resolvable] };
|
||||
describe('SqlEditor.vue', () => {
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: SETTINGS_STORE_DEFAULT_STATE.settings,
|
||||
},
|
||||
);
|
||||
[STORES.NDV]: {
|
||||
activeNodeName: 'Test Node',
|
||||
},
|
||||
[STORES.WORKFLOWS]: {
|
||||
workflow: {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
typeVersion: 1,
|
||||
name: 'Test Node',
|
||||
position: [0, 0],
|
||||
type: 'test',
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
setActivePinia(pinia);
|
||||
|
||||
afterEach(() => {
|
||||
const mockResolveExpression = () => {
|
||||
const mock = vi.fn();
|
||||
vi.spyOn(workflowHelpers, 'useWorkflowHelpers').mockReturnValueOnce({
|
||||
...workflowHelpers.useWorkflowHelpers({ router: useRouter() }),
|
||||
resolveExpression: mock,
|
||||
});
|
||||
|
||||
return mock;
|
||||
};
|
||||
|
||||
afterAll(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
@ -56,11 +68,14 @@ describe('SQL Editor Preview Tests', () => {
|
|||
modelValue: 'SELECT * FROM users',
|
||||
},
|
||||
});
|
||||
await waitAllPromises();
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users'),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders basic query with expression', async () => {
|
||||
mockResolveExpression().mockReturnValueOnce('users');
|
||||
const { getByTestId } = renderComponent(SqlEditor, {
|
||||
...DEFAULT_SETUP,
|
||||
props: {
|
||||
|
@ -68,11 +83,14 @@ describe('SQL Editor Preview Tests', () => {
|
|||
modelValue: 'SELECT * FROM {{ $json.table }}',
|
||||
},
|
||||
});
|
||||
await waitAllPromises();
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users'),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders resolved expressions with dot between resolvables', async () => {
|
||||
mockResolveExpression().mockReturnValueOnce('public.users');
|
||||
const { getByTestId } = renderComponent(SqlEditor, {
|
||||
...DEFAULT_SETUP,
|
||||
props: {
|
||||
|
@ -80,11 +98,19 @@ describe('SQL Editor Preview Tests', () => {
|
|||
modelValue: 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}',
|
||||
},
|
||||
});
|
||||
await waitAllPromises();
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM public.users');
|
||||
await waitFor(() =>
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
||||
'SELECT * FROM public.users',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders resolved expressions which resolve to 0', async () => {
|
||||
mockResolveExpression()
|
||||
.mockReturnValueOnce('public')
|
||||
.mockReturnValueOnce('users')
|
||||
.mockReturnValueOnce('id')
|
||||
.mockReturnValueOnce(0);
|
||||
const { getByTestId } = renderComponent(SqlEditor, {
|
||||
...DEFAULT_SETUP,
|
||||
props: {
|
||||
|
@ -93,13 +119,19 @@ describe('SQL Editor Preview Tests', () => {
|
|||
'SELECT * FROM {{ $json.schema }}.{{ $json.table }} WHERE {{ $json.id }} > {{ $json.limit - 10 }}',
|
||||
},
|
||||
});
|
||||
await waitAllPromises();
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
||||
'SELECT * FROM public.users WHERE id > 0',
|
||||
await waitFor(() =>
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
||||
'SELECT * FROM public.users WHERE id > 0',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps query formatting in rendered output', async () => {
|
||||
mockResolveExpression()
|
||||
.mockReturnValueOnce('public')
|
||||
.mockReturnValueOnce('users')
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(false);
|
||||
const { getByTestId } = renderComponent(SqlEditor, {
|
||||
...DEFAULT_SETUP,
|
||||
props: {
|
||||
|
@ -108,9 +140,10 @@ describe('SQL Editor Preview Tests', () => {
|
|||
'SELECT * FROM {{ $json.schema }}.{{ $json.table }}\n WHERE id > {{ $json.limit - 10 }}\n AND active = {{ $json.active }};',
|
||||
},
|
||||
});
|
||||
await waitAllPromises();
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
||||
'SELECT * FROM public.users WHERE id > 0 AND active = false;',
|
||||
await waitFor(() =>
|
||||
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
|
||||
'SELECT * FROM public.users WHERE id > 0 AND active = false;',
|
||||
),
|
||||
);
|
||||
// Output should have the same number of lines as the input
|
||||
expect(getByTestId('sql-editor-container').getElementsByClassName('cm-line').length).toEqual(
|
||||
|
|
|
@ -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('=');
|
||||
};
|
||||
|
||||
export const isEmptyExpression = (expr: string) => {
|
||||
return /\{\{\s*\}\}/.test(expr);
|
||||
};
|
||||
|
||||
export const removeExpressionPrefix = (expr: string) => {
|
||||
return expr.startsWith('=') ? expr.slice(1) : expr;
|
||||
};
|
||||
|
||||
export const isTestableExpression = (expr: string) => {
|
||||
return ExpressionParser.splitExpression(expr).every((c) => {
|
||||
if (c.type === 'text') {
|
||||
|
|
Loading…
Reference in a new issue