@neo4j-cypher/react-codemirror 2.0.0-canary-9fcb804 → 2.0.0-canary-bb7e9d3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { EditorView } from '@codemirror/view';
4
+ import { createRef } from 'react';
5
+ import { createRoot } from 'react-dom/client';
6
+ import { act } from 'react-dom/test-utils';
7
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
8
+ import { DEBOUNCE_TIME } from './constants';
9
+ import { CypherEditor } from './CypherEditor';
10
+
11
+ const container = document.createElement('div');
12
+ let root: ReturnType<typeof createRoot>;
13
+
14
+ const ref = createRef<CypherEditor>();
15
+ let value = '';
16
+ const onChange = vi.fn((v: string) => {
17
+ value = v;
18
+ rerender();
19
+ });
20
+
21
+ global.IS_REACT_ACT_ENVIRONMENT = true;
22
+
23
+ /** Avoids crash in test environment */
24
+ function mockEditorView(editorView: EditorView) {
25
+ editorView.coordsAtPos = vi.fn().mockReturnValue({
26
+ left: 0,
27
+ top: 0,
28
+ right: 0,
29
+ bottom: 0,
30
+ });
31
+ }
32
+
33
+ async function debounce() {
34
+ await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME));
35
+ }
36
+
37
+ function getEditorValue() {
38
+ return ref.current.editorView.current.state.doc.toString();
39
+ }
40
+
41
+ function rerender() {
42
+ act(() => {
43
+ root.render(<CypherEditor value={value} onChange={onChange} ref={ref} />);
44
+ });
45
+ }
46
+
47
+ beforeEach(() => {
48
+ root = createRoot(container);
49
+ act(() => {
50
+ root.render(<CypherEditor value={value} onChange={onChange} ref={ref} />);
51
+ });
52
+ mockEditorView(ref.current.editorView.current);
53
+ });
54
+
55
+ afterEach(() => {
56
+ act(() => {
57
+ root.unmount();
58
+ });
59
+ value = '';
60
+ vi.clearAllMocks();
61
+ });
62
+
63
+ test('editorValue updates props.value after debounce', async () => {
64
+ ref.current.setValueAndFocus('new value');
65
+ expect(onChange).not.toHaveBeenCalled();
66
+ expect(getEditorValue()).toBe('new value');
67
+ expect(value).toBe('');
68
+ await debounce();
69
+
70
+ expect(onChange).toHaveBeenCalledOnce();
71
+ expect(getEditorValue()).toBe('new value');
72
+ expect(value).toBe('new value');
73
+ });
74
+
75
+ test('editorValue updates should not be applied twice', async () => {
76
+ const dispatch = vi.spyOn(ref.current.editorView.current, 'dispatch');
77
+
78
+ ref.current.setValueAndFocus('new value');
79
+ await debounce();
80
+
81
+ expect(onChange).toHaveBeenCalledOnce();
82
+ expect(dispatch).toHaveBeenCalledOnce(); // it gets called once for the initial setValueAndFocus
83
+ });
84
+
85
+ test('props.value updates editorValue', () => {
86
+ value = 'external value';
87
+ rerender();
88
+
89
+ expect(getEditorValue()).toBe('external value');
90
+ expect(value).toBe('external value');
91
+ });
92
+
93
+ test('props.value set to undefined preserves editorValue', () => {
94
+ // 1. value is set initially
95
+ value = 'initial';
96
+ rerender();
97
+
98
+ // 2. value is set to undefined
99
+ value = undefined;
100
+ rerender();
101
+
102
+ expect(onChange).not.toHaveBeenCalled();
103
+ expect(value).toBeUndefined();
104
+ expect(getEditorValue()).toBe('initial');
105
+ });
106
+
107
+ // value updates from outside onExecute are overwritten by pending updates
108
+ test.fails('new props.value should cancel onChange', async () => {
109
+ // 1. value is updated internally
110
+ ref.current.setValueAndFocus('update');
111
+
112
+ // 2. editor is rerendered with a new value while a value update is still pending
113
+ value = 'new external value';
114
+ rerender();
115
+
116
+ await debounce();
117
+
118
+ // expect(onChange).not.toHaveBeenCalled();
119
+ expect(getEditorValue()).toBe('new external value');
120
+ expect(value).toBe('new external value');
121
+ });
122
+
123
+ // value updates from outside onExecute are overwritten by pending updates
124
+ test.fails(
125
+ 'new props.value set to same value should cancel onChange',
126
+ async () => {
127
+ // 1. value is set initially
128
+ value = 'same value';
129
+ rerender();
130
+
131
+ // 2. value is updated internally
132
+ ref.current.setValueAndFocus('update');
133
+
134
+ // 3. editor is rerendered with a new value while a value update is still pending
135
+ value = 'same value';
136
+ rerender();
137
+
138
+ await debounce();
139
+
140
+ // expect(onChange).not.toHaveBeenCalled();
141
+ expect(getEditorValue()).toBe('same value');
142
+ expect(value).toBe('same value');
143
+ },
144
+ );
145
+
146
+ test('rerender should not cancel onChange', async () => {
147
+ // 1. value is updated ínternally
148
+ ref.current.setValueAndFocus('changed');
149
+
150
+ // 2. editor is rerendered while a value update is still pending
151
+ rerender();
152
+
153
+ await debounce();
154
+
155
+ expect(onChange).toHaveBeenCalledOnce();
156
+ expect(getEditorValue()).toBe('changed');
157
+ expect(value).toBe('changed');
158
+ });
159
+
160
+ test('rerender with a previous update should not cancel onChange', async () => {
161
+ // 1. value is updated ínternally
162
+ ref.current.setValueAndFocus('changed');
163
+ await debounce();
164
+
165
+ // 2. value is updated ínternally again
166
+ ref.current.setValueAndFocus('new change');
167
+
168
+ // 3. editor is rerendered while a value update is still pending
169
+ rerender();
170
+
171
+ await debounce();
172
+
173
+ expect(onChange).toHaveBeenCalledTimes(2);
174
+ expect(getEditorValue()).toBe('new change');
175
+ expect(value).toBe('new change');
176
+ });
177
+
178
+ test('rerender with prior external update should not cancel onChange', async () => {
179
+ // 1. value is set initially
180
+ ref.current.setValueAndFocus('initial');
181
+ await debounce();
182
+
183
+ // 2. value is updated externally
184
+ value = 'external update';
185
+ rerender();
186
+
187
+ // 3. value is updated internally
188
+ ref.current.setValueAndFocus('internal update');
189
+
190
+ // 4. editor is rerendered while a value update is still pending
191
+ rerender();
192
+
193
+ await debounce();
194
+
195
+ expect(getEditorValue()).toBe('internal update');
196
+ expect(value).toBe('internal update');
197
+ });
@@ -16,6 +16,7 @@ import {
16
16
  import { type DbSchema } from '@neo4j-cypher/language-support';
17
17
  import debounce from 'lodash.debounce';
18
18
  import { Component, createRef } from 'react';
19
+ import { DEBOUNCE_TIME } from './constants';
19
20
  import {
20
21
  replaceHistory,
21
22
  replMode as historyNavigation,
@@ -265,8 +266,6 @@ export class CypherEditor extends Component<
265
266
  editorView: React.MutableRefObject<EditorView> = createRef();
266
267
  private schemaRef: React.MutableRefObject<CypherConfig> = createRef();
267
268
 
268
- private latestDispatchedValue: string | undefined;
269
-
270
269
  /**
271
270
  * Focus the editor
272
271
  */
@@ -316,10 +315,9 @@ export class CypherEditor extends Component<
316
315
  private debouncedOnChange = this.props.onChange
317
316
  ? debounce(
318
317
  ((value, viewUpdate) => {
319
- this.latestDispatchedValue = value;
320
318
  this.props.onChange(value, viewUpdate);
321
319
  }) satisfies CypherEditorProps['onChange'],
322
- 200,
320
+ DEBOUNCE_TIME,
323
321
  )
324
322
  : undefined;
325
323
 
@@ -374,14 +372,12 @@ export class CypherEditor extends Component<
374
372
  ]
375
373
  : [];
376
374
 
377
- this.latestDispatchedValue = this.props.value;
378
-
379
375
  this.editorState.current = EditorState.create({
380
376
  extensions: [
381
377
  keyBindingCompartment.of(
382
378
  keymap.of([
383
379
  ...executeKeybinding(onExecute, newLineOnEnter, () =>
384
- this.debouncedOnChange.flush(),
380
+ this.debouncedOnChange?.flush(),
385
381
  ),
386
382
  ...extraKeybindings,
387
383
  ]),
@@ -440,10 +436,10 @@ export class CypherEditor extends Component<
440
436
  const currentCmValue = this.editorView.current.state?.doc.toString() ?? '';
441
437
 
442
438
  if (
443
- this.props.value !== undefined &&
444
- this.props.value !== this.latestDispatchedValue
439
+ this.props.value !== undefined && // If the component becomes uncontolled, we just leave the value as is
440
+ this.props.value !== prevProps.value && // The value prop has changed, we need to update the editor
441
+ this.props.value !== currentCmValue // No need to dispatch an update if the value is the same
445
442
  ) {
446
- this.debouncedOnChange?.cancel();
447
443
  this.editorView.current.dispatch({
448
444
  changes: {
449
445
  from: 0,
@@ -510,7 +506,7 @@ export class CypherEditor extends Component<
510
506
  ...executeKeybinding(
511
507
  this.props.onExecute,
512
508
  this.props.newLineOnEnter,
513
- () => this.debouncedOnChange.flush(),
509
+ () => this.debouncedOnChange?.flush(),
514
510
  ),
515
511
  ...this.props.extraKeybindings,
516
512
  ]),
@@ -0,0 +1 @@
1
+ export const DEBOUNCE_TIME = 200;
@@ -62,6 +62,38 @@ test('get completions when typing and can accept completions with tab', async ({
62
62
  await expect(component).toContainText('RETURN');
63
63
  });
64
64
 
65
+ test('get completions when typing in controlled component', async ({
66
+ mount,
67
+ page,
68
+ }) => {
69
+ let value = '';
70
+ const onChange = (val: string) => {
71
+ value = val;
72
+ void component.update(<CypherEditor value={val} onChange={onChange} />);
73
+ };
74
+
75
+ const component = await mount(
76
+ <CypherEditor value={value} onChange={onChange} />,
77
+ );
78
+ const textField = page.getByRole('textbox');
79
+
80
+ await textField.fill('RETU');
81
+ await page.waitForTimeout(500); // wait for debounce
82
+
83
+ await expect(
84
+ page.locator('.cm-tooltip-autocomplete').getByText('RETURN'),
85
+ ).toBeVisible();
86
+
87
+ // We need to wait for the editor to realise there is a completion open
88
+ // so that it does not just indent with tab key
89
+ await page.waitForTimeout(500);
90
+ await textField.press('Tab');
91
+
92
+ await expect(page.locator('.cm-tooltip-autocomplete')).not.toBeVisible();
93
+
94
+ await expect(component).toContainText('RETURN');
95
+ });
96
+
65
97
  test('can complete labels', async ({ mount, page }) => {
66
98
  const component = await mount(
67
99
  <CypherEditor
@@ -1,30 +1,30 @@
1
1
  import { expect, test } from '@playwright/experimental-ct-react';
2
+ import { DEBOUNCE_TIME } from '../constants';
2
3
  import { CypherEditor } from '../CypherEditor';
3
4
  import { CypherEditorPage } from './e2eUtils';
4
5
 
5
- const DEBOUNCE_TIMER = 200;
6
+ // value updates from outside onExecute are overwritten by pending updates
7
+ test.fail(
8
+ 'external updates should override debounced updates',
9
+ async ({ mount, page }) => {
10
+ const editorPage = new CypherEditorPage(page);
11
+ let value = '';
6
12
 
7
- test('external updates should override debounced updates', async ({
8
- mount,
9
- page,
10
- }) => {
11
- const editorPage = new CypherEditorPage(page);
12
- let value = '';
13
-
14
- const onChange = (val: string) => {
15
- value = val;
16
- void component.update(<CypherEditor value={val} onChange={onChange} />);
17
- };
13
+ const onChange = (val: string) => {
14
+ value = val;
15
+ void component.update(<CypherEditor value={val} onChange={onChange} />);
16
+ };
18
17
 
19
- const component = await mount(
20
- <CypherEditor value={value} onChange={onChange} />,
21
- );
18
+ const component = await mount(
19
+ <CypherEditor value={value} onChange={onChange} />,
20
+ );
22
21
 
23
- await editorPage.getEditor().pressSequentially('RETURN 1');
24
- onChange('foo');
25
- await page.waitForTimeout(DEBOUNCE_TIMER);
26
- await expect(component).toContainText('foo');
27
- });
22
+ await editorPage.getEditor().pressSequentially('RETURN 1');
23
+ onChange('foo');
24
+ await page.waitForTimeout(DEBOUNCE_TIME);
25
+ await expect(component).toContainText('foo');
26
+ },
27
+ );
28
28
 
29
29
  test('onExecute updates should override debounce updates', async ({
30
30
  mount,
@@ -53,14 +53,14 @@ test('onExecute updates should override debounce updates', async ({
53
53
 
54
54
  await editorPage.getEditor().pressSequentially('RETURN 1');
55
55
  await editorPage.getEditor().press('Control+Enter');
56
- await page.waitForTimeout(DEBOUNCE_TIMER);
56
+ await page.waitForTimeout(DEBOUNCE_TIME);
57
57
  await expect(component).not.toContainText('RETURN 1');
58
58
 
59
59
  await editorPage.getEditor().pressSequentially('RETURN 1');
60
60
  await editorPage.getEditor().pressSequentially('');
61
61
  await editorPage.getEditor().pressSequentially('RETURN 1');
62
62
  await editorPage.getEditor().press('Control+Enter');
63
- await page.waitForTimeout(DEBOUNCE_TIMER);
63
+ await page.waitForTimeout(DEBOUNCE_TIME);
64
64
  await expect(component).not.toContainText('RETURN 1');
65
65
  });
66
66
 
@@ -94,7 +94,7 @@ test('onExecute should fire after debounced updates', async ({
94
94
  await editorPage.getEditor().press('Control+Enter');
95
95
  await editorPage.getEditor().fill('RETURN 2');
96
96
  await editorPage.getEditor().press('Control+Enter');
97
- await page.waitForTimeout(DEBOUNCE_TIMER);
97
+ await page.waitForTimeout(DEBOUNCE_TIME);
98
98
  await expect(component).toContainText('RETURN 2');
99
99
  expect(executedCommand).toBe('RETURN 2');
100
100
  });
@@ -3,6 +3,7 @@ import {
3
3
  applySyntaxColouring,
4
4
  CypherTokenType,
5
5
  } from '@neo4j-cypher/language-support';
6
+ import { expect, test } from 'vitest';
6
7
  import { tokenTypeToStyleTag } from './constants';
7
8
 
8
9
  const cypherQueryWithAllTokenTypes = `MATCH (variable :Label)-[:REL_TYPE]->()
@@ -5,12 +5,9 @@ import { DiagnosticSeverity } from 'vscode-languageserver-types';
5
5
  import workerpool from 'workerpool';
6
6
  import type { CypherConfig } from './langCypher';
7
7
  import type { LinterTask, LintWorker } from './lintWorker';
8
+ import WorkerURL from './lintWorker?worker&url';
8
9
 
9
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
10
- // @ts-ignore ignore: https://v3.vitejs.dev/guide/features.html#import-with-query-suffixes
11
- import WorkerURL from './lintWorker?url&worker';
12
-
13
- const pool = workerpool.pool(WorkerURL as string, {
10
+ const pool = workerpool.pool(WorkerURL, {
14
11
  minWorkers: 2,
15
12
  workerOpts: { type: 'module' },
16
13
  workerTerminateTimeout: 2000,
@@ -1,4 +1,5 @@
1
1
  import { tokens } from '@neo4j-ndl/base';
2
+ import { expect, test } from 'vitest';
2
3
  import { tokens as tokensCopy } from './ndlTokensCopy';
3
4
 
4
5
  /*
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />