diff --git a/.changeset/afraid-beans-juggle.md b/.changeset/afraid-beans-juggle.md new file mode 100644 index 000000000..dac0642ec --- /dev/null +++ b/.changeset/afraid-beans-juggle.md @@ -0,0 +1,5 @@ +--- +'@neo4j-cypher/react-codemirror': patch +--- + +Adds more props to the CypherEditor component diff --git a/packages/react-codemirror/src/CypherEditor.tsx b/packages/react-codemirror/src/CypherEditor.tsx index b534c43b6..6724e9c32 100644 --- a/packages/react-codemirror/src/CypherEditor.tsx +++ b/packages/react-codemirror/src/CypherEditor.tsx @@ -9,6 +9,7 @@ import { KeyBinding, keymap, lineNumbers, + placeholder, ViewUpdate, } from '@codemirror/view'; import type { DbSchema } from '@neo4j-cypher/language-support'; @@ -23,6 +24,7 @@ import { cleanupWorkers } from './lang-cypher/syntaxValidation'; import { basicNeo4jSetup } from './neo4jSetup'; import { getThemeExtension } from './themes'; +type DomEventHandlers = Parameters[0]; export interface CypherEditorProps { /** * The prompt to show on single line editors @@ -42,7 +44,7 @@ export interface CypherEditorProps { */ onExecute?: (cmd: string) => void; /** - * The editor history navigateable via up/down arrow keys. Order newest to oldest. + * The editor history navigable via up/down arrow keys. Order newest to oldest. * Add to this list with the `onExecute` callback for REPL style history. */ history?: string[]; @@ -102,6 +104,37 @@ export interface CypherEditorProps { * @param {ViewUpdate} viewUpdate - the view update from codemirror */ onChange?(value: string, viewUpdate: ViewUpdate): void; + + /** + * Map of event handlers to add to the editor. + * + * Note that the props are compared by reference, meaning object defined inline + * will cause the editor to re-render (much like the style prop does in this example: + *
+ * + * Memoize the object if you want/need to avoid this. + * + * @example + * // listen to blur events + * console.log("blur event fired")}} /> + */ + domEventHandlers?: DomEventHandlers; + /** + * Placeholder text to display when the editor is empty. + */ + placeholder?: string; + /** + * Whether the editor should show line numbers. + * + * @default true + */ + lineNumbers?: boolean; + /** + * Whether the editor is read-only. + * + * @default false + */ + readonly?: boolean; } const executeKeybinding = (onExecute?: (cmd: string) => void) => @@ -125,6 +158,20 @@ const executeKeybinding = (onExecute?: (cmd: string) => void) => const themeCompartment = new Compartment(); const keyBindingCompartment = new Compartment(); +const lineNumbersCompartment = new Compartment(); +const readOnlyCompartment = new Compartment(); +const placeholderCompartment = new Compartment(); +const domEventHandlerCompartment = new Compartment(); + +const formatLineNumber = + (prompt?: string) => (a: number, state: EditorState) => { + if (state.doc.lines === 1 && prompt !== undefined) { + return prompt; + } + + return a.toString(); + }; + type CypherEditorState = { cypherSupportEnabled: boolean }; const ExternalEdit = Annotation.define(); @@ -188,6 +235,7 @@ export class CypherEditor extends Component< extraKeybindings: [], history: [], theme: 'light', + lineNumbers: true, }; private debouncedOnChange = this.props.onChange @@ -249,15 +297,20 @@ export class CypherEditor extends Component< cypher(this.schemaRef.current), lineWrap ? EditorView.lineWrapping : [], - lineNumbers({ - formatNumber: (a, state) => { - if (state.doc.lines === 1 && this.props.prompt !== undefined) { - return this.props.prompt; - } - - return a.toString(); - }, - }), + lineNumbersCompartment.of( + this.props.lineNumbers + ? lineNumbers({ formatNumber: formatLineNumber(this.props.prompt) }) + : [], + ), + readOnlyCompartment.of(EditorState.readOnly.of(this.props.readonly)), + placeholderCompartment.of( + this.props.placeholder ? placeholder(this.props.placeholder) : [], + ), + domEventHandlerCompartment.of( + this.props.domEventHandlers + ? EditorView.domEventHandlers(this.props.domEventHandlers) + : [], + ), ], doc: this.props.value, }); @@ -313,6 +366,35 @@ export class CypherEditor extends Component< }); } + if ( + prevProps.lineNumbers !== this.props.lineNumbers || + prevProps.prompt !== this.props.prompt + ) { + this.editorView.current.dispatch({ + effects: lineNumbersCompartment.reconfigure( + this.props.lineNumbers + ? lineNumbers({ formatNumber: formatLineNumber(this.props.prompt) }) + : [], + ), + }); + } + + if (prevProps.readonly !== this.props.readonly) { + this.editorView.current.dispatch({ + effects: readOnlyCompartment.reconfigure( + EditorState.readOnly.of(this.props.readonly), + ), + }); + } + + if (prevProps.placeholder !== this.props.placeholder) { + this.editorView.current.dispatch({ + effects: placeholderCompartment.reconfigure( + this.props.placeholder ? placeholder(this.props.placeholder) : [], + ), + }); + } + if ( prevProps.extraKeybindings !== this.props.extraKeybindings || prevProps.onExecute !== this.props.onExecute @@ -327,6 +409,16 @@ export class CypherEditor extends Component< }); } + if (prevProps.domEventHandlers !== this.props.domEventHandlers) { + this.editorView.current.dispatch({ + effects: domEventHandlerCompartment.reconfigure( + this.props.domEventHandlers + ? EditorView.domEventHandlers(this.props.domEventHandlers) + : [], + ), + }); + } + // This component rerenders on every keystroke and comparing the // full lists of editor strings on every render could be expensive. const didChangeHistoryEstimate = diff --git a/packages/react-codemirror/src/e2e_tests/configuration.spec.tsx b/packages/react-codemirror/src/e2e_tests/configuration.spec.tsx new file mode 100644 index 000000000..d44926a1d --- /dev/null +++ b/packages/react-codemirror/src/e2e_tests/configuration.spec.tsx @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/experimental-ct-react'; +import { CypherEditor } from '../CypherEditor'; + +test.use({ viewport: { width: 500, height: 500 } }); + +test('prompt shows up', async ({ mount, page }) => { + const component = await mount(); + + await expect(component).toContainText('neo4j>'); + + await component.update(); + await expect(component).toContainText('test>'); + + const textField = page.getByRole('textbox'); + await textField.press('a'); + + await expect(textField).toHaveText('a'); +}); + +test('line numbers can be turned on/off', async ({ mount }) => { + const component = await mount(); + + await expect(component).toContainText('1'); + + await component.update(); + await expect(component).not.toContainText('1'); +}); + +test('can configure readonly', async ({ mount, page }) => { + const component = await mount(); + + const textField = page.getByRole('textbox'); + await textField.press('a'); + await expect(textField).not.toHaveText('a'); + + await component.update(); + await textField.press('b'); + await expect(textField).toHaveText('b'); +}); + +test('can set placeholder ', async ({ mount, page }) => { + const component = await mount(); + + const textField = page.getByRole('textbox'); + await expect(textField).toHaveText('bulbasaur'); + + await component.update(); + await expect(textField).not.toHaveText('bulbasaur'); + await expect(textField).toHaveText('venusaur'); + + await textField.fill('abc'); + await expect(textField).not.toHaveText('venusaur'); + await expect(textField).toHaveText('abc'); +}); + +test('can set/unset onFocus/onBlur', async ({ mount, page }) => { + const component = await mount(); + + let focusFireCount = 0; + let blurFireCount = 0; + + const focus = () => { + focusFireCount += 1; + }; + const blur = () => { + blurFireCount += 1; + }; + + await component.update(); + + const textField = page.getByRole('textbox'); + await textField.click(); + await expect(textField).toBeFocused(); + + // this is to give the events time to fire + await expect(() => { + expect(focusFireCount).toBe(1); + expect(blurFireCount).toBe(0); + }).toPass(); + + await textField.blur(); + + await expect(() => { + expect(focusFireCount).toBe(1); + expect(blurFireCount).toBe(1); + }).toPass(); + + await component.update(); + await textField.click(); + await expect(textField).toBeFocused(); + await textField.blur(); + + await expect(() => { + expect(focusFireCount).toBe(1); + expect(blurFireCount).toBe(1); + }).toPass(); +}); diff --git a/packages/react-codemirror/src/e2e_tests/e2eUtils.ts b/packages/react-codemirror/src/e2e_tests/e2eUtils.ts index 48b61ad3a..724620a8d 100644 --- a/packages/react-codemirror/src/e2e_tests/e2eUtils.ts +++ b/packages/react-codemirror/src/e2e_tests/e2eUtils.ts @@ -71,5 +71,10 @@ export class CypherEditorPage { await this.page.getByText(queryChunk, { exact: true }).hover(); await expect(this.page.locator('.cm-tooltip-hover').last()).toBeVisible(); await expect(this.page.getByText(expectedMsg)).toBeVisible(); + // make the sure the tooltip closes + await this.page.mouse.move(0, 0); + await expect( + this.page.locator('.cm-tooltip-hover').last(), + ).not.toBeVisible(); } } diff --git a/packages/react-codemirror/src/e2e_tests/sanityChecks.spec.tsx b/packages/react-codemirror/src/e2e_tests/sanityChecks.spec.tsx index bc36a4b19..7465a9cbd 100644 --- a/packages/react-codemirror/src/e2e_tests/sanityChecks.spec.tsx +++ b/packages/react-codemirror/src/e2e_tests/sanityChecks.spec.tsx @@ -76,17 +76,3 @@ test('can complete CALL/CREATE', async ({ page, mount }) => { await expect(textField).toHaveText('CALL'); }); - -test('prompt shows up', async ({ mount, page }) => { - const component = await mount(); - - await expect(component).toContainText('neo4j>'); - - await component.update(); - await expect(component).toContainText('test>'); - - const textField = page.getByRole('textbox'); - await textField.press('a'); - - await expect(textField).toHaveText('a'); -}); diff --git a/packages/react-codemirror/src/lang-cypher/createCypherTheme.ts b/packages/react-codemirror/src/lang-cypher/createCypherTheme.ts index d7900fdaf..521bad17c 100644 --- a/packages/react-codemirror/src/lang-cypher/createCypherTheme.ts +++ b/packages/react-codemirror/src/lang-cypher/createCypherTheme.ts @@ -64,8 +64,9 @@ export const createCypherTheme = ({ color: settings.gutterForeground, border: 'none', }, - '&.cm-editor .cm-scroller': { + '&.cm-editor': { fontFamily: 'Fira Code, Menlo, Monaco, Lucida Console, monospace', + height: '100%', }, '.cm-content': { caretColor: settings.cursor,