@graphiql/react
Version:
[Changelog](https://github.com/graphql/graphiql/blob/main/packages/graphiql-react/CHANGELOG.md) | [API Docs](https://graphiql-test.netlify.app/typedoc/modules/graphiql_react.html) | [NPM](https://www.npmjs.com/package/@graphiql/react)
539 lines (482 loc) • 16.1 kB
text/typescript
import { getSelectedOperationName } from '@graphiql/toolkit';
import type { SchemaReference } from 'codemirror-graphql/utils/SchemaReference';
import type {
DocumentNode,
FragmentDefinitionNode,
GraphQLSchema,
ValidationRule,
} from 'graphql';
import {
getOperationFacts,
GraphQLDocumentMode,
OperationFacts,
} from 'graphql-language-service';
import { MutableRefObject, useEffect, useRef } from 'react';
import { useExecutionContext } from '../execution';
import { useExplorerContext } from '../explorer';
import { markdown } from '../markdown';
import { DOC_EXPLORER_PLUGIN, usePluginContext } from '../plugin';
import { useSchemaContext } from '../schema';
import { useStorageContext } from '../storage';
import debounce from '../utility/debounce';
import {
commonKeys,
DEFAULT_EDITOR_THEME,
DEFAULT_KEY_MAP,
importCodeMirror,
} from './common';
import {
CodeMirrorEditorWithOperationFacts,
useEditorContext,
} from './context';
import {
useCompletion,
useCopyQuery,
UseCopyQueryArgs,
useKeyMap,
useMergeQuery,
usePrettifyEditors,
useSynchronizeOption,
} from './hooks';
import {
CodeMirrorEditor,
CodeMirrorType,
WriteableEditorProps,
} from './types';
import { normalizeWhitespace } from './whitespace';
export type UseQueryEditorArgs = WriteableEditorProps &
Pick<UseCopyQueryArgs, 'onCopyQuery'> & {
/**
* Invoked when a reference to the GraphQL schema (type or field) is clicked
* as part of the editor or one of its tooltips.
* @param reference The reference that has been clicked.
*/
onClickReference?(reference: SchemaReference): void;
/**
* Invoked when the contents of the query editor change.
* @param value The new contents of the editor.
* @param documentAST The editor contents parsed into a GraphQL document.
*/
onEdit?(value: string, documentAST?: DocumentNode): void;
};
// To make react-compiler happy, otherwise complains about using dynamic imports in Component
function importCodeMirrorImports() {
return importCodeMirror([
import('codemirror/addon/comment/comment.js'),
import('codemirror/addon/search/search.js'),
import('codemirror-graphql/esm/hint.js'),
import('codemirror-graphql/esm/lint.js'),
import('codemirror-graphql/esm/info.js'),
import('codemirror-graphql/esm/jump.js'),
import('codemirror-graphql/esm/mode.js'),
]);
}
const _useQueryEditor = useQueryEditor;
// To make react-compiler happy since we mutate variableEditor
function updateVariableEditor(
variableEditor: CodeMirrorEditor,
operationFacts?: OperationFacts,
) {
variableEditor.state.lint.linterOptions.variableToType =
operationFacts?.variableToType;
variableEditor.options.lint.variableToType = operationFacts?.variableToType;
variableEditor.options.hintOptions.variableToType =
operationFacts?.variableToType;
}
function updateEditorSchema(
editor: CodeMirrorEditor,
schema: GraphQLSchema | null,
) {
editor.state.lint.linterOptions.schema = schema;
editor.options.lint.schema = schema;
editor.options.hintOptions.schema = schema;
editor.options.info.schema = schema;
editor.options.jump.schema = schema;
}
function updateEditorValidationRules(
editor: CodeMirrorEditor,
validationRules: ValidationRule[] | null,
) {
editor.state.lint.linterOptions.validationRules = validationRules;
editor.options.lint.validationRules = validationRules;
}
function updateEditorExternalFragments(
editor: CodeMirrorEditor,
externalFragmentList: FragmentDefinitionNode[],
) {
editor.state.lint.linterOptions.externalFragments = externalFragmentList;
editor.options.lint.externalFragments = externalFragmentList;
editor.options.hintOptions.externalFragments = externalFragmentList;
}
export function useQueryEditor(
{
editorTheme = DEFAULT_EDITOR_THEME,
keyMap = DEFAULT_KEY_MAP,
onClickReference,
onCopyQuery,
onEdit,
readOnly = false,
}: UseQueryEditorArgs = {},
caller?: Function,
) {
const { schema } = useSchemaContext({
nonNull: true,
caller: caller || _useQueryEditor,
});
const {
externalFragments,
initialQuery,
queryEditor,
setOperationName,
setQueryEditor,
validationRules,
variableEditor,
updateActiveTabValues,
} = useEditorContext({
nonNull: true,
caller: caller || _useQueryEditor,
});
const executionContext = useExecutionContext();
const storage = useStorageContext();
const explorer = useExplorerContext();
const plugin = usePluginContext();
const copy = useCopyQuery({ caller: caller || _useQueryEditor, onCopyQuery });
const merge = useMergeQuery({ caller: caller || _useQueryEditor });
const prettify = usePrettifyEditors({ caller: caller || _useQueryEditor });
const ref = useRef<HTMLDivElement>(null);
const codeMirrorRef = useRef<CodeMirrorType>();
const onClickReferenceRef = useRef<
NonNullable<UseQueryEditorArgs['onClickReference']>
>(() => {});
useEffect(() => {
onClickReferenceRef.current = reference => {
if (!explorer || !plugin) {
return;
}
plugin.setVisiblePlugin(DOC_EXPLORER_PLUGIN);
switch (reference.kind) {
case 'Type': {
explorer.push({ name: reference.type.name, def: reference.type });
break;
}
case 'Field': {
explorer.push({ name: reference.field.name, def: reference.field });
break;
}
case 'Argument': {
if (reference.field) {
explorer.push({ name: reference.field.name, def: reference.field });
}
break;
}
case 'EnumValue': {
if (reference.type) {
explorer.push({ name: reference.type.name, def: reference.type });
}
break;
}
}
onClickReference?.(reference);
};
}, [explorer, onClickReference, plugin]);
useEffect(() => {
let isActive = true;
void importCodeMirrorImports().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, {
value: initialQuery,
lineNumbers: true,
tabSize: 2,
foldGutter: true,
mode: 'graphql',
theme: editorTheme,
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,
autocompleteOptions: {
// for the query editor, restrict to executable type definitions
mode: GraphQLDocumentMode.EXECUTABLE,
},
},
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
},
},
}) as CodeMirrorEditorWithOperationFacts;
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');
}
});
let showingHints = false;
// fired whenever a hint dialog opens
newEditor.on('startCompletion', () => {
showingHints = true;
});
// the codemirror hint extension fires this anytime the dialog is closed
// via any method (e.g. focus blur, escape key, ...)
newEditor.on('endCompletion', () => {
showingHints = false;
});
newEditor.on('keydown', (editorInstance, event) => {
if (event.key === 'Escape' && showingHints) {
event.stopPropagation();
}
});
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);
}
});
newEditor.documentAST = null;
newEditor.operationName = null;
newEditor.operations = null;
newEditor.variableToType = null;
setQueryEditor(newEditor);
});
return () => {
isActive = false;
};
}, [editorTheme, initialQuery, readOnly, setQueryEditor]);
useSynchronizeOption(queryEditor, 'keyMap', keyMap);
/**
* We don't use the generic `useChangeHandler` hook here because we want to
* have additional logic that updates the operation facts that we store as
* properties on the editor.
*/
useEffect(() => {
if (!queryEditor) {
return;
}
function getAndUpdateOperationFacts(
editorInstance: CodeMirrorEditorWithOperationFacts,
) {
const operationFacts = getOperationFacts(
schema,
editorInstance.getValue(),
);
// Update operation name should any query names change.
const operationName = getSelectedOperationName(
editorInstance.operations ?? undefined,
editorInstance.operationName ?? undefined,
operationFacts?.operations,
);
// Store the operation facts on editor properties
editorInstance.documentAST = operationFacts?.documentAST ?? null;
editorInstance.operationName = operationName ?? null;
editorInstance.operations = operationFacts?.operations ?? null;
// Update variable types for the variable editor
if (variableEditor) {
updateVariableEditor(variableEditor, operationFacts);
codeMirrorRef.current?.signal(variableEditor, 'change', variableEditor);
}
return operationFacts ? { ...operationFacts, operationName } : null;
}
const handleChange = debounce(
100,
(editorInstance: CodeMirrorEditorWithOperationFacts) => {
const query = editorInstance.getValue();
storage?.set(STORAGE_KEY_QUERY, query);
const currentOperationName = editorInstance.operationName;
const operationFacts = getAndUpdateOperationFacts(editorInstance);
if (operationFacts?.operationName !== undefined) {
storage?.set(
STORAGE_KEY_OPERATION_NAME,
operationFacts.operationName,
);
}
// Invoke callback props only after the operation facts have been updated
onEdit?.(query, operationFacts?.documentAST);
if (
operationFacts?.operationName &&
currentOperationName !== operationFacts.operationName
) {
setOperationName(operationFacts.operationName);
}
updateActiveTabValues({
query,
operationName: operationFacts?.operationName ?? null,
});
},
) as (editorInstance: CodeMirrorEditor) => void;
// Call once to initially update the values
getAndUpdateOperationFacts(queryEditor);
queryEditor.on('change', handleChange);
return () => queryEditor.off('change', handleChange);
}, [
onEdit,
queryEditor,
schema,
setOperationName,
storage,
variableEditor,
updateActiveTabValues,
]);
useSynchronizeSchema(queryEditor, schema ?? null, codeMirrorRef);
useSynchronizeValidationRules(
queryEditor,
validationRules ?? null,
codeMirrorRef,
);
useSynchronizeExternalFragments(
queryEditor,
externalFragments,
codeMirrorRef,
);
useCompletion(queryEditor, onClickReference || null, _useQueryEditor);
const run = executionContext?.run;
const runAtCursor = () => {
if (
!run ||
!queryEditor ||
!queryEditor.operations ||
!queryEditor.hasFocus()
) {
run?.();
return;
}
const cursorIndex = queryEditor.indexFromPos(queryEditor.getCursor());
// Loop through all operations to see if one contains the cursor.
let operationName: string | undefined;
for (const operation of queryEditor.operations) {
if (
operation.loc &&
operation.loc.start <= cursorIndex &&
operation.loc.end >= cursorIndex
) {
operationName = operation.name?.value;
}
}
if (operationName && operationName !== queryEditor.operationName) {
setOperationName(operationName);
}
run();
};
useKeyMap(queryEditor, ['Cmd-Enter', 'Ctrl-Enter'], runAtCursor);
useKeyMap(queryEditor, ['Shift-Ctrl-C'], copy);
useKeyMap(
queryEditor,
[
'Shift-Ctrl-P',
// Shift-Ctrl-P is hard coded in Firefox for private browsing so adding an alternative to prettify
'Shift-Ctrl-F',
],
prettify,
);
useKeyMap(queryEditor, ['Shift-Ctrl-M'], merge);
return ref;
}
function useSynchronizeSchema(
editor: CodeMirrorEditor | null,
schema: GraphQLSchema | null,
codeMirrorRef: MutableRefObject<CodeMirrorType | undefined>,
) {
useEffect(() => {
if (!editor) {
return;
}
const didChange = editor.options.lint.schema !== schema;
updateEditorSchema(editor, schema);
if (didChange && codeMirrorRef.current) {
codeMirrorRef.current.signal(editor, 'change', editor);
}
}, [editor, schema, codeMirrorRef]);
}
function useSynchronizeValidationRules(
editor: CodeMirrorEditor | null,
validationRules: ValidationRule[] | null,
codeMirrorRef: MutableRefObject<CodeMirrorType | undefined>,
) {
useEffect(() => {
if (!editor) {
return;
}
const didChange = editor.options.lint.validationRules !== validationRules;
updateEditorValidationRules(editor, validationRules);
if (didChange && codeMirrorRef.current) {
codeMirrorRef.current.signal(editor, 'change', editor);
}
}, [editor, validationRules, codeMirrorRef]);
}
function useSynchronizeExternalFragments(
editor: CodeMirrorEditor | null,
externalFragments: Map<string, FragmentDefinitionNode>,
codeMirrorRef: MutableRefObject<CodeMirrorType | undefined>,
) {
const externalFragmentList = [...externalFragments.values()]; // eslint-disable-line react-hooks/exhaustive-deps -- false positive, variable is optimized by react-compiler, no need to wrap with useMemo
useEffect(() => {
if (!editor) {
return;
}
const didChange =
editor.options.lint.externalFragments !== externalFragmentList;
updateEditorExternalFragments(editor, externalFragmentList);
if (didChange && codeMirrorRef.current) {
codeMirrorRef.current.signal(editor, 'change', editor);
}
}, [editor, externalFragmentList, codeMirrorRef]);
}
const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/;
export const STORAGE_KEY_QUERY = 'query';
const STORAGE_KEY_OPERATION_NAME = 'operationName';