fix(editor): Prevent expression editor focus being lost when user is selecting (#9525)

This commit is contained in:
Elias Meire
2024-05-29 14:37:05 +02:00
committed by GitHub
parent 3be7bb898b
commit 6698179a69
6 changed files with 238 additions and 176 deletions

View File

@@ -1,14 +1,16 @@
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import { EditorView } from '@codemirror/view';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { waitFor, fireEvent } 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 { defineComponent, h, ref, toValue } from 'vue';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { useExpressionEditor } from '../useExpressionEditor';
import { useRouter } from 'vue-router';
import { EditorSelection } from '@codemirror/state';
import userEvent from '@testing-library/user-event';
import { renderComponent } from '@/__tests__/render';
vi.mock('@/composables/useAutocompleteTelemetry', () => ({
useAutocompleteTelemetry: vi.fn(),
@@ -31,6 +33,26 @@ describe('useExpressionEditor', () => {
return mock;
};
const renderExpressionEditor = async (
options: Omit<Parameters<typeof useExpressionEditor>[0], 'editorRef'> = {},
) => {
let expressionEditor!: ReturnType<typeof useExpressionEditor>;
const renderResult = renderComponent(
defineComponent({
setup() {
const root = ref<HTMLElement>();
expressionEditor = useExpressionEditor({ ...options, editorRef: root });
return () => h('div', { ref: root, 'data-test-id': 'editor-root' });
},
}),
{ props: { options } },
);
expect(renderResult.getByTestId('editor-root')).toBeInTheDocument();
await waitFor(() => toValue(expressionEditor.editor));
return { renderResult, expressionEditor };
};
beforeEach(() => {
setActivePinia(createTestingPinia());
});
@@ -40,27 +62,21 @@ describe('useExpressionEditor', () => {
});
test('should create an editor', async () => {
const root = ref<HTMLElement>();
const { editor } = useExpressionEditor({
editorRef: root,
});
const { expressionEditor } = await renderExpressionEditor();
root.value = document.createElement('div');
await waitFor(() => expect(toValue(editor)).toBeInstanceOf(EditorView));
await waitFor(() => expect(toValue(expressionEditor.editor)).toBeInstanceOf(EditorView));
});
test('should calculate segments', async () => {
mockResolveExpression().mockReturnValueOnce(15);
const root = ref<HTMLElement>();
const { segments } = useExpressionEditor({
editorRef: root,
const {
expressionEditor: { segments },
} = await renderExpressionEditor({
editorValue: 'before {{ $json.test.length }} after',
extensions: [n8nLang()],
});
root.value = document.createElement('div');
await waitFor(() => {
expect(toValue(segments.all)).toEqual([
{
@@ -118,134 +134,124 @@ describe('useExpressionEditor', () => {
describe('readEditorValue()', () => {
test('should return the full editor value (unresolved)', async () => {
mockResolveExpression().mockReturnValueOnce(15);
const root = ref<HTMLElement>();
const { readEditorValue } = useExpressionEditor({
editorRef: root,
const {
expressionEditor: { readEditorValue },
} = await renderExpressionEditor({
editorValue: 'before {{ $json.test.length }} after',
extensions: [n8nLang()],
});
root.value = document.createElement('div');
await waitFor(() =>
expect(readEditorValue()).toEqual('before {{ $json.test.length }} after'),
);
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,
const {
expressionEditor: { editor, setCursorPosition },
} = await renderExpressionEditor({
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
setCursorPosition(4);
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(4)),
);
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,
const {
expressionEditor: { editor, setCursorPosition },
} = await renderExpressionEditor({
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
setCursorPosition('end');
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(correctPosition)),
);
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,
const {
expressionEditor: { editor, setCursorPosition },
} = await renderExpressionEditor({
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)),
);
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,
const {
expressionEditor: { editor, select },
} = await renderExpressionEditor({
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)),
);
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,
const {
expressionEditor: { editor, select },
} = await renderExpressionEditor({
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)),
);
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,
const {
expressionEditor: { editor, selectAll },
} = await renderExpressionEditor({
editorValue,
extensions: [],
});
root.value = document.createElement('div');
await waitFor(() => toValue(editor));
selectAll();
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(0, 9));
});
});
await waitFor(() =>
expect(toValue(editor)?.state.selection).toEqual(EditorSelection.single(0, 9)),
);
describe('blur on click outside', () => {
test('should blur when another element is clicked', async () => {
const { renderResult, expressionEditor } = await renderExpressionEditor();
const root = renderResult.getByTestId('editor-root');
const input = root.querySelector('.cm-line') as HTMLDivElement;
await userEvent.click(input);
expect(expressionEditor.editor.value?.hasFocus).toBe(true);
await fireEvent(document, new MouseEvent('click'));
expect(expressionEditor.editor.value?.hasFocus).toBe(false);
});
test('should NOT blur when another element is clicked while selecting', async () => {
const { renderResult, expressionEditor } = await renderExpressionEditor();
const root = renderResult.getByTestId('editor-root');
const input = root.querySelector('.cm-line') as HTMLDivElement;
await userEvent.click(input);
expect(expressionEditor.editor.value?.hasFocus).toBe(true);
await fireEvent(input, new MouseEvent('mousedown', { bubbles: true }));
await fireEvent(document, new MouseEvent('click'));
expect(expressionEditor.editor.value?.hasFocus).toBe(true);
});
});
});

View File

@@ -72,6 +72,7 @@ export const useExpressionEditor = ({
const readOnlyExtensions = ref<Compartment>(new Compartment());
const telemetryExtensions = ref<Compartment>(new Compartment());
const autocompleteStatus = ref<'pending' | 'active' | null>(null);
const dragging = ref(false);
const updateSegments = (): void => {
const state = editor.value?.state;
@@ -169,9 +170,10 @@ export const useExpressionEditor = ({
}
function blurOnClickOutside(event: MouseEvent) {
if (event.target && !editor.value?.dom.contains(event.target as Node)) {
if (event.target && !dragging.value && !editor.value?.dom.contains(event.target as Node)) {
blur();
}
dragging.value = false;
}
watch(editorRef, () => {
@@ -199,6 +201,11 @@ export const useExpressionEditor = ({
return null;
}),
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
EditorView.domEventHandlers({
mousedown: () => {
dragging.value = true;
},
}),
],
});