diff --git a/packages/editor-ui/src/__tests__/setup.ts b/packages/editor-ui/src/__tests__/setup.ts index 35bfe7aafbfd7..3ddee75f14410 100644 --- a/packages/editor-ui/src/__tests__/setup.ts +++ b/packages/editor-ui/src/__tests__/setup.ts @@ -61,3 +61,25 @@ Object.defineProperty(window, 'matchMedia', { dispatchEvent: vi.fn(), })), }); + +class Worker { + onmessage: (message: string) => void; + + url: string; + + constructor(url: string) { + this.url = url; + this.onmessage = () => {}; + } + + postMessage(message: string) { + this.onmessage(message); + } + + addEventListener() {} +} + +Object.defineProperty(window, 'Worker', { + writable: true, + value: Worker, +}); diff --git a/packages/editor-ui/src/composables/useCodeEditor.test.ts b/packages/editor-ui/src/composables/useCodeEditor.test.ts new file mode 100644 index 0000000000000..7dbb31f110449 --- /dev/null +++ b/packages/editor-ui/src/composables/useCodeEditor.test.ts @@ -0,0 +1,103 @@ +import { renderComponent } from '@/__tests__/render'; +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 { defineComponent, h, ref, toValue } from 'vue'; +import { useCodeEditor } from './useCodeEditor'; +import userEvent from '@testing-library/user-event'; + +describe('useCodeEditor', () => { + const defaultOptions: Omit[0], 'editorRef'> = { + language: 'javaScript', + }; + + const renderCodeEditor = async (options: Partial = defaultOptions) => { + let codeEditor!: ReturnType; + const renderResult = renderComponent( + defineComponent({ + setup() { + const root = ref(); + codeEditor = useCodeEditor({ ...defaultOptions, ...options, editorRef: root }); + + return () => h('div', { ref: root, 'data-test-id': 'editor-root' }); + }, + }), + { props: { options } }, + ); + expect(renderResult.getByTestId('editor-root')).toBeInTheDocument(); + await waitFor(() => toValue(codeEditor.editor)); + return { renderResult, codeEditor }; + }; + + beforeEach(() => { + setActivePinia(createTestingPinia()); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should create an editor', async () => { + const { codeEditor } = await renderCodeEditor(); + + await waitFor(() => expect(toValue(codeEditor.editor)).toBeInstanceOf(EditorView)); + }); + + it('should focus editor', async () => { + const { renderResult, codeEditor } = await renderCodeEditor({}); + + const root = renderResult.getByTestId('editor-root'); + const input = root.querySelector('.cm-line') as HTMLDivElement; + + await userEvent.click(input); + + expect(codeEditor.editor.value?.hasFocus).toBe(true); + }); + + it('should emit changes', async () => { + vi.useFakeTimers(); + + const user = userEvent.setup({ + advanceTimers: vi.advanceTimersByTime, + }); + + const onChange = vi.fn(); + const { renderResult } = await renderCodeEditor({ + onChange, + }); + + const root = renderResult.getByTestId('editor-root'); + const input = root.querySelector('.cm-line') as HTMLDivElement; + + await user.type(input, 'test'); + + vi.advanceTimersByTime(300); + + expect(onChange.mock.calls[0][0].state.doc.toString()).toEqual('test'); + }); + + it('should emit debounced changes before unmount', async () => { + vi.useFakeTimers(); + + const user = userEvent.setup({ + advanceTimers: vi.advanceTimersByTime, + }); + + const onChange = vi.fn(); + const { renderResult } = await renderCodeEditor({ + onChange, + }); + + const root = renderResult.getByTestId('editor-root'); + const input = root.querySelector('.cm-line') as HTMLDivElement; + + await user.type(input, 'test'); + + renderResult.unmount(); + + expect(onChange.mock.calls[0][0].state.doc.toString()).toEqual('test'); + }); +}); diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index 0a63189eeda40..05afc3b2d9902 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -30,7 +30,6 @@ import { } from '@codemirror/view'; import { indentationMarkers } from '@replit/codemirror-indentation-markers'; import { html } from 'codemirror-lang-html-n8n'; -import { debounce } from 'lodash-es'; import { jsonParse, type CodeExecutionMode, type IDataObject } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import { @@ -47,6 +46,8 @@ import { import { useCompleter } from '../components/CodeNodeEditor/completer'; import { mappingDropCursor } from '../plugins/codemirror/dragAndDrop'; import { languageFacet, type CodeEditorLanguage } from '../plugins/codemirror/format'; +import { debounce } from 'lodash-es'; +import { ignoreUpdateAnnotation } from '../utils/forceParse'; export type CodeEditorLanguageParamsMap = { json: {}; @@ -85,7 +86,6 @@ export const useCodeEditor = ({ const editor = ref(); const hasFocus = ref(false); const hasChanges = ref(false); - const lastChange = ref(); const selection = ref(EditorSelection.cursor(0)) as Ref; const customExtensions = ref(new Compartment()); const readOnlyExtensions = ref(new Compartment()); @@ -157,14 +157,19 @@ export const useCodeEditor = ({ const emitChanges = debounce((update: ViewUpdate) => { onChange(update); }, 300); + const lastChange = ref(); function onEditorUpdate(update: ViewUpdate) { autocompleteStatus.value = completionStatus(update.view.state); updateSelection(update); - if (update.docChanged) { - hasChanges.value = true; + const shouldIgnoreUpdate = update.transactions.some((tr) => + tr.annotation(ignoreUpdateAnnotation), + ); + + if (update.docChanged && !shouldIgnoreUpdate) { lastChange.value = update; + hasChanges.value = true; emitChanges(update); } } @@ -369,7 +374,10 @@ export const useCodeEditor = ({ // Code is too large, localStorage quota exceeded localStorage.removeItem(storedStateId.value); } - if (lastChange.value) onChange(lastChange.value); + + if (lastChange.value) { + onChange(lastChange.value); + } editor.value.destroy(); } }); diff --git a/packages/editor-ui/src/utils/forceParse.ts b/packages/editor-ui/src/utils/forceParse.ts index d6cd800405377..40494508f46fa 100644 --- a/packages/editor-ui/src/utils/forceParse.ts +++ b/packages/editor-ui/src/utils/forceParse.ts @@ -1,14 +1,19 @@ +import { Annotation } from '@codemirror/state'; import type { EditorView } from '@codemirror/view'; +export const ignoreUpdateAnnotation = Annotation.define(); + /** * Simulate user action to force parser to catch up during scroll. */ export function forceParse(view: EditorView) { view.dispatch({ changes: { from: view.viewport.to, insert: '_' }, + annotations: [ignoreUpdateAnnotation.of(true)], }); view.dispatch({ changes: { from: view.viewport.to - 1, to: view.viewport.to, insert: '' }, + annotations: [ignoreUpdateAnnotation.of(true)], }); }