UNPKG

@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)

470 lines (425 loc) 13.5 kB
import { fillLeafs, GetDefaultFieldNamesFn, mergeAst } from '@graphiql/toolkit'; import type { EditorChange, EditorConfiguration } from 'codemirror'; import type { SchemaReference } from 'codemirror-graphql/utils/SchemaReference'; import copyToClipboard from 'copy-to-clipboard'; import { parse, print } from 'graphql'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports -- TODO: check why query builder update only 1st field https://github.com/graphql/graphiql/issues/3836 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useExplorerContext } from '../explorer'; import { usePluginContext } from '../plugin'; import { useSchemaContext } from '../schema'; import { useStorageContext } from '../storage'; import debounce from '../utility/debounce'; import { onHasCompletion } from './completion'; import { useEditorContext } from './context'; import { CodeMirrorEditor } from './types'; export function useSynchronizeValue( editor: CodeMirrorEditor | null, value?: string, ) { useEffect(() => { if (editor && typeof value === 'string' && value !== editor.getValue()) { editor.setValue(value); } }, [editor, value]); } export function useSynchronizeOption<K extends keyof EditorConfiguration>( editor: CodeMirrorEditor | null, option: K, value: EditorConfiguration[K], ) { useEffect(() => { if (editor) { editor.setOption(option, value); } }, [editor, option, value]); } export function useChangeHandler( editor: CodeMirrorEditor | null, callback: ((value: string) => void) | undefined, storageKey: string | null, tabProperty: 'variables' | 'headers', caller: Function, ) { const { updateActiveTabValues } = useEditorContext({ nonNull: true, caller }); const storage = useStorageContext(); useEffect(() => { if (!editor) { return; } const store = debounce(500, (value: string) => { if (!storage || storageKey === null) { return; } storage.set(storageKey, value); }); const updateTab = debounce(100, (value: string) => { updateActiveTabValues({ [tabProperty]: value }); }); const handleChange = ( editorInstance: CodeMirrorEditor, changeObj?: EditorChange, ) => { // When we signal a change manually without actually changing anything // we don't want to invoke the callback. if (!changeObj) { return; } const newValue = editorInstance.getValue(); store(newValue); updateTab(newValue); callback?.(newValue); }; editor.on('change', handleChange); return () => editor.off('change', handleChange); }, [ callback, editor, storage, storageKey, tabProperty, updateActiveTabValues, ]); } export function useCompletion( editor: CodeMirrorEditor | null, callback: ((reference: SchemaReference) => void) | null, caller: Function, ) { const { schema } = useSchemaContext({ nonNull: true, caller }); const explorer = useExplorerContext(); const plugin = usePluginContext(); useEffect(() => { if (!editor) { return; } const handleCompletion = ( instance: CodeMirrorEditor, changeObj?: EditorChange, ) => { onHasCompletion(instance, changeObj, schema, explorer, plugin, type => { callback?.({ kind: 'Type', type, schema: schema || undefined }); }); }; editor.on( // @ts-expect-error @TODO additional args for hasCompletion event 'hasCompletion', handleCompletion, ); return () => editor.off( // @ts-expect-error @TODO additional args for hasCompletion event 'hasCompletion', handleCompletion, ); }, [callback, editor, explorer, plugin, schema]); } type EmptyCallback = () => void; export function useKeyMap( editor: CodeMirrorEditor | null, keys: string[], callback?: EmptyCallback, ) { useEffect(() => { if (!editor) { return; } for (const key of keys) { editor.removeKeyMap(key); } if (callback) { const keyMap: Record<string, EmptyCallback> = {}; for (const key of keys) { keyMap[key] = () => callback(); } editor.addKeyMap(keyMap); } }, [editor, keys, callback]); } export type UseCopyQueryArgs = { /** * This is only meant to be used internally in `@graphiql/react`. */ caller?: Function; /** * Invoked when the current contents of the query editor are copied to the * clipboard. * @param query The content that has been copied. */ onCopyQuery?: (query: string) => void; }; // To make react-compiler happy, otherwise complains about - Hooks may not be referenced as normal values const _useCopyQuery = useCopyQuery; const _useMergeQuery = useMergeQuery; const _usePrettifyEditors = usePrettifyEditors; const _useAutoCompleteLeafs = useAutoCompleteLeafs; export function useCopyQuery({ caller, onCopyQuery }: UseCopyQueryArgs = {}) { const { queryEditor } = useEditorContext({ nonNull: true, caller: caller || _useCopyQuery, }); return () => { if (!queryEditor) { return; } const query = queryEditor.getValue(); copyToClipboard(query); onCopyQuery?.(query); }; } type UseMergeQueryArgs = { /** * This is only meant to be used internally in `@graphiql/react`. */ caller?: Function; }; export function useMergeQuery({ caller }: UseMergeQueryArgs = {}) { const { queryEditor } = useEditorContext({ nonNull: true, caller: caller || _useMergeQuery, }); const { schema } = useSchemaContext({ nonNull: true, caller: _useMergeQuery, }); return () => { const documentAST = queryEditor?.documentAST; const query = queryEditor?.getValue(); if (!documentAST || !query) { return; } queryEditor.setValue(print(mergeAst(documentAST, schema))); }; } type UsePrettifyEditorsArgs = { /** * This is only meant to be used internally in `@graphiql/react`. */ caller?: Function; }; export function usePrettifyEditors({ caller }: UsePrettifyEditorsArgs = {}) { const { queryEditor, headerEditor, variableEditor } = useEditorContext({ nonNull: true, caller: caller || _usePrettifyEditors, }); return () => { if (variableEditor) { const variableEditorContent = variableEditor.getValue(); try { const prettifiedVariableEditorContent = JSON.stringify( JSON.parse(variableEditorContent), null, 2, ); if (prettifiedVariableEditorContent !== variableEditorContent) { variableEditor.setValue(prettifiedVariableEditorContent); } } catch { /* Parsing JSON failed, skip prettification */ } } if (headerEditor) { const headerEditorContent = headerEditor.getValue(); try { const prettifiedHeaderEditorContent = JSON.stringify( JSON.parse(headerEditorContent), null, 2, ); if (prettifiedHeaderEditorContent !== headerEditorContent) { headerEditor.setValue(prettifiedHeaderEditorContent); } } catch { /* Parsing JSON failed, skip prettification */ } } if (queryEditor) { const editorContent = queryEditor.getValue(); const prettifiedEditorContent = print(parse(editorContent)); if (prettifiedEditorContent !== editorContent) { queryEditor.setValue(prettifiedEditorContent); } } }; } export type UseAutoCompleteLeafsArgs = { /** * A function to determine which field leafs are automatically added when * trying to execute a query with missing selection sets. It will be called * with the `GraphQLType` for which fields need to be added. */ getDefaultFieldNames?: GetDefaultFieldNamesFn; /** * This is only meant to be used internally in `@graphiql/react`. */ caller?: Function; }; export function useAutoCompleteLeafs({ getDefaultFieldNames, caller, }: UseAutoCompleteLeafsArgs = {}) { const { schema } = useSchemaContext({ nonNull: true, caller: caller || _useAutoCompleteLeafs, }); const { queryEditor } = useEditorContext({ nonNull: true, caller: caller || _useAutoCompleteLeafs, }); return () => { if (!queryEditor) { return; } const query = queryEditor.getValue(); const { insertions, result } = fillLeafs( schema, query, getDefaultFieldNames, ); if (insertions && insertions.length > 0) { queryEditor.operation(() => { const cursor = queryEditor.getCursor(); const cursorIndex = queryEditor.indexFromPos(cursor); queryEditor.setValue(result || ''); let added = 0; const markers = insertions.map(({ index, string }) => queryEditor.markText( queryEditor.posFromIndex(index + added), queryEditor.posFromIndex(index + (added += string.length)), { className: 'auto-inserted-leaf', clearOnEnter: true, title: 'Automatically added leaf fields', }, ), ); setTimeout(() => { for (const marker of markers) { marker.clear(); } }, 7000); let newCursorIndex = cursorIndex; for (const { index, string } of insertions) { if (index < cursorIndex) { newCursorIndex += string.length; } } queryEditor.setCursor(queryEditor.posFromIndex(newCursorIndex)); }); } return result; }; } // https://react.dev/learn/you-might-not-need-an-effect export const useEditorState = (editor: 'query' | 'variable' | 'header') => { 'use no memo'; // eslint-disable-line react-compiler/react-compiler -- TODO: check why query builder update only 1st field https://github.com/graphql/graphiql/issues/3836 const context = useEditorContext({ nonNull: true, }); const editorInstance = context[`${editor}Editor` as const]; let valueString = ''; const editorValue = editorInstance?.getValue() ?? false; if (editorValue && editorValue.length > 0) { valueString = editorValue; } const handleEditorValue = useCallback( (value: string) => editorInstance?.setValue(value), [editorInstance], ); return useMemo<[string, (val: string) => void]>( () => [valueString, handleEditorValue], [valueString, handleEditorValue], ); }; /** * useState-like hook for current tab operations editor state */ export const useOperationsEditorState = (): [ operations: string, setOperations: (content: string) => void, ] => { return useEditorState('query'); }; /** * useState-like hook for current tab variables editor state */ export const useVariablesEditorState = (): [ variables: string, setVariables: (content: string) => void, ] => { return useEditorState('variable'); }; /** * useState-like hook for current tab variables editor state */ export const useHeadersEditorState = (): [ headers: string, setHeaders: (content: string) => void, ] => { return useEditorState('header'); }; /** * Implements an optimistic caching strategy around a useState-like hook in * order to prevent loss of updates when the hook has an internal delay and the * update function is called again before the updated state is sent out. * * Use this as a wrapper around `useOperationsEditorState`, * `useVariablesEditorState`, or `useHeadersEditorState` if you anticipate * calling them with great frequency (due to, for instance, mouse, keyboard, or * network events). * * Example: * * ```ts * const [operationsString, handleEditOperations] = * useOptimisticState(useOperationsEditorState()); * ``` */ export function useOptimisticState([ upstreamState, upstreamSetState, ]: ReturnType<typeof useEditorState>): ReturnType<typeof useEditorState> { const lastStateRef = useRef({ /** The last thing that we sent upstream; we're expecting this back */ pending: null as string | null, /** The last thing we received from upstream */ last: upstreamState, }); const [state, setOperationsText] = useState(upstreamState); useEffect(() => { if (lastStateRef.current.last === upstreamState) { // No change; ignore } else { lastStateRef.current.last = upstreamState; if (lastStateRef.current.pending === null) { // Gracefully accept update from upstream setOperationsText(upstreamState); } else if (lastStateRef.current.pending === upstreamState) { // They received our update and sent it back to us - clear pending, and // send next if appropriate lastStateRef.current.pending = null; if (upstreamState !== state) { // Change has occurred; upstream it lastStateRef.current.pending = state; upstreamSetState(state); } } else { // They got a different update; overwrite our local state (!!) lastStateRef.current.pending = null; setOperationsText(upstreamState); } } }, [upstreamState, state, upstreamSetState]); const setState = (newState: string) => { setOperationsText(newState); if ( lastStateRef.current.pending === null && lastStateRef.current.last !== newState ) { // No pending updates and change has occurred... send it upstream lastStateRef.current.pending = newState; upstreamSetState(newState); } }; return [state, setState]; }