fix(editor): Fix Code node bug erasing and overwriting code when switching between nodes (#12637)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions

Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
Alex Grozav 2025-01-16 19:36:08 +02:00 committed by GitHub
parent 0b0f532367
commit 02d953db34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 143 additions and 5 deletions

View file

@ -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,
});

View file

@ -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<Parameters<typeof useCodeEditor>[0], 'editorRef'> = {
language: 'javaScript',
};
const renderCodeEditor = async (options: Partial<typeof defaultOptions> = defaultOptions) => {
let codeEditor!: ReturnType<typeof useCodeEditor>;
const renderResult = renderComponent(
defineComponent({
setup() {
const root = ref<HTMLElement>();
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');
});
});

View file

@ -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 = <L extends CodeEditorLanguage>({
const editor = ref<EditorView>();
const hasFocus = ref(false);
const hasChanges = ref(false);
const lastChange = ref<ViewUpdate>();
const selection = ref<SelectionRange>(EditorSelection.cursor(0)) as Ref<SelectionRange>;
const customExtensions = ref<Compartment>(new Compartment());
const readOnlyExtensions = ref<Compartment>(new Compartment());
@ -157,14 +157,19 @@ export const useCodeEditor = <L extends CodeEditorLanguage>({
const emitChanges = debounce((update: ViewUpdate) => {
onChange(update);
}, 300);
const lastChange = ref<ViewUpdate>();
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 = <L extends CodeEditorLanguage>({
// 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();
}
});

View file

@ -1,14 +1,19 @@
import { Annotation } from '@codemirror/state';
import type { EditorView } from '@codemirror/view';
export const ignoreUpdateAnnotation = Annotation.define<boolean>();
/**
* 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)],
});
}