diff --git a/cypress/e2e/14-data-transformation-expressions.cy.ts b/cypress/e2e/14-data-transformation-expressions.cy.ts index da08ec8817..21c958d691 100644 --- a/cypress/e2e/14-data-transformation-expressions.cy.ts +++ b/cypress/e2e/14-data-transformation-expressions.cy.ts @@ -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); diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 4eeb27094f..3de9a13917 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -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) => diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index 74eaa2a39f..1f9b7b2930 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -2,159 +2,142 @@
- '.length]); - } - - if (node.type.name === 'Script') { - scriptRanges.push([node.from - ' diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index dfd4b78047..de0a419915 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -2,16 +2,14 @@
- diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index c977df6626..bde01887f3 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -90,7 +90,7 @@ :rows="getArgument('rows')" :disable-expression-coloring="!isHtmlNode(node)" :disable-expression-completions="!isHtmlNode(node)" - fill-parent + fullscreen @update:model-value="valueChangedDebounced" /> -
-
+
+
- '; + 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, '
Content'); + + await waitFor(() => + expect(emitted('update:model-value')).toEqual([ + ['
Content
  • one
  • two
'], + ]), + ); + }); +}); diff --git a/packages/editor-ui/src/components/__tests__/SQLEditor.test.ts b/packages/editor-ui/src/components/__tests__/SQLEditor.test.ts index 0469e9a72e..7e7014ec59 100644 --- a/packages/editor-ui/src/components/__tests__/SQLEditor.test.ts +++ b/packages/editor-ui/src/components/__tests__/SQLEditor.test.ts @@ -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( diff --git a/packages/editor-ui/src/composables/__tests__/useAutocompleteTelemetry.test.ts b/packages/editor-ui/src/composables/__tests__/useAutocompleteTelemetry.test.ts new file mode 100644 index 0000000000..54c105d124 --- /dev/null +++ b/packages/editor-ui/src/composables/__tests__/useAutocompleteTelemetry.test.ts @@ -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()); + }); +}); diff --git a/packages/editor-ui/src/composables/__tests__/useExpressionEditor.test.ts b/packages/editor-ui/src/composables/__tests__/useExpressionEditor.test.ts new file mode 100644 index 0000000000..dac86b8ca8 --- /dev/null +++ b/packages/editor-ui/src/composables/__tests__/useExpressionEditor.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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)), + ); + }); + }); +}); diff --git a/packages/editor-ui/src/composables/useAutocompleteTelemetry.ts b/packages/editor-ui/src/composables/useAutocompleteTelemetry.ts new file mode 100644 index 0000000000..cd500588be --- /dev/null +++ b/packages/editor-ui/src/composables/useAutocompleteTelemetry.ts @@ -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; + parameterPath: MaybeRefOrGetter; + compartment: MaybeRefOrGetter; +}) => { + const ndvStore = useNDVStore(); + const rootStore = useRootStore(); + const telemetry = useTelemetry(); + + const expressionExtensionsCategories = computed(() => { + return ExpressionExtensions.reduce>((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)); + }), + ]), + }); + }); +}; diff --git a/packages/editor-ui/src/composables/useExpressionEditor.ts b/packages/editor-ui/src/composables/useExpressionEditor.ts new file mode 100644 index 0000000000..2608954a39 --- /dev/null +++ b/packages/editor-ui/src/composables/useExpressionEditor.ts @@ -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; + editorValue?: MaybeRefOrGetter; + extensions?: MaybeRefOrGetter; + additionalData?: MaybeRefOrGetter; + skipSegments?: MaybeRefOrGetter; + autocompleteTelemetry?: MaybeRefOrGetter<{ enabled: true; parameterPath: string }>; + isReadOnly?: MaybeRefOrGetter; +}) => { + const ndvStore = useNDVStore(); + const workflowsStore = useWorkflowsStore(); + const router = useRouter(); + const workflowHelpers = useWorkflowHelpers({ router }); + const i18n = useI18n(); + const editor = ref(); + const hasFocus = ref(false); + const segments = ref([]); + const customExtensions = ref(new Compartment()); + const readOnlyExtensions = ref(new Compartment()); + const telemetryExtensions = ref(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((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>(() => { + return new Set( + ExpressionExtensions.reduce((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(() => { + return segments.value.filter((s): s is Resolvable => s.kind === 'resolvable'); + }); + + const plaintextSegments = computed(() => { + return segments.value.filter((s): s is Plaintext => s.kind === 'plaintext'); + }); + + const htmlSegments = computed(() => { + 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(() => { + 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, + }; +}; diff --git a/packages/editor-ui/src/mixins/completionManager.ts b/packages/editor-ui/src/mixins/completionManager.ts deleted file mode 100644 index 8709c0d892..0000000000 --- a/packages/editor-ui/src/mixins/completionManager.ts +++ /dev/null @@ -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>((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; - }, - }, -}); diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts deleted file mode 100644 index 883f100ce8..0000000000 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ /dev/null @@ -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, - }, - additionalData: { - type: Object as PropType, - 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 { - return new Set( - ExpressionExtensions.reduce((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((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); - }, - }, -}); diff --git a/packages/editor-ui/src/utils/expressions.ts b/packages/editor-ui/src/utils/expressions.ts index 569abd721f..2e9b2ee6ae 100644 --- a/packages/editor-ui/src/utils/expressions.ts +++ b/packages/editor-ui/src/utils/expressions.ts @@ -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') {