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