diff --git a/.changeset/moody-tigers-hang.md b/.changeset/moody-tigers-hang.md new file mode 100644 index 00000000000..89777467217 --- /dev/null +++ b/.changeset/moody-tigers-hang.md @@ -0,0 +1,6 @@ +--- +'graphiql': minor +'@graphiql/react': minor +--- + +Move the logic of the query editor from the `graphiql` package into a hook `useQueryEditor` provided by `@graphiql/react` diff --git a/package.json b/package.json index 1165c24e07e..98a7f821058 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,16 @@ "npm": "please_use_yarn_instead" }, "scripts": { - "build": "yarn workspace @graphiql/react run build && yarn tsc --clean && yarn tsc", + "build": "yarn build:clean && yarn build:packages && yarn build:graphiql-react && yarn build:graphiql", "build-bundles": "yarn prebuild-bundles && yarn workspace graphiql run build-bundles", "build-bundles-clean": "rimraf '{packages,examples,plugins}/**/{bundle,cdn,webpack}' && yarn workspace graphiql run build-bundles-clean", "build-clean": "wsrun build-clean", "build-demo": "wsrun build-demo", "build-docs": "rimraf 'packages/graphiql/typedoc' && typedoc 'packages'", - "build:clean": "yarn tsc --clean", + "build:clean": "yarn tsc --clean && yarn tsc --clean resources/tsconfig.graphiql.json", + "build:graphiql": "yarn tsc resources/tsconfig.graphiql.json", + "build:graphiql-react": "yarn workspace @graphiql/react run build", + "build:packages": "yarn tsc", "build:watch": "yarn tsc --watch", "watch": "yarn build:watch", "watch-vscode": "concurrently --raw 'yarn tsc --watch' 'yarn workspace vscode-graphql run compile --watch'", diff --git a/packages/graphiql-react/jest.config.js b/packages/graphiql-react/jest.config.js new file mode 100644 index 00000000000..d5f2ae82f35 --- /dev/null +++ b/packages/graphiql-react/jest.config.js @@ -0,0 +1,5 @@ +const base = require('../../jest.config.base')(__dirname); + +module.exports = { + ...base, +}; diff --git a/packages/graphiql-react/src/editor/__tests__/whitespace.spec.ts b/packages/graphiql-react/src/editor/__tests__/whitespace.spec.ts new file mode 100644 index 00000000000..4a44b6aab21 --- /dev/null +++ b/packages/graphiql-react/src/editor/__tests__/whitespace.spec.ts @@ -0,0 +1,8 @@ +import { invalidCharacters, normalizeWhitespace } from '../whitespace'; + +describe('normalizeWhitespace', () => { + it('removes unicode characters', () => { + const result = normalizeWhitespace(invalidCharacters.join('')); + expect(result).toEqual(' '.repeat(invalidCharacters.length)); + }); +}); diff --git a/packages/graphiql-react/src/editor/completion.ts b/packages/graphiql-react/src/editor/completion.ts index eb52afafc96..2ecd96aabbd 100644 --- a/packages/graphiql-react/src/editor/completion.ts +++ b/packages/graphiql-react/src/editor/completion.ts @@ -7,10 +7,8 @@ import { GraphQLField, } from 'graphql'; import escapeHTML from 'escape-html'; -import MD from 'markdown-it'; import { importCodeMirror } from './common'; - -const md = new MD(); +import { markdown } from '../markdown'; /** * Render a custom UI for CodeMirror's hint which includes additional info @@ -63,7 +61,7 @@ export default function onHasCompletion( // Now that the UI has been set up, add info to information. const description = ctx.description - ? md.render(ctx.description) + ? markdown.render(ctx.description) : 'Self descriptive.'; const type = ctx.type ? '' + renderType(ctx.type) + '' @@ -78,7 +76,7 @@ export default function onHasCompletion( if (ctx && deprecation && ctx.deprecationReason) { const reason = ctx.deprecationReason - ? md.render(ctx.deprecationReason) + ? markdown.render(ctx.deprecationReason) : ''; deprecation.innerHTML = 'Deprecated' + reason; diff --git a/packages/graphiql-react/src/editor/context.tsx b/packages/graphiql-react/src/editor/context.tsx index 346406711e9..80d6e451761 100644 --- a/packages/graphiql-react/src/editor/context.tsx +++ b/packages/graphiql-react/src/editor/context.tsx @@ -1,24 +1,32 @@ -import type { Editor } from 'codemirror'; import { createContext, ReactNode, useState } from 'react'; +import { CodeMirrorEditor } from './types'; + export type EditorContextType = { - headerEditor: Editor | null; - setHeaderEditor(newEditor: Editor): void; + headerEditor: CodeMirrorEditor | null; + queryEditor: CodeMirrorEditor | null; + setHeaderEditor(newEditor: CodeMirrorEditor): void; + setQueryEditor(newEditor: CodeMirrorEditor): void; }; export const EditorContext = createContext({ headerEditor: null, + queryEditor: null, setHeaderEditor() {}, + setQueryEditor() {}, }); export function EditorContextProvider(props: { children: ReactNode; initialValue?: string; }) { - const [editor, setEditor] = useState(null); + const [headerEditor, setHeaderEditor] = useState( + null, + ); + const [queryEditor, setQueryEditor] = useState(null); return ( + value={{ headerEditor, queryEditor, setHeaderEditor, setQueryEditor }}> {props.children} ); diff --git a/packages/graphiql-react/src/editor/header-editor.tsx b/packages/graphiql-react/src/editor/header-editor.tsx index 9bd881eeb2c..44d56b9f4c1 100644 --- a/packages/graphiql-react/src/editor/header-editor.tsx +++ b/packages/graphiql-react/src/editor/header-editor.tsx @@ -46,10 +46,17 @@ export function useHeaderEditor({ const { headerEditor, setHeaderEditor } = context; useEffect(() => { + let isActive = true; + importCodeMirror([ // @ts-expect-error import('codemirror/mode/javascript/javascript'), ]).then(CodeMirror => { + // Don't continue if the effect has already been cleaned up + if (!isActive) { + return; + } + const container = ref.current; if (!container) { return; @@ -99,6 +106,10 @@ export function useHeaderEditor({ setHeaderEditor(newEditor); }); + + return () => { + isActive = false; + }; }, [editorTheme, readOnly, setHeaderEditor]); useSynchronizeValue(headerEditor, value); diff --git a/packages/graphiql-react/src/editor/hooks.ts b/packages/graphiql-react/src/editor/hooks.ts index a759af5979d..151e136e32c 100644 --- a/packages/graphiql-react/src/editor/hooks.ts +++ b/packages/graphiql-react/src/editor/hooks.ts @@ -1,10 +1,11 @@ -import { Editor, EditorChange } from 'codemirror'; +import { EditorChange } from 'codemirror'; import { RefObject, useEffect, useRef } from 'react'; import onHasCompletion from './completion'; +import { CodeMirrorEditor } from './types'; export function useSynchronizeValue( - editor: Editor | null, + editor: CodeMirrorEditor | null, value: string | undefined, ) { useEffect(() => { @@ -19,7 +20,7 @@ export function useSynchronizeValue( export type EditCallback = (value: string) => void; export function useChangeHandler( - editor: Editor | null, + editor: CodeMirrorEditor | null, callback: EditCallback | undefined, ) { useEffect(() => { @@ -27,7 +28,7 @@ export function useChangeHandler( return; } - const handleChange = (editorInstance: Editor) => { + const handleChange = (editorInstance: CodeMirrorEditor) => { const newValue = editorInstance.getValue(); callback?.(newValue); }; @@ -39,12 +40,15 @@ export function useChangeHandler( export type CompletionCallback = (value: HTMLDivElement) => void; export function useCompletion( - editor: Editor | null, + editor: CodeMirrorEditor | null, callback: CompletionCallback | undefined, ) { useEffect(() => { if (editor && callback) { - const handleCompletion = (instance: Editor, changeObj?: EditorChange) => { + const handleCompletion = ( + instance: CodeMirrorEditor, + changeObj?: EditorChange, + ) => { onHasCompletion(instance, changeObj, callback); }; editor.on( @@ -65,7 +69,7 @@ export function useCompletion( export type EmptyCallback = () => void; export function useKeyMap( - editor: Editor | null, + editor: CodeMirrorEditor | null, keys: string[], callback: EmptyCallback | undefined, ) { @@ -88,7 +92,7 @@ export function useKeyMap( } export function useResizeEditor( - editor: Editor | null, + editor: CodeMirrorEditor | null, ref: RefObject, ) { const sizeRef = useRef(); diff --git a/packages/graphiql-react/src/editor/index.ts b/packages/graphiql-react/src/editor/index.ts index 297b502dfce..0ef674ad22b 100644 --- a/packages/graphiql-react/src/editor/index.ts +++ b/packages/graphiql-react/src/editor/index.ts @@ -1,9 +1,16 @@ import { EditorContext, EditorContextProvider } from './context'; import { useHeaderEditor } from './header-editor'; +import { useQueryEditor } from './query-editor'; import type { EditorContextType } from './context'; import type { UseHeaderEditorArgs } from './header-editor'; +import type { UseQueryEditorArgs } from './query-editor'; -export { EditorContext, EditorContextProvider, useHeaderEditor }; +export { + EditorContext, + EditorContextProvider, + useHeaderEditor, + useQueryEditor, +}; -export type { EditorContextType, UseHeaderEditorArgs }; +export type { EditorContextType, UseHeaderEditorArgs, UseQueryEditorArgs }; diff --git a/packages/graphiql-react/src/editor/query-editor.tsx b/packages/graphiql-react/src/editor/query-editor.tsx new file mode 100644 index 00000000000..98f11e6731b --- /dev/null +++ b/packages/graphiql-react/src/editor/query-editor.tsx @@ -0,0 +1,291 @@ +import type { SchemaReference } from 'codemirror-graphql/utils/SchemaReference'; +import type { + FragmentDefinitionNode, + GraphQLSchema, + ValidationRule, +} from 'graphql'; +import { MutableRefObject, useContext, useEffect, useRef } from 'react'; + +import { commonKeys, importCodeMirror } from './common'; +import { EditorContext } from './context'; +import { + CompletionCallback, + EditCallback, + EmptyCallback, + useChangeHandler, + useCompletion, + useKeyMap, + useResizeEditor, + useSynchronizeValue, +} from './hooks'; +import { markdown } from '../markdown'; +import { normalizeWhitespace } from './whitespace'; +import { CodeMirrorType, CodeMirrorEditor } from './types'; + +type OnClickReference = (reference: SchemaReference) => void; + +export type UseQueryEditorArgs = { + editorTheme?: string; + externalFragments?: string | FragmentDefinitionNode[]; + onClickReference?: OnClickReference; + onCopyQuery?: EmptyCallback; + onEdit?: EditCallback; + onHintInformationRender?: CompletionCallback; + onPrettifyQuery?: EmptyCallback; + onMergeQuery?: EmptyCallback; + onRunQuery?: EmptyCallback; + readOnly?: boolean; + schema?: GraphQLSchema | null; + validationRules?: ValidationRule[]; + value?: string; +}; + +export function useQueryEditor({ + editorTheme = 'graphiql', + externalFragments, + onClickReference, + onCopyQuery, + onEdit, + onHintInformationRender, + onMergeQuery, + onPrettifyQuery, + onRunQuery, + readOnly = false, + schema, + validationRules, + value, +}: UseQueryEditorArgs = {}) { + const context = useContext(EditorContext); + const ref = useRef(null); + const codeMirrorRef = useRef(); + + if (!context) { + throw new Error( + 'Tried to call the `useQueryEditor` hook without the necessary context. Make sure that the `EditorContextProvider` from `@graphiql/react` is rendered higher in the tree.', + ); + } + + const { queryEditor, setQueryEditor } = context; + + const onClickReferenceRef = useRef(); + useEffect(() => { + onClickReferenceRef.current = onClickReference; + }, [onClickReference]); + + useEffect(() => { + let isActive = true; + + importCodeMirror([ + import('codemirror/addon/comment/comment'), + import('codemirror/addon/search/search'), + import('codemirror-graphql/esm/hint'), + import('codemirror-graphql/esm/lint'), + import('codemirror-graphql/esm/info'), + import('codemirror-graphql/esm/jump'), + import('codemirror-graphql/esm/mode'), + ]).then(CodeMirror => { + // Don't continue if the effect has already been cleaned up + if (!isActive) { + return; + } + + codeMirrorRef.current = CodeMirror; + + const container = ref.current; + if (!container) { + return; + } + + const newEditor = CodeMirror(container, { + lineNumbers: true, + tabSize: 2, + foldGutter: true, + mode: 'graphql', + theme: editorTheme, + keyMap: 'sublime', + autoCloseBrackets: true, + matchBrackets: true, + showCursorWhenSelecting: true, + readOnly: readOnly ? 'nocursor' : false, + lint: { + // @ts-expect-error + schema: undefined, + validationRules: null, + // linting accepts string or FragmentDefinitionNode[] + externalFragments: undefined, + }, + hintOptions: { + // @ts-expect-error + schema: undefined, + closeOnUnfocus: false, + completeSingle: false, + container, + externalFragments: undefined, + }, + info: { + schema: undefined, + renderDescription: (text: string) => markdown.render(text), + onClick: (reference: SchemaReference) => { + onClickReferenceRef.current?.(reference); + }, + }, + jump: { + schema: undefined, + onClick: (reference: SchemaReference) => { + onClickReferenceRef.current?.(reference); + }, + }, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], + extraKeys: { + ...commonKeys, + 'Cmd-S'() { + // empty + }, + 'Ctrl-S'() { + // empty + }, + }, + }); + + newEditor.addKeyMap({ + 'Cmd-Space'() { + newEditor.showHint({ completeSingle: true, container }); + }, + 'Ctrl-Space'() { + newEditor.showHint({ completeSingle: true, container }); + }, + 'Alt-Space'() { + newEditor.showHint({ completeSingle: true, container }); + }, + 'Shift-Space'() { + newEditor.showHint({ completeSingle: true, container }); + }, + 'Shift-Alt-Space'() { + newEditor.showHint({ completeSingle: true, container }); + }, + }); + + newEditor.on('keyup', (editorInstance, event) => { + if (AUTO_COMPLETE_AFTER_KEY.test(event.key)) { + editorInstance.execCommand('autocomplete'); + } + }); + + newEditor.on('beforeChange', (editorInstance, change) => { + // The update function is only present on non-redo, non-undo events. + if (change.origin === 'paste') { + const text = change.text.map(normalizeWhitespace); + change.update?.(change.from, change.to, text); + } + }); + + setQueryEditor(newEditor); + }); + + return () => { + isActive = false; + }; + }, [editorTheme, readOnly, setQueryEditor]); + + useSynchronizeSchema(queryEditor, schema, codeMirrorRef); + useSynchronizeValidationRules( + queryEditor, + validationRules ?? null, + codeMirrorRef, + ); + useSynchronizeExternalFragments( + queryEditor, + externalFragments, + codeMirrorRef, + ); + + useSynchronizeValue(queryEditor, value); + + useChangeHandler(queryEditor, onEdit); + + useCompletion(queryEditor, onHintInformationRender); + + useKeyMap(queryEditor, ['Cmd-Enter', 'Ctrl-Enter'], onRunQuery); + useKeyMap(queryEditor, ['Shift-Ctrl-C'], onCopyQuery); + useKeyMap( + queryEditor, + [ + 'Shift-Ctrl-P', + // Shift-Ctrl-P is hard coded in Firefox for private browsing so adding an alternative to Pretiffy + 'Shift-Ctrl-F', + ], + onPrettifyQuery, + ); + useKeyMap(queryEditor, ['Shift-Ctrl-M'], onMergeQuery); + + useResizeEditor(queryEditor, ref); + + return ref; +} + +function useSynchronizeSchema( + editor: CodeMirrorEditor | null, + schema: GraphQLSchema | null | undefined, + codeMirrorRef: MutableRefObject, +) { + useEffect(() => { + if (!editor) { + return; + } + + const didChange = editor.options.lint.schema !== schema; + + editor.options.lint.schema = schema; + editor.options.hintOptions.schema = schema; + editor.options.info.schema = schema; + editor.options.jump.schema = schema; + + if (didChange && codeMirrorRef.current) { + codeMirrorRef.current.signal(editor, 'change', editor); + } + }, [editor, schema, codeMirrorRef]); +} + +function useSynchronizeValidationRules( + editor: CodeMirrorEditor | null, + validationRules: ValidationRule[] | null, + codeMirrorRef: MutableRefObject, +) { + useEffect(() => { + if (!editor) { + return; + } + + const didChange = editor.options.lint.validationRules !== validationRules; + + editor.options.lint.validationRules = validationRules; + + if (didChange && codeMirrorRef.current) { + codeMirrorRef.current.signal(editor, 'change', editor); + } + }, [editor, validationRules, codeMirrorRef]); +} + +function useSynchronizeExternalFragments( + editor: CodeMirrorEditor | null, + externalFragments: string | FragmentDefinitionNode[] | undefined, + codeMirrorRef: MutableRefObject, +) { + useEffect(() => { + if (!editor) { + return; + } + + const didChange = + editor.options.lint.externalFragments !== externalFragments; + + editor.options.lint.externalFragments = externalFragments; + editor.options.hintOptions.externalFragments = externalFragments; + + if (didChange && codeMirrorRef.current) { + codeMirrorRef.current.signal(editor, 'change', editor); + } + }, [editor, externalFragments, codeMirrorRef]); +} + +const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/; diff --git a/packages/graphiql-react/src/editor/types.ts b/packages/graphiql-react/src/editor/types.ts new file mode 100644 index 00000000000..b9eeb456b4a --- /dev/null +++ b/packages/graphiql-react/src/editor/types.ts @@ -0,0 +1,5 @@ +import type { Editor } from 'codemirror'; + +export type CodeMirrorType = typeof import('codemirror'); + +export type CodeMirrorEditor = Editor & { options?: any }; diff --git a/packages/graphiql/src/utility/normalizeWhitespace.ts b/packages/graphiql-react/src/editor/whitespace.ts similarity index 69% rename from packages/graphiql/src/utility/normalizeWhitespace.ts rename to packages/graphiql-react/src/editor/whitespace.ts index 3f151734246..e0c7d4e86c2 100644 --- a/packages/graphiql/src/utility/normalizeWhitespace.ts +++ b/packages/graphiql-react/src/editor/whitespace.ts @@ -1,10 +1,3 @@ -/** - * Copyright (c) 2021 GraphQL Contributors. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - // Unicode whitespace characters that break the interface. export const invalidCharacters = Array.from({ length: 11 }, (_, i) => { // \u2000 -> \u200a diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index 8fb5b1d8375..55f9ecbb3de 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -2,6 +2,7 @@ import { EditorContext, EditorContextProvider, useHeaderEditor, + useQueryEditor, } from './editor'; import { ExplorerContext, @@ -9,7 +10,11 @@ import { useExplorerNavStack, } from './explorer'; -import type { EditorContextType, UseHeaderEditorArgs } from './editor'; +import type { + EditorContextType, + UseHeaderEditorArgs, + UseQueryEditorArgs, +} from './editor'; import type { ExplorerContextType, ExplorerFieldDef, @@ -22,6 +27,7 @@ export { EditorContext, EditorContextProvider, useHeaderEditor, + useQueryEditor, // explorer ExplorerContext, ExplorerContextProvider, @@ -32,6 +38,7 @@ export type { // editor EditorContextType, UseHeaderEditorArgs, + UseQueryEditorArgs, // explorer ExplorerContextType, ExplorerFieldDef, diff --git a/packages/graphiql-react/src/markdown.ts b/packages/graphiql-react/src/markdown.ts new file mode 100644 index 00000000000..92efb783e51 --- /dev/null +++ b/packages/graphiql-react/src/markdown.ts @@ -0,0 +1,3 @@ +import MarkdownIt from 'markdown-it'; + +export const markdown = new MarkdownIt(); diff --git a/packages/graphiql/__mocks__/@graphiql/react.ts b/packages/graphiql/__mocks__/@graphiql/react.ts new file mode 100644 index 00000000000..fed12a5e171 --- /dev/null +++ b/packages/graphiql/__mocks__/@graphiql/react.ts @@ -0,0 +1,117 @@ +import { + EditorContext, + EditorContextProvider, + ExplorerContext, + ExplorerContextProvider, + useExplorerNavStack, + useHeaderEditor as _useHeaderEditor, + useQueryEditor as _useQueryEditor, +} from '@graphiql/react'; +import type { + EditorContextType, + ExplorerContextType, + ExplorerFieldDef, + ExplorerNavStack, + ExplorerNavStackItem, + UseHeaderEditorArgs, + UseQueryEditorArgs, +} from '@graphiql/react'; +import { useEffect, useRef, useState } from 'react'; + +export { + EditorContext, + EditorContextProvider, + ExplorerContext, + ExplorerContextProvider, + useExplorerNavStack, +}; + +export type { + EditorContextType, + ExplorerContextType, + ExplorerFieldDef, + ExplorerNavStack, + ExplorerNavStackItem, + UseHeaderEditorArgs, + UseQueryEditorArgs, +}; + +function useMockedEditor(value?: string, onEdit?: (newValue: string) => void) { + const [code, setCode] = useState(value); + const ref = useRef(null); + + useEffect(() => { + if (!ref.current) { + return; + } + + if (ref.current.childElementCount > 0) { + return; + } + + const mockGutter = document.createElement('div'); + mockGutter.className = 'CodeMirror-gutter'; + + const mockTextArea = document.createElement('textarea'); + mockTextArea.className = 'mockCodeMirror'; + + const mockWrapper = document.createElement('div'); + mockWrapper.appendChild(mockGutter); + mockWrapper.appendChild(mockTextArea); + + ref.current.appendChild(mockWrapper); + }); + + useEffect(() => { + if (!ref.current) { + return; + } + + const textarea = ref.current.querySelector('.mockCodeMirror'); + if (!(textarea instanceof HTMLTextAreaElement)) { + return; + } + + function handleChange(event: Event) { + const newValue = (event.target as HTMLTextAreaElement).value; + setCode(newValue); + onEdit?.(newValue); + } + + textarea.addEventListener('change', handleChange); + return () => textarea.removeEventListener('change', handleChange); + }, [onEdit]); + + useEffect(() => { + setCode(value); + }, [value]); + + useEffect(() => { + if (!ref.current) { + return; + } + + const textarea = ref.current.querySelector('.mockCodeMirror'); + if (!(textarea instanceof HTMLTextAreaElement)) { + return; + } + + textarea.value = code; + }, [code]); + + return ref; +} + +export const useHeaderEditor: typeof _useHeaderEditor = function useHeaderEditor({ + onEdit, + value, +}) { + return useMockedEditor(value, onEdit); +}; + +export const useQueryEditor: typeof _useQueryEditor = function useQueryEditor({ + onEdit, + value, +}) { + return useMockedEditor(value, onEdit); +}; diff --git a/packages/graphiql/cypress/integration/init.spec.ts b/packages/graphiql/cypress/integration/init.spec.ts index 0750587b043..f4802d7aca8 100644 --- a/packages/graphiql/cypress/integration/init.spec.ts +++ b/packages/graphiql/cypress/integration/init.spec.ts @@ -38,12 +38,7 @@ describe('GraphiQL On Initialization', () => { '.variable-editor', ]; cy.visit(`/`); - cy.window().then(w => { - // @ts-ignore - const value = w.g.getQueryEditor().getValue(); - // this message changes between graphql 15 & 16 - expect(value).to.contain('# Welcome to GraphiQL'); - }); + cy.get('.query-editor').contains('# Welcome to GraphiQL'); containers.forEach(cSelector => cy.get(cSelector).should('be.visible')); }); diff --git a/packages/graphiql/cypress/integration/prettify.spec.ts b/packages/graphiql/cypress/integration/prettify.spec.ts index f1e61d35a54..f1a033e5809 100644 --- a/packages/graphiql/cypress/integration/prettify.spec.ts +++ b/packages/graphiql/cypress/integration/prettify.spec.ts @@ -30,11 +30,9 @@ describeOrSkip('GraphiQL Prettify', () => { cy.clickPrettify(); - cy.window().then(w => { - cy.assertHasValues({ - query: prettifiedQuery, - variablesString: prettifiedVariables, - }); + cy.assertHasValues({ + query: prettifiedQuery, + variablesString: prettifiedVariables, }); }); @@ -46,11 +44,9 @@ describeOrSkip('GraphiQL Prettify', () => { cy.clickPrettify(); - cy.window().then(w => { - cy.assertHasValues({ - query: prettifiedQuery, - variablesString: prettifiedVariables, - }); + cy.assertHasValues({ + query: prettifiedQuery, + variablesString: prettifiedVariables, }); }); @@ -59,11 +55,9 @@ describeOrSkip('GraphiQL Prettify', () => { cy.clickPrettify(); - cy.window().then(w => { - cy.assertHasValues({ - query: brokenQuery, - variablesString: prettifiedVariables, - }); + cy.assertHasValues({ + query: brokenQuery, + variablesString: prettifiedVariables, }); }); @@ -72,11 +66,9 @@ describeOrSkip('GraphiQL Prettify', () => { cy.clickPrettify(); - cy.window().then(w => { - cy.assertHasValues({ - query: prettifiedQuery, - variablesString: brokenVariables, - }); + cy.assertHasValues({ + query: prettifiedQuery, + variablesString: brokenVariables, }); }); }); diff --git a/packages/graphiql/cypress/support/commands.ts b/packages/graphiql/cypress/support/commands.ts index 28acf21629b..30d7ca99d79 100644 --- a/packages/graphiql/cypress/support/commands.ts +++ b/packages/graphiql/cypress/support/commands.ts @@ -10,12 +10,12 @@ // / +type Op = { + query: string; + variables?: Record; + variablesString?: string; +}; declare namespace Cypress { - type Op = { - query: string; - variables?: Record; - variablesString?: string; - }; type MockResult = | { data: any } | { data: any; hasNext?: boolean } @@ -65,21 +65,24 @@ Cypress.Commands.add('visitWithOp', ({ query, variables, variablesString }) => { Cypress.Commands.add( 'assertHasValues', - ({ query, variables, variablesString }) => { - cy.window().then(w => { - // @ts-ignore - expect(w.g.getQueryEditor().getValue()).to.equal(query); - if (variables) { - // @ts-ignore - expect(w.g.getVariableEditor().getValue()).to.equal( - JSON.stringify(variables, null, 2), - ); - } - if (variablesString) { - // @ts-ignore - expect(w.g.getVariableEditor().getValue()).to.equal(variablesString); - } + ({ query, variables, variablesString }: Op) => { + cy.get('.query-editor').should(element => { + expect(element.get(0).innerText).to.equal(codeWithLineNumbers(query)); }); + if (typeof variables !== 'undefined') { + cy.get('.variable-editor .codemirrorWrap').should(element => { + expect(element.get(0).innerText).to.equal( + codeWithLineNumbers(JSON.stringify(variables, null, 2)), + ); + }); + } + if (typeof variablesString !== 'undefined') { + cy.get('.variable-editor .codemirrorWrap').should(element => { + expect(element.get(0).innerText).to.equal( + codeWithLineNumbers(variablesString), + ); + }); + } }, ); @@ -102,3 +105,10 @@ Cypress.Commands.add('assertResult', (expectedResult, timeout = 200) => { expect(value).to.deep.equal(JSON.stringify(expectedResult, null, 2)); }); }); + +function codeWithLineNumbers(code: string): string { + return code + .split('\n') + .map((line, i) => `${i + 1}\n${line}`) + .join('\n'); +} diff --git a/packages/graphiql/src/components/GraphiQL.tsx b/packages/graphiql/src/components/GraphiQL.tsx index 49ba1b1fa63..cc445f46255 100644 --- a/packages/graphiql/src/components/GraphiQL.tsx +++ b/packages/graphiql/src/components/GraphiQL.tsx @@ -459,7 +459,6 @@ class GraphiQLWithContext extends React.Component< _queryHistory: Maybe; _historyStore: Maybe; editorBarComponent: Maybe; - queryEditorComponent: Maybe; resultViewerElement: Maybe; constructor(props: GraphiQLWithContextProps) { @@ -775,7 +774,6 @@ class GraphiQLWithContext extends React.Component< // If this update caused DOM nodes to have changed sizes, update the // corresponding CodeMirror instance sizes to match. this.codeMirrorSizer.updateSizes([ - this.queryEditorComponent, this.variableEditorComponent, this.resultComponent, ]); @@ -987,9 +985,6 @@ class GraphiQLWithContext extends React.Component< onMouseDown={this.handleResizeStart}>
{ - this.queryEditorComponent = n; - }} schema={this.state.schema} validationRules={this.props.validationRules} value={this.state.query} @@ -1122,10 +1117,7 @@ class GraphiQLWithContext extends React.Component< * @public */ getQueryEditor() { - if (this.queryEditorComponent) { - return this.queryEditorComponent.getCodeMirror(); - } - // return null + return this.props.editorContext?.queryEditor || null; } /** @@ -1155,9 +1147,7 @@ class GraphiQLWithContext extends React.Component< * @public */ public refresh() { - if (this.queryEditorComponent) { - this.queryEditorComponent.getCodeMirror().refresh(); - } + this.props.editorContext?.queryEditor?.refresh(); if (this.variableEditorComponent) { this.variableEditorComponent.getCodeMirror().refresh(); } @@ -1715,15 +1705,21 @@ class GraphiQLWithContext extends React.Component< }; handleMergeQuery = () => { - const editor = this.getQueryEditor() as CodeMirror.Editor; - const query = editor.getValue(); + if (!this.state.documentAST) { + return; + } + const editor = this.getQueryEditor(); + if (!editor) { + return; + } + + const query = editor.getValue(); if (!query) { return; } - const ast = this.state.documentAST!; - editor.setValue(print(mergeAST(ast, this.state.schema))); + editor.setValue(print(mergeAST(this.state.documentAST, this.state.schema))); }; handleEditQuery = debounce(100, (value: string) => { diff --git a/packages/graphiql/src/components/QueryEditor.tsx b/packages/graphiql/src/components/QueryEditor.tsx index 5ca6d02ea2f..146d7cad966 100644 --- a/packages/graphiql/src/components/QueryEditor.tsx +++ b/packages/graphiql/src/components/QueryEditor.tsx @@ -5,304 +5,18 @@ * LICENSE file in the root directory of this source tree. */ +import { useQueryEditor, UseQueryEditorArgs } from '@graphiql/react'; import React from 'react'; -import { Editor } from 'codemirror'; -import { FragmentDefinitionNode, GraphQLSchema, ValidationRule } from 'graphql'; -import MD from 'markdown-it'; -import { SchemaReference } from 'codemirror-graphql/src/utils/SchemaReference'; -import { normalizeWhitespace } from '../utility/normalizeWhitespace'; -import onHasCompletion from '../utility/onHasCompletion'; -import commonKeys from '../utility/commonKeys'; -import { SizerComponent } from '../utility/CodeMirrorSizer'; -import { importCodeMirror } from '../utility/importCodeMirror'; -import { CodeMirrorEditor } from '../types'; - -const md = new MD(); -const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/; - -type QueryEditorProps = { - schema?: GraphQLSchema | null; - validationRules?: ValidationRule[]; - value?: string; - onEdit?: (value: string) => void; - readOnly?: boolean; - onHintInformationRender: (elem: HTMLDivElement) => void; - onClickReference?: (reference: SchemaReference) => void; - onCopyQuery?: () => void; - onPrettifyQuery?: () => void; - onMergeQuery?: () => void; - onRunQuery?: () => void; - editorTheme?: string; - externalFragments?: string | FragmentDefinitionNode[]; -}; /** * QueryEditor * * Maintains an instance of CodeMirror responsible for editing a GraphQL query. * - * Props: - * - * - schema: A GraphQLSchema instance enabling editor linting and hinting. - * - value: The text of the editor. - * - onEdit: A function called when the editor changes, given the edited text. - * - readOnly: Turns the editor to read-only mode. - * */ -export class QueryEditor extends React.Component - implements SizerComponent { - cachedValue: string | undefined; - editor: CodeMirrorEditor | null = null; - ignoreChangeEvent: boolean = false; - CodeMirror: any; - _node: HTMLElement | null = null; - - constructor(props: QueryEditorProps) { - super(props); - - // Keep a cached version of the value, this cache will be updated when the - // editor is updated, which can later be used to protect the editor from - // unnecessary updates during the update lifecycle. - this.cachedValue = props.value || ''; - } - - componentDidMount() { - this.initializeEditor() - .then(editor => { - if (editor) { - editor.on('change', this._onEdit); - editor.on('keyup', this._onKeyUp); - // @ts-ignore @TODO additional args for hasCompletion event - editor.on('hasCompletion', this._onHasCompletion); - editor.on('beforeChange', this._onBeforeChange); - } - }) - .catch(console.error); - } - - componentDidUpdate(prevProps: QueryEditorProps) { - // Ensure the changes caused by this update are not interpreted as - // user-input changes which could otherwise result in an infinite - // event loop. - this.ignoreChangeEvent = true; - let signalChange = false; - if (this.props.schema !== prevProps.schema && this.editor) { - this.editor.options.lint.schema = this.props.schema; - this.editor.options.hintOptions.schema = this.props.schema; - this.editor.options.info.schema = this.props.schema; - this.editor.options.jump.schema = this.props.schema; - signalChange = true; - } - if ( - this.props.externalFragments !== prevProps.externalFragments && - this.editor - ) { - this.editor.options.lint.externalFragments = this.props.externalFragments; - this.editor.options.hintOptions.externalFragments = this.props.externalFragments; - signalChange = true; - } - if (signalChange) { - this.CodeMirror.signal(this.editor, 'change', this.editor); - } - if ( - this.props.value !== prevProps.value && - this.props.value !== this.cachedValue && - this.editor - ) { - this.cachedValue = this.props.value; - this.editor.setValue(this.props.value as string); - } - this.ignoreChangeEvent = false; - } - - componentWillUnmount() { - if (this.editor) { - this.editor.off('change', this._onEdit); - this.editor.off('keyup', this._onKeyUp); - // @ts-ignore @TODO additional args for hasCompletion event - this.editor.off('hasCompletion', this._onHasCompletion); - } - } - - render() { - return ( -
{ - this._node = node; - }} - /> - ); - } - - addonModules = () => [ - import('codemirror/addon/comment/comment'), - import('codemirror/addon/search/search'), - import('codemirror-graphql/hint'), - import('codemirror-graphql/lint'), - import('codemirror-graphql/info'), - import('codemirror-graphql/jump'), - import('codemirror-graphql/mode'), - ]; - - async initializeEditor() { - const CodeMirror = (this.CodeMirror = await importCodeMirror( - this.addonModules(), - )); - const editor = (this.editor = CodeMirror(this._node!, { - value: this.props.value ?? '', - lineNumbers: true, - tabSize: 2, - foldGutter: { - // @ts-expect-error - - minFoldSize: 4, - }, - mode: 'graphql', - theme: this.props.editorTheme || 'graphiql', - keyMap: 'sublime', - autoCloseBrackets: true, - matchBrackets: true, - showCursorWhenSelecting: true, - readOnly: this.props.readOnly ? 'nocursor' : false, - lint: { - // @ts-expect-error - - schema: this.props.schema, - validationRules: this.props.validationRules ?? null, - // linting accepts string or FragmentDefinitionNode[] - externalFragments: this.props?.externalFragments, - }, - hintOptions: { - // @ts-expect-error - - schema: this.props.schema, - closeOnUnfocus: false, - completeSingle: false, - container: this._node, - externalFragments: this.props?.externalFragments, - }, - info: { - schema: this.props.schema, - renderDescription: (text: string) => md.render(text), - onClick: (reference: SchemaReference) => - this.props.onClickReference && this.props.onClickReference(reference), - }, - jump: { - schema: this.props.schema, - onClick: (reference: SchemaReference) => - this.props.onClickReference && this.props.onClickReference(reference), - }, - gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], - extraKeys: { - 'Cmd-Space': () => - editor.showHint({ completeSingle: true, container: this._node }), - 'Ctrl-Space': () => - editor.showHint({ completeSingle: true, container: this._node }), - 'Alt-Space': () => - editor.showHint({ completeSingle: true, container: this._node }), - 'Shift-Space': () => - editor.showHint({ completeSingle: true, container: this._node }), - 'Shift-Alt-Space': () => - editor.showHint({ completeSingle: true, container: this._node }), - - 'Cmd-Enter': () => { - if (this.props.onRunQuery) { - this.props.onRunQuery(); - } - }, - 'Ctrl-Enter': () => { - if (this.props.onRunQuery) { - this.props.onRunQuery(); - } - }, - - 'Shift-Ctrl-C': () => { - if (this.props.onCopyQuery) { - this.props.onCopyQuery(); - } - }, - - 'Shift-Ctrl-P': () => { - if (this.props.onPrettifyQuery) { - this.props.onPrettifyQuery(); - } - }, - - /* Shift-Ctrl-P is hard coded in Firefox for private browsing so adding an alternative to Pretiffy */ - - 'Shift-Ctrl-F': () => { - if (this.props.onPrettifyQuery) { - this.props.onPrettifyQuery(); - } - }, - - 'Shift-Ctrl-M': () => { - if (this.props.onMergeQuery) { - this.props.onMergeQuery(); - } - }, - ...commonKeys, - 'Cmd-S': () => { - if (this.props.onRunQuery) { - // empty - } - }, - - 'Ctrl-S': () => { - if (this.props.onRunQuery) { - // empty - } - }, - }, - })) as CodeMirrorEditor; - return editor; - } - - /** - * Public API for retrieving the CodeMirror instance from this - * React component. - */ - getCodeMirror() { - return this.editor as Editor; - } - - /** - * Public API for retrieving the DOM client height for this component. - */ - getClientHeight() { - return this._node && this._node.clientHeight; - } - - private _onKeyUp = (_cm: Editor, event: KeyboardEvent) => { - if (AUTO_COMPLETE_AFTER_KEY.test(event.key) && this.editor) { - this.editor.execCommand('autocomplete'); - } - }; - - private _onEdit = () => { - if (!this.ignoreChangeEvent && this.editor) { - this.cachedValue = this.editor.getValue(); - if (this.props.onEdit) { - this.props.onEdit(this.cachedValue); - } - } - }; - - /** - * Render a custom UI for CodeMirror's hint which includes additional info - * about the type and description for the selected context. - */ - private _onHasCompletion = (cm: Editor, data: any) => { - onHasCompletion(cm, data, this.props.onHintInformationRender); - }; - - private _onBeforeChange(_instance: Editor, change: any) { - // The update function is only present on non-redo, non-undo events. - if (change.origin === 'paste') { - const text = change.text.map(normalizeWhitespace); - change.update(change.from, change.to, text); - } - } +export function QueryEditor(props: UseQueryEditorArgs) { + const ref = useQueryEditor(props); + return ( +
+ ); } diff --git a/packages/graphiql/src/utility/__tests__/normalizeWhitespace.spec.ts b/packages/graphiql/src/utility/__tests__/normalizeWhitespace.spec.ts deleted file mode 100644 index ee76a10f08a..00000000000 --- a/packages/graphiql/src/utility/__tests__/normalizeWhitespace.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) 2021 GraphQL Contributors. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { invalidCharacters, normalizeWhitespace } from '../normalizeWhitespace'; - -describe('QueryEditor', () => { - it('removes unicode characters', () => { - const result = normalizeWhitespace(invalidCharacters.join('')); - expect(result).toEqual(' '.repeat(invalidCharacters.length)); - }); -}); diff --git a/resources/tsconfig.build.cjs.json b/resources/tsconfig.build.cjs.json index 3eeeff42a39..49d0fa5a989 100644 --- a/resources/tsconfig.build.cjs.json +++ b/resources/tsconfig.build.cjs.json @@ -8,9 +8,6 @@ { "path": "../packages/graphiql-toolkit" }, - { - "path": "../packages/graphiql" - }, { "path": "../packages/monaco-graphql" }, diff --git a/resources/tsconfig.build.esm.json b/resources/tsconfig.build.esm.json index 97890a08c7c..7276cc7f12a 100644 --- a/resources/tsconfig.build.esm.json +++ b/resources/tsconfig.build.esm.json @@ -8,9 +8,6 @@ { "path": "../packages/graphiql-toolkit/tsconfig.esm.json" }, - { - "path": "../packages/graphiql/tsconfig.esm.json" - }, { "path": "../packages/codemirror-graphql/tsconfig.esm.json" }, diff --git a/resources/tsconfig.graphiql.build.cjs.json b/resources/tsconfig.graphiql.build.cjs.json new file mode 100644 index 00000000000..db66232fa06 --- /dev/null +++ b/resources/tsconfig.graphiql.build.cjs.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "../packages/graphiql" + } + ] +} diff --git a/resources/tsconfig.graphiql.build.esm.json b/resources/tsconfig.graphiql.build.esm.json new file mode 100644 index 00000000000..43ab57ddd18 --- /dev/null +++ b/resources/tsconfig.graphiql.build.esm.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "../packages/graphiql/tsconfig.esm.json" + } + ] +} diff --git a/resources/tsconfig.graphiql.json b/resources/tsconfig.graphiql.json new file mode 100644 index 00000000000..e9de0b81a31 --- /dev/null +++ b/resources/tsconfig.graphiql.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "composite": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.graphiql.build.esm.json" + }, + { + "path": "./tsconfig.graphiql.build.cjs.json" + } + ] +}