Skip to content

Commit 6cc9022

Browse files
Add more props to the codemirror editor to ease migration from older version (#178)
* support toggling line numbers * ensure min-height works as intended * add changeset * fix e2e test * fix test with overlapping panels * fix changeset typo
1 parent df8f703 commit 6cc9022

File tree

6 files changed

+211
-25
lines changed

6 files changed

+211
-25
lines changed

.changeset/afraid-beans-juggle.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@neo4j-cypher/react-codemirror': patch
3+
---
4+
5+
Adds more props to the CypherEditor component

packages/react-codemirror/src/CypherEditor.tsx

+102-10
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
KeyBinding,
1010
keymap,
1111
lineNumbers,
12+
placeholder,
1213
ViewUpdate,
1314
} from '@codemirror/view';
1415
import type { DbSchema } from '@neo4j-cypher/language-support';
@@ -23,6 +24,7 @@ import { cleanupWorkers } from './lang-cypher/syntaxValidation';
2324
import { basicNeo4jSetup } from './neo4jSetup';
2425
import { getThemeExtension } from './themes';
2526

27+
type DomEventHandlers = Parameters<typeof EditorView.domEventHandlers>[0];
2628
export interface CypherEditorProps {
2729
/**
2830
* The prompt to show on single line editors
@@ -42,7 +44,7 @@ export interface CypherEditorProps {
4244
*/
4345
onExecute?: (cmd: string) => void;
4446
/**
45-
* The editor history navigateable via up/down arrow keys. Order newest to oldest.
47+
* The editor history navigable via up/down arrow keys. Order newest to oldest.
4648
* Add to this list with the `onExecute` callback for REPL style history.
4749
*/
4850
history?: string[];
@@ -102,6 +104,37 @@ export interface CypherEditorProps {
102104
* @param {ViewUpdate} viewUpdate - the view update from codemirror
103105
*/
104106
onChange?(value: string, viewUpdate: ViewUpdate): void;
107+
108+
/**
109+
* Map of event handlers to add to the editor.
110+
*
111+
* Note that the props are compared by reference, meaning object defined inline
112+
* will cause the editor to re-render (much like the style prop does in this example:
113+
* <div style={{}} />
114+
*
115+
* Memoize the object if you want/need to avoid this.
116+
*
117+
* @example
118+
* // listen to blur events
119+
* <CypherEditor domEventHandlers={{blur: () => console.log("blur event fired")}} />
120+
*/
121+
domEventHandlers?: DomEventHandlers;
122+
/**
123+
* Placeholder text to display when the editor is empty.
124+
*/
125+
placeholder?: string;
126+
/**
127+
* Whether the editor should show line numbers.
128+
*
129+
* @default true
130+
*/
131+
lineNumbers?: boolean;
132+
/**
133+
* Whether the editor is read-only.
134+
*
135+
* @default false
136+
*/
137+
readonly?: boolean;
105138
}
106139

107140
const executeKeybinding = (onExecute?: (cmd: string) => void) =>
@@ -125,6 +158,20 @@ const executeKeybinding = (onExecute?: (cmd: string) => void) =>
125158

126159
const themeCompartment = new Compartment();
127160
const keyBindingCompartment = new Compartment();
161+
const lineNumbersCompartment = new Compartment();
162+
const readOnlyCompartment = new Compartment();
163+
const placeholderCompartment = new Compartment();
164+
const domEventHandlerCompartment = new Compartment();
165+
166+
const formatLineNumber =
167+
(prompt?: string) => (a: number, state: EditorState) => {
168+
if (state.doc.lines === 1 && prompt !== undefined) {
169+
return prompt;
170+
}
171+
172+
return a.toString();
173+
};
174+
128175
type CypherEditorState = { cypherSupportEnabled: boolean };
129176

130177
const ExternalEdit = Annotation.define<boolean>();
@@ -188,6 +235,7 @@ export class CypherEditor extends Component<
188235
extraKeybindings: [],
189236
history: [],
190237
theme: 'light',
238+
lineNumbers: true,
191239
};
192240

193241
private debouncedOnChange = this.props.onChange
@@ -249,15 +297,20 @@ export class CypherEditor extends Component<
249297
cypher(this.schemaRef.current),
250298
lineWrap ? EditorView.lineWrapping : [],
251299

252-
lineNumbers({
253-
formatNumber: (a, state) => {
254-
if (state.doc.lines === 1 && this.props.prompt !== undefined) {
255-
return this.props.prompt;
256-
}
257-
258-
return a.toString();
259-
},
260-
}),
300+
lineNumbersCompartment.of(
301+
this.props.lineNumbers
302+
? lineNumbers({ formatNumber: formatLineNumber(this.props.prompt) })
303+
: [],
304+
),
305+
readOnlyCompartment.of(EditorState.readOnly.of(this.props.readonly)),
306+
placeholderCompartment.of(
307+
this.props.placeholder ? placeholder(this.props.placeholder) : [],
308+
),
309+
domEventHandlerCompartment.of(
310+
this.props.domEventHandlers
311+
? EditorView.domEventHandlers(this.props.domEventHandlers)
312+
: [],
313+
),
261314
],
262315
doc: this.props.value,
263316
});
@@ -313,6 +366,35 @@ export class CypherEditor extends Component<
313366
});
314367
}
315368

369+
if (
370+
prevProps.lineNumbers !== this.props.lineNumbers ||
371+
prevProps.prompt !== this.props.prompt
372+
) {
373+
this.editorView.current.dispatch({
374+
effects: lineNumbersCompartment.reconfigure(
375+
this.props.lineNumbers
376+
? lineNumbers({ formatNumber: formatLineNumber(this.props.prompt) })
377+
: [],
378+
),
379+
});
380+
}
381+
382+
if (prevProps.readonly !== this.props.readonly) {
383+
this.editorView.current.dispatch({
384+
effects: readOnlyCompartment.reconfigure(
385+
EditorState.readOnly.of(this.props.readonly),
386+
),
387+
});
388+
}
389+
390+
if (prevProps.placeholder !== this.props.placeholder) {
391+
this.editorView.current.dispatch({
392+
effects: placeholderCompartment.reconfigure(
393+
this.props.placeholder ? placeholder(this.props.placeholder) : [],
394+
),
395+
});
396+
}
397+
316398
if (
317399
prevProps.extraKeybindings !== this.props.extraKeybindings ||
318400
prevProps.onExecute !== this.props.onExecute
@@ -327,6 +409,16 @@ export class CypherEditor extends Component<
327409
});
328410
}
329411

412+
if (prevProps.domEventHandlers !== this.props.domEventHandlers) {
413+
this.editorView.current.dispatch({
414+
effects: domEventHandlerCompartment.reconfigure(
415+
this.props.domEventHandlers
416+
? EditorView.domEventHandlers(this.props.domEventHandlers)
417+
: [],
418+
),
419+
});
420+
}
421+
330422
// This component rerenders on every keystroke and comparing the
331423
// full lists of editor strings on every render could be expensive.
332424
const didChangeHistoryEstimate =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { expect, test } from '@playwright/experimental-ct-react';
2+
import { CypherEditor } from '../CypherEditor';
3+
4+
test.use({ viewport: { width: 500, height: 500 } });
5+
6+
test('prompt shows up', async ({ mount, page }) => {
7+
const component = await mount(<CypherEditor prompt="neo4j>" />);
8+
9+
await expect(component).toContainText('neo4j>');
10+
11+
await component.update(<CypherEditor prompt="test>" />);
12+
await expect(component).toContainText('test>');
13+
14+
const textField = page.getByRole('textbox');
15+
await textField.press('a');
16+
17+
await expect(textField).toHaveText('a');
18+
});
19+
20+
test('line numbers can be turned on/off', async ({ mount }) => {
21+
const component = await mount(<CypherEditor lineNumbers />);
22+
23+
await expect(component).toContainText('1');
24+
25+
await component.update(<CypherEditor lineNumbers={false} />);
26+
await expect(component).not.toContainText('1');
27+
});
28+
29+
test('can configure readonly', async ({ mount, page }) => {
30+
const component = await mount(<CypherEditor readonly />);
31+
32+
const textField = page.getByRole('textbox');
33+
await textField.press('a');
34+
await expect(textField).not.toHaveText('a');
35+
36+
await component.update(<CypherEditor readonly={false} />);
37+
await textField.press('b');
38+
await expect(textField).toHaveText('b');
39+
});
40+
41+
test('can set placeholder ', async ({ mount, page }) => {
42+
const component = await mount(<CypherEditor placeholder="bulbasaur" />);
43+
44+
const textField = page.getByRole('textbox');
45+
await expect(textField).toHaveText('bulbasaur');
46+
47+
await component.update(<CypherEditor placeholder="venusaur" />);
48+
await expect(textField).not.toHaveText('bulbasaur');
49+
await expect(textField).toHaveText('venusaur');
50+
51+
await textField.fill('abc');
52+
await expect(textField).not.toHaveText('venusaur');
53+
await expect(textField).toHaveText('abc');
54+
});
55+
56+
test('can set/unset onFocus/onBlur', async ({ mount, page }) => {
57+
const component = await mount(<CypherEditor />);
58+
59+
let focusFireCount = 0;
60+
let blurFireCount = 0;
61+
62+
const focus = () => {
63+
focusFireCount += 1;
64+
};
65+
const blur = () => {
66+
blurFireCount += 1;
67+
};
68+
69+
await component.update(<CypherEditor domEventHandlers={{ blur, focus }} />);
70+
71+
const textField = page.getByRole('textbox');
72+
await textField.click();
73+
await expect(textField).toBeFocused();
74+
75+
// this is to give the events time to fire
76+
await expect(() => {
77+
expect(focusFireCount).toBe(1);
78+
expect(blurFireCount).toBe(0);
79+
}).toPass();
80+
81+
await textField.blur();
82+
83+
await expect(() => {
84+
expect(focusFireCount).toBe(1);
85+
expect(blurFireCount).toBe(1);
86+
}).toPass();
87+
88+
await component.update(<CypherEditor />);
89+
await textField.click();
90+
await expect(textField).toBeFocused();
91+
await textField.blur();
92+
93+
await expect(() => {
94+
expect(focusFireCount).toBe(1);
95+
expect(blurFireCount).toBe(1);
96+
}).toPass();
97+
});

packages/react-codemirror/src/e2e_tests/e2eUtils.ts

+5
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,10 @@ export class CypherEditorPage {
7171
await this.page.getByText(queryChunk, { exact: true }).hover();
7272
await expect(this.page.locator('.cm-tooltip-hover').last()).toBeVisible();
7373
await expect(this.page.getByText(expectedMsg)).toBeVisible();
74+
// make the sure the tooltip closes
75+
await this.page.mouse.move(0, 0);
76+
await expect(
77+
this.page.locator('.cm-tooltip-hover').last(),
78+
).not.toBeVisible();
7479
}
7580
}

packages/react-codemirror/src/e2e_tests/sanityChecks.spec.tsx

-14
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,3 @@ test('can complete CALL/CREATE', async ({ page, mount }) => {
7676

7777
await expect(textField).toHaveText('CALL');
7878
});
79-
80-
test('prompt shows up', async ({ mount, page }) => {
81-
const component = await mount(<CypherEditor prompt="neo4j>" />);
82-
83-
await expect(component).toContainText('neo4j>');
84-
85-
await component.update(<CypherEditor prompt="test>" />);
86-
await expect(component).toContainText('test>');
87-
88-
const textField = page.getByRole('textbox');
89-
await textField.press('a');
90-
91-
await expect(textField).toHaveText('a');
92-
});

packages/react-codemirror/src/lang-cypher/createCypherTheme.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ export const createCypherTheme = ({
6464
color: settings.gutterForeground,
6565
border: 'none',
6666
},
67-
'&.cm-editor .cm-scroller': {
67+
'&.cm-editor': {
6868
fontFamily: 'Fira Code, Menlo, Monaco, Lucida Console, monospace',
69+
height: '100%',
6970
},
7071
'.cm-content': {
7172
caretColor: settings.cursor,

0 commit comments

Comments
 (0)