From 3bdff89ee7804484486b0936d5b5ab1548f875dd Mon Sep 17 00:00:00 2001 From: Thomas Heyenbrock Date: Mon, 30 May 2022 13:15:54 +0200 Subject: [PATCH] add execution context to `@graphiql/react` --- .changeset/big-forks-deny.md | 6 + package.json | 1 - packages/graphiql-react/package.json | 4 +- .../src/editor/header-editor.tsx | 7 +- packages/graphiql-react/src/editor/hooks.ts | 2 +- .../src/editor/query-editor.tsx | 7 +- .../src/editor/variable-editor.tsx | 7 +- packages/graphiql-react/src/execution.tsx | 369 ++++++++++++++ packages/graphiql-react/src/index.ts | 12 + .../graphiql/__mocks__/@graphiql/react.ts | 8 + packages/graphiql/package.json | 1 - .../graphiql/src/components/ExecuteButton.tsx | 36 +- packages/graphiql/src/components/GraphiQL.tsx | 479 ++---------------- .../components/__tests__/GraphiQL.spec.tsx | 2 + packages/graphiql/test/schema.js | 2 +- 15 files changed, 472 insertions(+), 471 deletions(-) create mode 100644 .changeset/big-forks-deny.md create mode 100644 packages/graphiql-react/src/execution.tsx diff --git a/.changeset/big-forks-deny.md b/.changeset/big-forks-deny.md new file mode 100644 index 00000000000..9347d6a8a1d --- /dev/null +++ b/.changeset/big-forks-deny.md @@ -0,0 +1,6 @@ +--- +'@graphiql/react': minor +'graphiql': patch +--- + +Add execution context to `@graphiql/react` and move over the logic from `graphiql` diff --git a/package.json b/package.json index 68e6d15b0db..cf70fe12748 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,6 @@ "@types/node": "^14.14.22", "@types/prettier": "^2.0.0", "@types/react": "^17.0.37", - "@types/set-value": "^4.0.1", "@types/theme-ui": "^0.3.1", "@types/ws": "^7.4.0", "@typescript-eslint/eslint-plugin": "^4.14.0", diff --git a/packages/graphiql-react/package.json b/packages/graphiql-react/package.json index 1ca9db94434..a8b2ef8c03e 100644 --- a/packages/graphiql-react/package.json +++ b/packages/graphiql-react/package.json @@ -36,11 +36,13 @@ "copy-to-clipboard": "^3.2.0", "escape-html": "^1.0.3", "graphql-language-service": "^5.0.4", - "markdown-it": "^12.2.0" + "markdown-it": "^12.2.0", + "set-value": "^4.1.0" }, "devDependencies": { "@types/codemirror": "^5.60.5", "@types/escape-html": "^1.0.1", + "@types/set-value": "^4.0.1", "@vitejs/plugin-react": "^1.3.0", "graphql": "^16.4.0", "react": "^17.0.2", diff --git a/packages/graphiql-react/src/editor/header-editor.tsx b/packages/graphiql-react/src/editor/header-editor.tsx index d562bcb1600..e91ec87f189 100644 --- a/packages/graphiql-react/src/editor/header-editor.tsx +++ b/packages/graphiql-react/src/editor/header-editor.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef } from 'react'; +import { useExecutionContext } from '../execution'; import { commonKeys, importCodeMirror } from './common'; import { useEditorContext } from './context'; import { EditCallback, - EmptyCallback, useChangeHandler, useCompletion, useKeyMap, @@ -16,7 +16,6 @@ import { export type UseHeaderEditorArgs = { editorTheme?: string; onEdit?: EditCallback; - onRunQuery?: EmptyCallback; readOnly?: boolean; shouldPersistHeaders?: boolean; }; @@ -24,7 +23,6 @@ export type UseHeaderEditorArgs = { export function useHeaderEditor({ editorTheme = 'graphiql', onEdit, - onRunQuery, readOnly = false, shouldPersistHeaders = false, }: UseHeaderEditorArgs = {}) { @@ -32,6 +30,7 @@ export function useHeaderEditor({ nonNull: true, caller: useHeaderEditor, }); + const executionContext = useExecutionContext(); const merge = useMergeQuery({ caller: useHeaderEditor }); const prettify = usePrettifyEditors({ caller: useHeaderEditor }); const ref = useRef(null); @@ -114,7 +113,7 @@ export function useHeaderEditor({ useCompletion(headerEditor); - useKeyMap(headerEditor, ['Cmd-Enter', 'Ctrl-Enter'], onRunQuery); + useKeyMap(headerEditor, ['Cmd-Enter', 'Ctrl-Enter'], executionContext?.run); useKeyMap(headerEditor, ['Shift-Ctrl-P'], prettify); useKeyMap(headerEditor, ['Shift-Ctrl-M'], merge); diff --git a/packages/graphiql-react/src/editor/hooks.ts b/packages/graphiql-react/src/editor/hooks.ts index 03c964c0d1a..67e907678e8 100644 --- a/packages/graphiql-react/src/editor/hooks.ts +++ b/packages/graphiql-react/src/editor/hooks.ts @@ -97,7 +97,7 @@ export function useCompletion(editor: CodeMirrorEditor | null) { }, [editor, explorer, schema]); } -export type EmptyCallback = () => void; +type EmptyCallback = () => void; export function useKeyMap( editor: CodeMirrorEditor | null, diff --git a/packages/graphiql-react/src/editor/query-editor.tsx b/packages/graphiql-react/src/editor/query-editor.tsx index 859b01c1ace..61240d0a2fe 100644 --- a/packages/graphiql-react/src/editor/query-editor.tsx +++ b/packages/graphiql-react/src/editor/query-editor.tsx @@ -8,6 +8,7 @@ import type { import { getOperationFacts } from 'graphql-language-service'; import { MutableRefObject, useEffect, useRef } from 'react'; +import { useExecutionContext } from '../execution'; import { useExplorerContext } from '../explorer'; import { markdown } from '../markdown'; import { useSchemaContext } from '../schema'; @@ -21,7 +22,6 @@ import { import { CopyQueryCallback, EditCallback, - EmptyCallback, useCompletion, useCopyQuery, useKeyMap, @@ -40,7 +40,6 @@ export type UseQueryEditorArgs = { onEdit?: EditCallback; onEditOperationName?: EditCallback; onCopyQuery?: CopyQueryCallback; - onRunQuery?: EmptyCallback; readOnly?: boolean; validationRules?: ValidationRule[]; }; @@ -51,7 +50,6 @@ export function useQueryEditor({ onEdit, onEditOperationName, onCopyQuery, - onRunQuery, readOnly = false, validationRules, }: UseQueryEditorArgs = {}) { @@ -69,6 +67,7 @@ export function useQueryEditor({ nonNull: true, caller: useQueryEditor, }); + const executionContext = useExecutionContext(); const storage = useStorageContext(); const explorer = useExplorerContext(); const copy = useCopyQuery({ caller: useQueryEditor, onCopyQuery }); @@ -322,7 +321,7 @@ export function useQueryEditor({ useCompletion(queryEditor); - useKeyMap(queryEditor, ['Cmd-Enter', 'Ctrl-Enter'], onRunQuery); + useKeyMap(queryEditor, ['Cmd-Enter', 'Ctrl-Enter'], executionContext?.run); useKeyMap(queryEditor, ['Shift-Ctrl-C'], copy); useKeyMap( queryEditor, diff --git a/packages/graphiql-react/src/editor/variable-editor.tsx b/packages/graphiql-react/src/editor/variable-editor.tsx index 58d3dcf9264..3f3e3b1b6d0 100644 --- a/packages/graphiql-react/src/editor/variable-editor.tsx +++ b/packages/graphiql-react/src/editor/variable-editor.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef } from 'react'; +import { useExecutionContext } from '../execution'; import { commonKeys, importCodeMirror } from './common'; import { useEditorContext } from './context'; import { EditCallback, - EmptyCallback, useChangeHandler, useCompletion, useKeyMap, @@ -17,14 +17,12 @@ import { CodeMirrorType } from './types'; export type UseVariableEditorArgs = { editorTheme?: string; onEdit?: EditCallback; - onRunQuery?: EmptyCallback; readOnly?: boolean; }; export function useVariableEditor({ editorTheme = 'graphiql', onEdit, - onRunQuery, readOnly = false, }: UseVariableEditorArgs = {}) { const { @@ -35,6 +33,7 @@ export function useVariableEditor({ nonNull: true, caller: useVariableEditor, }); + const executionContext = useExecutionContext(); const merge = useMergeQuery({ caller: useVariableEditor }); const prettify = usePrettifyEditors({ caller: useVariableEditor }); const ref = useRef(null); @@ -132,7 +131,7 @@ export function useVariableEditor({ useCompletion(variableEditor); - useKeyMap(variableEditor, ['Cmd-Enter', 'Ctrl-Enter'], onRunQuery); + useKeyMap(variableEditor, ['Cmd-Enter', 'Ctrl-Enter'], executionContext?.run); useKeyMap(variableEditor, ['Shift-Ctrl-P'], prettify); useKeyMap(variableEditor, ['Shift-Ctrl-M'], merge); diff --git a/packages/graphiql-react/src/execution.tsx b/packages/graphiql-react/src/execution.tsx new file mode 100644 index 00000000000..5105fd49878 --- /dev/null +++ b/packages/graphiql-react/src/execution.tsx @@ -0,0 +1,369 @@ +import { + Fetcher, + FetcherResultPayload, + formatError, + formatResult, + isAsyncIterable, + isObservable, + Unsubscribable, +} from '@graphiql/toolkit'; +import { + ExecutionResult, + FragmentDefinitionNode, + parse, + print, + visit, +} from 'graphql'; +import { getFragmentDependenciesForAST } from 'graphql-language-service'; +import { ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import setValue from 'set-value'; + +import { + useAutoCompleteLeafs, + useEditorContext, + UseQueryEditorArgs, +} from './editor'; +import { EditCallback } from './editor/hooks'; +import { useHistoryContext } from './history'; +import { createContextHook, createNullableContext } from './utility/context'; + +export type ExecutionContextType = { + isFetching: boolean; + run(selectedOperationName?: string): void; + stop(): void; + subscription: Unsubscribable | null; +}; + +export const ExecutionContext = createNullableContext( + 'ExecutionContext', +); + +type ExecutionContextProviderProps = { + children: ReactNode; + externalFragments?: FragmentDefinitionNode[] | string; + fetcher: Fetcher; + onEditOperationName?: EditCallback; + shouldPersistHeaders?: boolean; +}; + +export function ExecutionContextProvider(props: ExecutionContextProviderProps) { + const { + headerEditor, + queryEditor, + responseEditor, + variableEditor, + updateActiveTabValues, + } = useEditorContext({ nonNull: true, caller: ExecutionContextProvider }); + const history = useHistoryContext(); + const autoCompleteLeafs = useAutoCompleteLeafs(); + const [isFetching, setIsFetching] = useState(false); + const [subscription, setSubscription] = useState(null); + const queryIdRef = useRef(0); + + const stop = useCallback(() => { + subscription?.unsubscribe(); + setIsFetching(false); + setSubscription(null); + }, [subscription]); + + const { + externalFragments, + fetcher, + onEditOperationName, + shouldPersistHeaders, + } = props; + const run = useCallback( + async _selectedOperationName => { + if (!queryEditor || !responseEditor) { + return; + } + + // If there's an active subscription, unsubscribe it and return + if (subscription) { + stop(); + return; + } + + const setResponse = (value: string) => { + responseEditor.setValue(value); + updateActiveTabValues({ response: value }); + }; + + queryIdRef.current += 1; + const queryId = queryIdRef.current; + + // Use the edited query after autoCompleteLeafs() runs or, + // in case autoCompletion fails (the function returns undefined), + // the current query from the editor. + let query = autoCompleteLeafs() || queryEditor.getValue(); + + const variablesString = variableEditor?.getValue(); + const variables = tryParseJson( + variablesString, + 'Variables are invalid JSON', + ); + if (typeof variables !== 'object') { + throw new Error('Variables are not a JSON object.'); + } + + const headersString = headerEditor?.getValue(); + const headers = tryParseJson(headersString, 'Headers are invalid JSON'); + if (typeof headers !== 'object') { + throw new Error('Headers are not a JSON object.'); + } + + const selectedOperationName = + _selectedOperationName || + // If no operation name is provided explicitly then try to derive it + // from the current cursor position + (() => { + if (!queryEditor.operations || !queryEditor.hasFocus()) { + return undefined; + } + + const cursorIndex = queryEditor.indexFromPos(queryEditor.getCursor()); + + // Loop through all operations to see if one contains the cursor. + for (const operation of queryEditor.operations) { + if ( + operation.loc && + operation.loc.start <= cursorIndex && + operation.loc.end >= cursorIndex + ) { + return operation.name && operation.name.value; + } + } + + return undefined; + })(); + + let operationName = queryEditor.operationName; + if (selectedOperationName && selectedOperationName !== operationName) { + // If an operation was explicitly provided, different from the current + // operation name, then report that it changed. + operationName = selectedOperationName; + + queryEditor.operationName = selectedOperationName; + updateActiveTabValues({ operationName: selectedOperationName }); + onEditOperationName?.(selectedOperationName); + } + + if (externalFragments) { + const externalFragmentsMap = new Map(); + + if (Array.isArray(externalFragments)) { + externalFragments.forEach(def => { + externalFragmentsMap.set(def.name.value, def); + }); + } else { + visit(parse(externalFragments, {}), { + FragmentDefinition(def) { + externalFragmentsMap.set(def.name.value, def); + }, + }); + } + + const fragmentDependencies = queryEditor.documentAST + ? getFragmentDependenciesForAST( + queryEditor.documentAST, + externalFragmentsMap, + ) + : []; + if (fragmentDependencies.length > 0) { + query += + '\n' + + fragmentDependencies + .map((node: FragmentDefinitionNode) => print(node)) + .join('\n'); + } + } + + setResponse(''); + setIsFetching(true); + + history?.addToHistory({ + query, + variables: variablesString, + headers: headersString, + operationName: operationName ?? undefined, + }); + + try { + let fullResponse: FetcherResultPayload = { data: {} }; + const handleResponse = (result: ExecutionResult) => { + // A different query was dispatched in the meantime, so don't + // show the results of this one. + if (queryId !== queryIdRef.current) { + return; + } + + let maybeMultipart = Array.isArray(result) ? result : false; + if ( + !maybeMultipart && + typeof result === 'object' && + result !== null && + 'hasNext' in result + ) { + maybeMultipart = [result]; + } + + if (maybeMultipart) { + const payload: FetcherResultPayload = { + data: fullResponse.data, + }; + const maybeErrors = [ + ...(fullResponse?.errors || []), + ...maybeMultipart + .map(i => i.errors) + .flat() + .filter(Boolean), + ]; + + if (maybeErrors.length) { + payload.errors = maybeErrors; + } + + for (const part of maybeMultipart) { + // We pull out errors here, so we dont include it later + const { path, data, errors: _errors, ...rest } = part; + if (path) { + if (!data) { + throw new Error( + `Expected part to contain a data property, but got ${part}`, + ); + } + + setValue(payload.data, path, data, { merge: true }); + } else if (data) { + // If there is no path, we don't know what to do with the payload, + // so we just set it. + payload.data = part.data; + } + + // Ensures we also bring extensions and alike along for the ride + fullResponse = { + ...payload, + ...rest, + }; + } + + setIsFetching(false); + setResponse(formatResult(fullResponse)); + } else { + const response = formatResult(result); + setIsFetching(false); + setResponse(response); + } + }; + + const fetch = fetcher( + { + query, + variables, + operationName: queryEditor.operationName, + }, + { + headers: headers ?? undefined, + shouldPersistHeaders, + documentAST: queryEditor.documentAST ?? undefined, + }, + ); + + const value = await Promise.resolve(fetch); + if (isObservable(value)) { + // If the fetcher returned an Observable, then subscribe to it, calling + // the callback on each next value, and handling both errors and the + // completion of the Observable. + setSubscription( + value.subscribe({ + next(result) { + handleResponse(result); + }, + error(error: Error) { + setIsFetching(false); + if (error) { + setResponse(formatError(error)); + } + setSubscription(null); + }, + complete() { + setIsFetching(false); + setSubscription(null); + }, + }), + ); + } else if (isAsyncIterable(value)) { + (async () => { + try { + for await (const result of value) { + handleResponse(result); + } + setIsFetching(false); + setSubscription(null); + } catch (error) { + setIsFetching(false); + setResponse( + formatError( + error instanceof Error ? error : new Error(`${error}`), + ), + ); + setSubscription(null); + } + })(); + + setSubscription({ + unsubscribe: () => value[Symbol.asyncIterator]().return?.(), + }); + } else { + handleResponse(value); + } + } catch (error) { + setIsFetching(false); + setResponse( + formatError(error instanceof Error ? error : new Error(`${error}`)), + ); + setSubscription(null); + } + }, + [ + autoCompleteLeafs, + externalFragments, + fetcher, + headerEditor, + history, + onEditOperationName, + queryEditor, + responseEditor, + shouldPersistHeaders, + stop, + subscription, + updateActiveTabValues, + variableEditor, + ], + ); + + const value = useMemo( + () => ({ isFetching, run, stop, subscription }), + [isFetching, run, stop, subscription], + ); + + return ( + + {props.children} + + ); +} + +export const useExecutionContext = createContextHook(ExecutionContext); + +function tryParseJson(str: string | undefined, errorMessage: string) { + let parsed: Record | string | number | boolean | null = null; + try { + parsed = str && str.trim() !== '' ? JSON.parse(str) : null; + } catch (error) { + throw new Error( + `${errorMessage}: ${error instanceof Error ? error.message : error}.`, + ); + } + return parsed; +} diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index 74fe0e3d55f..5692b62ddac 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -13,6 +13,11 @@ import { useResponseEditor, useVariableEditor, } from './editor'; +import { + ExecutionContext, + ExecutionContextProvider, + useExecutionContext, +} from './execution'; import { ExplorerContext, ExplorerContextProvider, @@ -43,6 +48,7 @@ import type { UseResponseEditorArgs, UseVariableEditorArgs, } from './editor'; +import type { ExecutionContextType } from './execution'; import type { ExplorerContextType, ExplorerFieldDef, @@ -68,6 +74,10 @@ export { useQueryEditor, useResponseEditor, useVariableEditor, + // execution + ExecutionContext, + ExecutionContextProvider, + useExecutionContext, // explorer ExplorerContext, ExplorerContextProvider, @@ -95,6 +105,8 @@ export type { UseQueryEditorArgs, UseResponseEditorArgs, UseVariableEditorArgs, + // execution + ExecutionContextType, // explorer ExplorerContextType, ExplorerFieldDef, diff --git a/packages/graphiql/__mocks__/@graphiql/react.ts b/packages/graphiql/__mocks__/@graphiql/react.ts index 7d6c79309b4..6efd452b562 100644 --- a/packages/graphiql/__mocks__/@graphiql/react.ts +++ b/packages/graphiql/__mocks__/@graphiql/react.ts @@ -1,6 +1,8 @@ import { EditorContext, EditorContextProvider, + ExecutionContext, + ExecutionContextProvider, ExplorerContext, ExplorerContextProvider, HistoryContext, @@ -14,6 +16,7 @@ import { useAutoCompleteLeafs, useCopyQuery, useEditorContext, + useExecutionContext, useExplorerContext, useHistoryContext, useMergeQuery, @@ -27,6 +30,7 @@ import { } from '@graphiql/react'; import type { EditorContextType, + ExecutionContextType, ExplorerContextType, ExplorerFieldDef, ExplorerNavStack, @@ -46,6 +50,8 @@ import { useEffect, useRef, useState } from 'react'; export { EditorContext, EditorContextProvider, + ExecutionContext, + ExecutionContextProvider, ExplorerContext, ExplorerContextProvider, HistoryContext, @@ -59,6 +65,7 @@ export { useAutoCompleteLeafs, useCopyQuery, useEditorContext, + useExecutionContext, useExplorerContext, useHistoryContext, useMergeQuery, @@ -69,6 +76,7 @@ export { export type { EditorContextType, + ExecutionContextType, ExplorerContextType, ExplorerFieldDef, ExplorerNavStack, diff --git a/packages/graphiql/package.json b/packages/graphiql/package.json index 87828ede028..27b2d09fdef 100644 --- a/packages/graphiql/package.json +++ b/packages/graphiql/package.json @@ -48,7 +48,6 @@ "dependencies": { "@graphiql/react": "^0.2.1", "@graphiql/toolkit": "^0.6.0", - "set-value": "^4.1.0", "entities": "^2.0.0", "graphql-language-service": "^5.0.4", "markdown-it": "^12.2.0" diff --git a/packages/graphiql/src/components/ExecuteButton.tsx b/packages/graphiql/src/components/ExecuteButton.tsx index b1282521185..fbd1ef712ee 100644 --- a/packages/graphiql/src/components/ExecuteButton.tsx +++ b/packages/graphiql/src/components/ExecuteButton.tsx @@ -4,34 +4,22 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -import React, { useContext, useState } from 'react'; +import { useEditorContext, useExecutionContext } from '@graphiql/react'; import { OperationDefinitionNode } from 'graphql'; -import { EditorContext } from '@graphiql/react'; - -type ExecuteButtonProps = { - isRunning: boolean; - onStop: () => void; - onRun: (value?: string) => void; -}; - -export function ExecuteButton({ - isRunning, - onStop, - onRun, -}: ExecuteButtonProps) { - const editorContext = useContext(EditorContext); - if (!editorContext) { - throw new Error( - 'Tried to render the `ExecuteButton` component without the necessary context. Make sure that the `EditorContextProvider` from `@graphiql/react` is rendered higher in the tree.', - ); - } +import React, { useState } from 'react'; +export function ExecuteButton() { + const { queryEditor } = useEditorContext({ nonNull: true }); + const { isFetching, run, stop, subscription } = useExecutionContext({ + nonNull: true, + }); const [optionsOpen, setOptionsOpen] = useState(false); const [highlight, setHighlight] = useState( null, ); - const operations = editorContext.queryEditor?.operations || []; + const isRunning = isFetching || Boolean(subscription); + const operations = queryEditor?.operations || []; const hasOptions = operations.length > 1; return ( @@ -79,9 +67,9 @@ export function ExecuteButton({ isRunning || !hasOptions ? () => { if (isRunning) { - onStop(); + stop(); } else { - onRun(); + run(); } } : undefined @@ -109,7 +97,7 @@ export function ExecuteButton({ onMouseOut={() => setHighlight(null)} onMouseUp={() => { setOptionsOpen(false); - onRun(operation.name && operation.name.value); + run(operation.name && operation.name.value); }}> {opName} diff --git a/packages/graphiql/src/components/GraphiQL.tsx b/packages/graphiql/src/components/GraphiQL.tsx index d31f2940c82..f38caba159b 100644 --- a/packages/graphiql/src/components/GraphiQL.tsx +++ b/packages/graphiql/src/components/GraphiQL.tsx @@ -13,17 +13,15 @@ import React, { } from 'react'; import { GraphQLSchema, - parse, - print, - visit, ValidationRule, FragmentDefinitionNode, DocumentNode, } from 'graphql'; -import { getFragmentDependenciesForAST } from 'graphql-language-service'; import { EditorContextProvider, + ExecutionContextProvider, + ExecutionContextType, ExplorerContextProvider, HistoryContextProvider, SchemaContextProvider, @@ -31,6 +29,7 @@ import { useAutoCompleteLeafs, useCopyQuery, useEditorContext, + useExecutionContext, useExplorerContext, useHistoryContext, useMergeQuery, @@ -61,22 +60,12 @@ import { QueryHistory } from './QueryHistory'; import debounce from '../utility/debounce'; import find from '../utility/find'; import { getLeft, getTop } from '../utility/elementPosition'; -import setValue from 'set-value'; -import { - formatError, - formatResult, - isAsyncIterable, - isObservable, -} from '@graphiql/toolkit'; +import { formatError, formatResult } from '@graphiql/toolkit'; import type { Fetcher, - FetcherResult, - FetcherResultPayload, GetDefaultFieldNamesFn, QueryStoreItem, - SyncFetcherResult, - Unsubscribable, } from '@graphiql/toolkit'; import { Tab, TabAddButton, Tabs } from './Tabs'; @@ -312,17 +301,13 @@ export type GraphiQLProps = { }; export type GraphiQLState = { - response?: string; editorFlex: number; secondaryEditorOpen: boolean; secondaryEditorHeight: number; variableEditorActive: boolean; headerEditorActive: boolean; headerEditorEnabled: boolean; - shouldPersistHeaders: boolean; docExplorerWidth: number; - isWaitingForResponse: boolean; - subscription?: Unsubscribable | null; }; /** @@ -334,6 +319,7 @@ export type GraphiQLState = { export function GraphiQL({ dangerouslyAssumeSchemaIsValid, docExplorerOpen, + fetcher, inputValueDeprecation, introspectionQueryName, maxHistoryLength, @@ -344,31 +330,42 @@ export function GraphiQL({ schemaDescription, ...props }: GraphiQLProps) { + // Ensure props are correct + if (typeof fetcher !== 'function') { + throw new TypeError('GraphiQL requires a fetcher function.'); + } + return ( - - - - - - - - - + + + + + + + + + + + ); } @@ -416,6 +413,7 @@ type GraphiQLWithContextProviderProps = Omit< | 'dangerouslyAssumeSchemaIsValid' | 'defaultQuery' | 'docExplorerOpen' + | 'fetcher' | 'inputValueDeprecation' | 'introspectionQueryName' | 'maxHistoryLength' @@ -433,6 +431,7 @@ function GraphiQLConsumeContexts({ ...props }: GraphiQLWithContextProviderProps) { const editorContext = useEditorContext({ nonNull: true }); + const executionContext = useExecutionContext({ nonNull: true }); const explorerContext = useExplorerContext(); const historyContext = useHistoryContext(); const schemaContext = useSchemaContext({ nonNull: true }); @@ -447,6 +446,7 @@ function GraphiQLConsumeContexts({ & { editorContext: EditorContextType; + executionContext: ExecutionContextType; explorerContext: ExplorerContextType | null; historyContext: HistoryContextType | null; schemaContext: SchemaContextType; @@ -479,12 +480,6 @@ class GraphiQLWithContext extends React.Component< GraphiQLWithContextConsumerProps, GraphiQLState > { - // Ensure only the last executed editor query is rendered. - _editorQueryID = 0; - - // Ensure the component is mounted to execute async setState - componentIsMounted: boolean; - // refs graphiqlContainer: Maybe; editorBarComponent: Maybe; @@ -492,14 +487,6 @@ class GraphiQLWithContext extends React.Component< constructor(props: GraphiQLWithContextConsumerProps) { super(props); - // Ensure props are correct - if (typeof props.fetcher !== 'function') { - throw new TypeError('GraphiQL requires a fetcher function.'); - } - - // Disable setState when the component is not mounted - this.componentIsMounted = false; - const variables = props.variables ?? props.storageContext?.get('variables') ?? undefined; @@ -517,11 +504,9 @@ class GraphiQLWithContext extends React.Component< } const headerEditorEnabled = props.headerEditorEnabled ?? true; - const shouldPersistHeaders = props.shouldPersistHeaders ?? false; // Initialize state this.state = { - response: '', editorFlex: Number(this.props.storageContext?.get('editorFlex')) || 1, secondaryEditorOpen, secondaryEditorHeight: @@ -534,50 +519,18 @@ class GraphiQLWithContext extends React.Component< headerEditorActive: this.props.storageContext?.get('headerEditorActive') === 'true', headerEditorEnabled, - shouldPersistHeaders, docExplorerWidth: Number(this.props.storageContext?.get('docExplorerWidth')) || DEFAULT_DOC_EXPLORER_WIDTH, - isWaitingForResponse: false, - subscription: null, }; } componentDidMount() { - // Allow async state changes - this.componentIsMounted = true; - if (typeof window !== 'undefined') { window.g = this; } } - UNSAFE_componentWillMount() { - this.componentIsMounted = false; - } - - // TODO: these values should be updated in a reducer imo - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps( - nextProps: GraphiQLWithContextConsumerProps, - ) { - let nextResponse = this.state.response; - - if (nextProps.response !== undefined) { - nextResponse = nextProps.response; - } - - this.setState({ - response: nextResponse, - }); - } - - // Use it when the state change is async - // TODO: Annotate correctly this function - safeSetState = (nextState: any, callback?: any): void => { - this.componentIsMounted && this.setState(nextState, callback); - }; - render() { const children = React.Children.toArray(this.props.children); @@ -668,11 +621,7 @@ class GraphiQLWithContext extends React.Component< {this.props.beforeTopBarContent}
{logo} - + {toolbar}
{this.props.explorerContext && @@ -699,30 +648,14 @@ class GraphiQLWithContext extends React.Component< title={tab.title} isCloseable={this.props.editorContext.tabs.length > 1} onSelect={() => { - this.handleStopQuery(); - + this.props.executionContext.stop(); this.props.editorContext.changeTab(index); - this.setState({ - response: - this.props.editorContext.tabs[index].response ?? - undefined, - }); }} onClose={() => { if (this.props.editorContext.activeTabIndex === index) { - this.handleStopQuery(); + this.props.executionContext.stop(); } - this.props.editorContext.closeTab(index); - this.setState({ - response: - this.props.editorContext.tabs[ - Math.max( - this.props.editorContext.activeTabIndex - 1, - 0, - ) - ].response ?? undefined, - }); }} tabProps={{ 'aria-controls': 'sessionWrap', @@ -733,7 +666,6 @@ class GraphiQLWithContext extends React.Component< { this.props.editorContext.addTab(); - this.setState({ response: undefined }); }} /> @@ -754,7 +686,6 @@ class GraphiQLWithContext extends React.Component< externalFragments={this.props.externalFragments} onEdit={this.handleEditQuery} onEditOperationName={this.props.onEditOperationName} - onRunQuery={this.handleEditorRunQuery} readOnly={this.props.readOnly} validationRules={this.props.validationRules} /> @@ -797,7 +728,6 @@ class GraphiQLWithContext extends React.Component< @@ -815,13 +744,13 @@ class GraphiQLWithContext extends React.Component<
- {this.state.isWaitingForResponse && ( + {this.props.executionContext.isFetching && (
)} @@ -897,300 +826,6 @@ class GraphiQLWithContext extends React.Component< // Private methods - private async _fetchQuery( - query: string, - variables: string | undefined, - headers: string | undefined, - operationName: string | undefined, - shouldPersistHeaders: boolean, - cb: (value: FetcherResult) => any, - ): Promise { - const fetcher = this.props.fetcher; - let jsonVariables = null; - let jsonHeaders = null; - - try { - jsonVariables = - variables && variables.trim() !== '' ? JSON.parse(variables) : null; - } catch (error) { - throw new Error( - `Variables are invalid JSON: ${(error as Error).message}.`, - ); - } - - if (typeof jsonVariables !== 'object') { - throw new Error('Variables are not a JSON object.'); - } - - try { - jsonHeaders = - headers && headers.trim() !== '' ? JSON.parse(headers) : null; - } catch (error) { - throw new Error(`Headers are invalid JSON: ${(error as Error).message}.`); - } - - if (typeof jsonHeaders !== 'object') { - throw new Error('Headers are not a JSON object.'); - } - - const documentAST = - this.props.editorContext.queryEditor?.documentAST ?? undefined; - // TODO: memoize this - if (this.props.externalFragments) { - const externalFragments = new Map(); - - if (Array.isArray(this.props.externalFragments)) { - this.props.externalFragments.forEach(def => { - externalFragments.set(def.name.value, def); - }); - } else { - visit(parse(this.props.externalFragments, {}), { - FragmentDefinition(def) { - externalFragments.set(def.name.value, def); - }, - }); - } - - const fragmentDependencies = documentAST - ? getFragmentDependenciesForAST(documentAST, externalFragments) - : []; - if (fragmentDependencies.length > 0) { - query += - '\n' + - fragmentDependencies - .map((node: FragmentDefinitionNode) => print(node)) - .join('\n'); - } - } - - const fetch = fetcher( - { query, variables: jsonVariables, operationName }, - { headers: jsonHeaders, shouldPersistHeaders, documentAST }, - ); - - return Promise.resolve(fetch) - .then(value => { - if (isObservable(value)) { - // If the fetcher returned an Observable, then subscribe to it, calling - // the callback on each next value, and handling both errors and the - // completion of the Observable. Returns a Subscription object. - const subscription = value.subscribe({ - next: cb, - error: (error: Error) => { - this.safeSetState({ - isWaitingForResponse: false, - response: error ? formatError(error) : undefined, - subscription: null, - }); - }, - complete: () => { - this.safeSetState({ - isWaitingForResponse: false, - subscription: null, - }); - }, - }); - - return subscription; - } else if (isAsyncIterable(value)) { - (async () => { - try { - for await (const result of value) { - cb(result); - } - this.safeSetState({ - isWaitingForResponse: false, - subscription: null, - }); - } catch (error) { - this.safeSetState({ - isWaitingForResponse: false, - response: error ? formatError(error as Error) : undefined, - subscription: null, - }); - } - })(); - - return { - unsubscribe: () => value[Symbol.asyncIterator]().return?.(), - }; - } else { - cb(value); - return null; - } - }) - .catch(error => { - this.safeSetState({ - isWaitingForResponse: false, - response: error ? formatError(error) : undefined, - }); - return null; - }); - } - - handleRunQuery = async (selectedOperationName?: string) => { - this._editorQueryID++; - const queryID = this._editorQueryID; - - // Use the edited query after autoCompleteLeafs() runs or, - // in case autoCompletion fails (the function returns undefined), - // the current query from the editor. - const editedQuery = - this.props.autoCompleteLeafs() || getQuery(this.props) || ''; - const variables = getVariables(this.props); - const headers = getHeaders(this.props); - const shouldPersistHeaders = this.state.shouldPersistHeaders; - let operationName = - this.props.editorContext.queryEditor?.operationName ?? undefined; - - // If an operation was explicitly provided, different from the current - // operation name, then report that it changed. - if (selectedOperationName && selectedOperationName !== operationName) { - operationName = selectedOperationName; - if (this.props.editorContext.queryEditor) { - this.props.editorContext.queryEditor.operationName = selectedOperationName; - } - this.props.onEditOperationName?.(operationName); - } - - try { - this.setState({ isWaitingForResponse: true, response: undefined }); - - this.props.historyContext?.addToHistory({ - query: editedQuery, - variables, - headers, - operationName, - }); - - // when dealing with defer or stream, we need to aggregate results - let fullResponse: FetcherResultPayload = { data: {} }; - - // _fetchQuery may return a subscription. - const subscription = await this._fetchQuery( - editedQuery, - variables, - headers, - operationName, - shouldPersistHeaders, - (result: FetcherResult) => { - if (queryID === this._editorQueryID) { - let maybeMultipart = Array.isArray(result) ? result : false; - if ( - !maybeMultipart && - typeof result === 'object' && - result !== null && - 'hasNext' in result - ) { - maybeMultipart = [result]; - } - - if (maybeMultipart) { - const payload: FetcherResultPayload = { data: fullResponse.data }; - const maybeErrors = [ - ...(fullResponse?.errors || []), - ...maybeMultipart - .map(i => i.errors) - .flat() - .filter(Boolean), - ]; - - if (maybeErrors.length) { - payload.errors = maybeErrors; - } - - for (const part of maybeMultipart) { - // We pull out errors here, so we dont include it later - const { path, data, errors: _errors, ...rest } = part; - if (path) { - if (!data) { - throw new Error( - `Expected part to contain a data property, but got ${part}`, - ); - } - - setValue(payload.data, path, data, { merge: true }); - } else if (data) { - // If there is no path, we don't know what to do with the payload, - // so we just set it. - payload.data = part.data; - } - - // Ensures we also bring extensions and alike along for the ride - fullResponse = { - ...payload, - ...rest, - }; - } - - this.setState({ - isWaitingForResponse: false, - response: formatResult(fullResponse), - }); - } else { - const response = formatResult(result); - this.setState({ - isWaitingForResponse: false, - response, - }); - this.props.editorContext.updateActiveTabValues({ response }); - } - } - }, - ); - - this.setState({ subscription }); - } catch (error) { - this.setState({ - isWaitingForResponse: false, - response: (error as Error).message, - }); - } - }; - - handleStopQuery = () => { - const subscription = this.state.subscription; - this.setState({ - isWaitingForResponse: false, - subscription: null, - }); - if (subscription) { - subscription.unsubscribe(); - } - }; - - private _runQueryAtCursor() { - if (this.state.subscription) { - this.handleStopQuery(); - return; - } - - let operationName; - const operations = this.props.editorContext.queryEditor?.operations; - if (operations) { - const editor = this.getQueryEditor(); - if (editor && editor.hasFocus()) { - const cursor = editor.getCursor(); - const cursorIndex = editor.indexFromPos(cursor); - - // Loop through all operations to see if one contains the cursor. - for (let i = 0; i < operations.length; i++) { - const operation = operations[i]; - if ( - operation.loc && - operation.loc.start <= cursorIndex && - operation.loc.end >= cursorIndex - ) { - operationName = operation.name && operation.name.value; - break; - } - } - } - } - - this.handleRunQuery(operationName); - } - handleEditQuery = (value: string) => { this.props.onEditQuery?.( value, @@ -1210,10 +845,6 @@ class GraphiQLWithContext extends React.Component< } }; - handleEditorRunQuery = () => { - this._runQueryAtCursor(); - }; - handleSelectHistoryQuery = ({ query, variables, @@ -1494,26 +1125,14 @@ function isChildComponentType( return child.type === component; } -function getQuery(props: GraphiQLWithContextConsumerProps) { - return props.editorContext.queryEditor?.getValue(); -} - function setQuery(props: GraphiQLWithContextConsumerProps, value: string) { props.editorContext.queryEditor?.setValue(value); } -function getVariables(props: GraphiQLWithContextConsumerProps) { - return props.editorContext.variableEditor?.getValue(); -} - function setVariables(props: GraphiQLWithContextConsumerProps, value: string) { props.editorContext.variableEditor?.setValue(value); } -function getHeaders(props: GraphiQLWithContextConsumerProps) { - return props.editorContext.headerEditor?.getValue(); -} - function setHeaders(props: GraphiQLWithContextConsumerProps, value: string) { props.editorContext.headerEditor?.setValue(value); } diff --git a/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx b/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx index 00382589ce0..63795e065cb 100644 --- a/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx +++ b/packages/graphiql/src/components/__tests__/GraphiQL.spec.tsx @@ -287,6 +287,7 @@ describe('GraphiQL', () => { const executeQueryButton = getByTitle('Execute Query (Ctrl-Enter)'); fireEvent.click(executeQueryButton); expect(container.querySelectorAll('.history-label')).toHaveLength(1); + await wait(); fireEvent.change( container.querySelector('[aria-label="Query Variables"] .mockCodeMirror'), @@ -316,6 +317,7 @@ describe('GraphiQL', () => { const executeQueryButton = getByTitle('Execute Query (Ctrl-Enter)'); fireEvent.click(executeQueryButton); expect(container.querySelectorAll('.history-label')).toHaveLength(1); + await wait(); fireEvent.click(getByText('Request Headers')); diff --git a/packages/graphiql/test/schema.js b/packages/graphiql/test/schema.js index 43444039a06..d388bf73342 100644 --- a/packages/graphiql/test/schema.js +++ b/packages/graphiql/test/schema.js @@ -336,7 +336,7 @@ const TestSubscriptionType = new GraphQLObjectType({ args: { delay: delayArgument(600), }, - async *subscribe(args) { + async *subscribe(root, args) { for (const hi of ['Hi', 'Bonjour', 'Hola', 'Ciao', 'Zdravo']) { if (args && args.delay) { await sleep(args.delay);