diff --git a/packages/graphiql-react/src/editor/components/increments-editors.tsx b/packages/graphiql-react/src/editor/components/increments-editors.tsx new file mode 100644 index 00000000000..8a9604069d6 --- /dev/null +++ b/packages/graphiql-react/src/editor/components/increments-editors.tsx @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { ChevronDownIcon, ChevronUpIcon } from '../../icons'; +import { UnStyledButton } from '../../ui'; +import { + commonKeys, + DEFAULT_EDITOR_THEME, + DEFAULT_KEY_MAP, + importCodeMirror, +} from '../common'; +import { useSynchronizeOption } from '../hooks'; +import { IncrementalPayload } from '../tabs'; +import { CodeMirrorEditor, CommonEditorProps } from '../types'; + +import '../style/codemirror.css'; +import '../style/fold.css'; +import '../style/lint.css'; +import '../style/hint.css'; +import '../style/info.css'; +import '../style/jump.css'; +import '../style/auto-insertion.css'; +import '../style/editor.css'; +import '../style/increments-editors.css'; + +type UseIncrementsEditorArgs = CommonEditorProps & { + increment: IncrementalPayload; +}; + +function useIncrementsEditor({ + editorTheme = DEFAULT_EDITOR_THEME, + keyMap = DEFAULT_KEY_MAP, + increment, +}: UseIncrementsEditorArgs) { + const [editor, setEditor] = useState(null); + + const ref = useRef(null); + + useEffect(() => { + let isActive = true; + void importCodeMirror( + [ + import('codemirror/addon/fold/foldgutter'), + import('codemirror/addon/fold/brace-fold'), + import('codemirror/addon/dialog/dialog'), + import('codemirror/addon/search/search'), + import('codemirror/addon/search/searchcursor'), + import('codemirror/addon/search/jump-to-line'), + // @ts-expect-error + import('codemirror/keymap/sublime'), + import('codemirror-graphql/esm/results/mode'), + import('codemirror-graphql/esm/utils/info-addon'), + ], + { useCommonAddons: false }, + ).then(CodeMirror => { + // Don't continue if the effect has already been cleaned up + if (!isActive) { + return; + } + + const container = ref.current; + if (!container) { + return; + } + + const newEditor = CodeMirror(container, { + value: JSON.stringify(increment.payload, null, 2), + lineWrapping: true, + readOnly: true, + theme: editorTheme, + mode: 'graphql-results', + foldGutter: true, + gutters: ['CodeMirror-foldgutter'], + // @ts-expect-error + info: true, + extraKeys: commonKeys, + }); + + setEditor(newEditor); + }); + + return () => { + isActive = false; + }; + }, [editorTheme]); + + useSynchronizeOption(editor, 'keyMap', keyMap); + + return ref; +} + +function IncrementEditor( + props: UseIncrementsEditorArgs & { isInitial: boolean }, +) { + const [isOpen, setIsOpen] = useState(false); + const incrementEditor = useIncrementsEditor(props); + + const toggleEditor = useCallback(() => setIsOpen(current => !current), []); + + return ( +
+ + {props.isInitial ? 'Initial payload' : 'Increment'} (after{' '} + {props.increment.timing / 1000}s) + {isOpen ? ( + + ) : ( + + )} + +
+
+ ); +} + +export type IncrementsEditorsProps = CommonEditorProps & { + incrementalPayloads: IncrementalPayload[]; +}; + +export function IncrementsEditors(props: IncrementsEditorsProps) { + return ( +
+ {props.incrementalPayloads.map((increment, index) => ( + + ))} +
+ ); +} diff --git a/packages/graphiql-react/src/editor/components/index.ts b/packages/graphiql-react/src/editor/components/index.ts index 9fbe6db2a47..7a700837cd7 100644 --- a/packages/graphiql-react/src/editor/components/index.ts +++ b/packages/graphiql-react/src/editor/components/index.ts @@ -1,5 +1,8 @@ export { HeaderEditor } from './header-editor'; export { ImagePreview } from './image-preview'; +export { IncrementsEditors } from './increments-editors'; export { QueryEditor } from './query-editor'; export { ResponseEditor } from './response-editor'; export { VariableEditor } from './variable-editor'; + +export type { IncrementsEditorsProps } from './increments-editors'; diff --git a/packages/graphiql-react/src/editor/index.ts b/packages/graphiql-react/src/editor/index.ts index c7a902c4307..17d789ff27e 100644 --- a/packages/graphiql-react/src/editor/index.ts +++ b/packages/graphiql-react/src/editor/index.ts @@ -1,6 +1,7 @@ export { HeaderEditor, ImagePreview, + IncrementsEditors, QueryEditor, ResponseEditor, VariableEditor, @@ -26,6 +27,7 @@ export { useQueryEditor } from './query-editor'; export { useResponseEditor } from './response-editor'; export { useVariableEditor } from './variable-editor'; +export type { IncrementsEditorsProps } from './components'; export type { EditorContextType, EditorContextProviderProps } from './context'; export type { UseHeaderEditorArgs } from './header-editor'; export type { UseQueryEditorArgs } from './query-editor'; @@ -33,7 +35,7 @@ export type { ResponseTooltipType, UseResponseEditorArgs, } from './response-editor'; -export type { TabsState } from './tabs'; +export type { IncrementalPayload, TabsState } from './tabs'; export type { UseVariableEditorArgs } from './variable-editor'; export type { CommonEditorProps, KeyMap, WriteableEditorProps } from './types'; diff --git a/packages/graphiql-react/src/editor/style/increments-editors.css b/packages/graphiql-react/src/editor/style/increments-editors.css new file mode 100644 index 00000000000..766e5ddfd07 --- /dev/null +++ b/packages/graphiql-react/src/editor/style/increments-editors.css @@ -0,0 +1,31 @@ +.graphiql-increments-editors { + display: flex; + flex-direction: column; + padding: var(--px-16); +} + +.graphiql-increment-editor { + padding: var(--px-4) 0; + display: flex; + flex-direction: column; + position: relative; +} + +.graphiql-increment-editor + .graphiql-increment-editor { + border-top: 1px solid + hsla(var(--color-neutral), var(--alpha-background-heavy)); +} + +.graphiql-increment-editor-toggle, +button.graphiql-increment-editor-toggle { + padding: var(--px-2) var(--px-4); + display: flex; + justify-content: space-between; + align-items: center; +} + +.graphiql-increment-editor-chevron { + height: var(--px-12); + width: var(--px-12); + margin-left: var(--px-4); +} diff --git a/packages/graphiql-react/src/editor/tabs.ts b/packages/graphiql-react/src/editor/tabs.ts index b9110dd5135..f62a8775648 100644 --- a/packages/graphiql-react/src/editor/tabs.ts +++ b/packages/graphiql-react/src/editor/tabs.ts @@ -1,7 +1,9 @@ import { StorageAPI } from '@graphiql/toolkit'; +import { ExecutionResult } from 'graphql'; import { useCallback, useMemo } from 'react'; import debounce from '../utility/debounce'; +import { IncrementalResult } from '../utility/incremental'; import { CodeMirrorEditorWithOperationFacts } from './context'; import { CodeMirrorEditor } from './types'; @@ -47,6 +49,27 @@ export type TabState = TabDefinition & { * The contents of the response editor of this tab. */ response: string | null; + /** + * While being subscribed to a multi-part request (subscription, defer, + * stream, etc.) this list will accumulate all incremental results received + * from the server, including a client-generated timestamp for when the + * increment was received. Each time a new request starts to run, this list + * will be cleared. + */ + incrementalPayloads?: IncrementalPayload[] | null; +}; + +export type IncrementalPayload = { + /** + * The number of milliseconds that went by between sending the request and + * receiving this increment. + */ + timing: number; + /** + * The execution result (for subscriptions), or the list of incremental + * results (for @defer/@stream). + */ + payload: ExecutionResult | IncrementalResult[]; }; /** @@ -125,6 +148,7 @@ export function getDefaultTabState({ headers, operationName, response: null, + incrementalPayloads: [], }); parsed.activeTabIndex = parsed.tabs.length - 1; } @@ -172,7 +196,8 @@ function isTabState(obj: any): obj is TabState { hasStringOrNullKey(obj, 'variables') && hasStringOrNullKey(obj, 'headers') && hasStringOrNullKey(obj, 'operationName') && - hasStringOrNullKey(obj, 'response') + hasStringOrNullKey(obj, 'response') && + hasIncrementalPayloads(obj) ); } @@ -188,6 +213,31 @@ function hasStringOrNullKey(obj: Record, key: string) { return key in obj && (typeof obj[key] === 'string' || obj[key] === null); } +function hasIncrementalPayloads(obj: Record) { + const { incrementalPayloads } = obj; + + // Not having any values is fine + if (incrementalPayloads === undefined || incrementalPayloads === null) { + return true; + } + + // Anything other than an array is bad + if (!Array.isArray(incrementalPayloads)) { + return false; + } + + return incrementalPayloads.every( + item => + item && + typeof item === 'object' && + 'timing' in item && + typeof item.timing === 'number' && + 'payload' in item && + item.payload && + typeof item.payload === 'object', + ); +} + export function useSynchronizeActiveTabValues({ queryEditor, variableEditor, @@ -225,6 +275,7 @@ export function serializeTabState( return JSON.stringify(tabState, (key, value) => key === 'hash' || key === 'response' || + key === 'incrementalPayloads' || (!shouldPersistHeaders && key === 'headers') ? null : value, @@ -299,6 +350,7 @@ export function createTab({ headers, operationName: null, response: null, + incrementalPayloads: [], }; } diff --git a/packages/graphiql-react/src/execution.tsx b/packages/graphiql-react/src/execution.tsx index be1b8bcaced..f0decdd6f93 100644 --- a/packages/graphiql-react/src/execution.tsx +++ b/packages/graphiql-react/src/execution.tsx @@ -6,20 +6,19 @@ import { isObservable, Unsubscribable, } from '@graphiql/toolkit'; -import { - ExecutionResult, - FragmentDefinitionNode, - GraphQLError, - print, -} from 'graphql'; +import { ExecutionResult, FragmentDefinitionNode, print } from 'graphql'; import { getFragmentDependenciesForAST } from 'graphql-language-service'; import { ReactNode, useCallback, useMemo, useRef, useState } from 'react'; -import setValue from 'set-value'; import { useAutoCompleteLeafs, useEditorContext } from './editor'; import { UseAutoCompleteLeafsArgs } from './editor/hooks'; +import { IncrementalPayload } from './editor/tabs'; import { useHistoryContext } from './history'; import { createContextHook, createNullableContext } from './utility/context'; +import { + IncrementalResult, + mergeIncrementalResult, +} from './utility/incremental'; export type ExecutionContextType = { /** @@ -30,8 +29,9 @@ export type ExecutionContextType = { isFetching: boolean; /** * If there is currently a GraphQL request in-flight. For multi-part - * requests like subscriptions, this will be `true` until the last batch - * has been fetched or the connection is closed from the client. + * requests (subscriptions, defer, stream, etc.), this will be `true` until + * the last batch has been fetched or the connection is closed from the + * client. */ isSubscribed: boolean; /** @@ -119,9 +119,25 @@ export function ExecutionContextProvider({ return; } - const setResponse = (value: string) => { + // Clear any incremental results of previous runs + updateActiveTabValues({ incrementalPayloads: [] }); + + const startTime = Date.now(); + const incrementalPayloads: IncrementalPayload[] = []; + const setResponse = ( + value: string, + incrementalPayload?: ExecutionResult | IncrementalResult[], + ) => { responseEditor.setValue(value); - updateActiveTabValues({ response: value }); + + if (incrementalPayload) { + incrementalPayloads.push({ + timing: Date.now() - startTime, + payload: incrementalPayload, + }); + } + + updateActiveTabValues({ response: value, incrementalPayloads }); }; queryIdRef.current += 1; @@ -188,9 +204,9 @@ export function ExecutionContextProvider({ try { const fullResponse: ExecutionResult = {}; - const handleResponse = (result: ExecutionResult) => { - // A different query was dispatched in the meantime, so don't - // show the results of this one. + const handleResponse = (result: ExecutionResult, isStreaming = false) => { + // A different query was dispatched in the meantime, so discard the + // results of this one. if (queryId !== queryIdRef.current) { return; } @@ -211,11 +227,11 @@ export function ExecutionContextProvider({ } setIsFetching(false); - setResponse(formatResult(fullResponse)); + setResponse(formatResult(fullResponse), maybeMultipart); } else { const response = formatResult(result); setIsFetching(false); - setResponse(response); + setResponse(response, isStreaming ? result : undefined); } }; @@ -239,7 +255,7 @@ export function ExecutionContextProvider({ setSubscription( value.subscribe({ next(result) { - handleResponse(result); + handleResponse(result, true); }, error(error: Error) { setIsFetching(false); @@ -259,7 +275,7 @@ export function ExecutionContextProvider({ unsubscribe: () => value[Symbol.asyncIterator]().return?.(), }); for await (const result of value) { - handleResponse(result); + handleResponse(result, true); } setIsFetching(false); setSubscription(null); @@ -333,61 +349,3 @@ function tryParseJsonObject({ } return parsed; } - -type IncrementalResult = { - data?: Record | null; - errors?: ReadonlyArray; - extensions?: Record; - hasNext?: boolean; - path?: ReadonlyArray; - incremental?: ReadonlyArray; - label?: string; - items?: ReadonlyArray> | null; -}; - -/** - * @param executionResult The complete execution result object which will be - * mutated by merging the contents of the incremental result. - * @param incrementalResult The incremental result that will be merged into the - * complete execution result. - */ -function mergeIncrementalResult( - executionResult: ExecutionResult, - incrementalResult: IncrementalResult, -): void { - const path = ['data', ...(incrementalResult.path ?? [])]; - - if (incrementalResult.items) { - for (const item of incrementalResult.items) { - setValue(executionResult, path.join('.'), item); - // Increment the last path segment (the array index) to merge the next item at the next index - // eslint-disable-next-line unicorn/prefer-at -- cannot mutate the array using Array.at() - (path[path.length - 1] as number)++; - } - } - - if (incrementalResult.data) { - setValue(executionResult, path.join('.'), incrementalResult.data, { - merge: true, - }); - } - - if (incrementalResult.errors) { - executionResult.errors ||= []; - (executionResult.errors as GraphQLError[]).push( - ...incrementalResult.errors, - ); - } - - if (incrementalResult.extensions) { - setValue(executionResult, 'extensions', incrementalResult.extensions, { - merge: true, - }); - } - - if (incrementalResult.incremental) { - for (const incrementalSubResult of incrementalResult.incremental) { - mergeIncrementalResult(executionResult, incrementalSubResult); - } - } -} diff --git a/packages/graphiql-react/src/index.ts b/packages/graphiql-react/src/index.ts index d52e3bffb24..73e17f1cebb 100644 --- a/packages/graphiql-react/src/index.ts +++ b/packages/graphiql-react/src/index.ts @@ -5,6 +5,7 @@ export { EditorContextProvider, HeaderEditor, ImagePreview, + IncrementsEditors, QueryEditor, ResponseEditor, useAutoCompleteLeafs, @@ -80,6 +81,8 @@ export type { CommonEditorProps, EditorContextProviderProps, EditorContextType, + IncrementalPayload, + IncrementsEditorsProps, KeyMap, ResponseTooltipType, TabsState, @@ -116,3 +119,4 @@ export type { StorageContextType, } from './storage'; export type { Theme } from './theme'; +export type { IncrementalResult } from './utility/incremental'; diff --git a/packages/graphiql-react/src/utility/incremental.ts b/packages/graphiql-react/src/utility/incremental.ts new file mode 100644 index 00000000000..3fa64cce14a --- /dev/null +++ b/packages/graphiql-react/src/utility/incremental.ts @@ -0,0 +1,60 @@ +import { ExecutionResult, GraphQLError } from 'graphql'; +import setValue from 'set-value'; + +/** + * @param executionResult The complete execution result object which will be + * mutated by merging the contents of the incremental result. + * @param incrementalResult The incremental result that will be merged into the + * complete execution result. + */ +export function mergeIncrementalResult( + executionResult: ExecutionResult, + incrementalResult: IncrementalResult, +): void { + const path = ['data', ...(incrementalResult.path ?? [])]; + + if (incrementalResult.items) { + for (const item of incrementalResult.items) { + setValue(executionResult, path.join('.'), item); + // Increment the last path segment (the array index) to merge the next item at the next index + // eslint-disable-next-line unicorn/prefer-at -- cannot mutate the array using Array.at() + (path[path.length - 1] as number)++; + } + } + + if (incrementalResult.data) { + setValue(executionResult, path.join('.'), incrementalResult.data, { + merge: true, + }); + } + + if (incrementalResult.errors) { + executionResult.errors ||= []; + (executionResult.errors as GraphQLError[]).push( + ...incrementalResult.errors, + ); + } + + if (incrementalResult.extensions) { + setValue(executionResult, 'extensions', incrementalResult.extensions, { + merge: true, + }); + } + + if (incrementalResult.incremental) { + for (const incrementalSubResult of incrementalResult.incremental) { + mergeIncrementalResult(executionResult, incrementalSubResult); + } + } +} + +export type IncrementalResult = { + data?: Record | null; + errors?: ReadonlyArray; + extensions?: Record; + hasNext?: boolean; + path?: ReadonlyArray; + incremental?: ReadonlyArray; + label?: string; + items?: ReadonlyArray> | null; +}; diff --git a/packages/graphiql/src/components/GraphiQL.tsx b/packages/graphiql/src/components/GraphiQL.tsx index fb2a44522ff..149b9b0f9b6 100644 --- a/packages/graphiql/src/components/GraphiQL.tsx +++ b/packages/graphiql/src/components/GraphiQL.tsx @@ -27,6 +27,7 @@ import { GraphiQLProvider, GraphiQLProviderProps, HeaderEditor, + IncrementsEditors, KeyboardShortcutIcon, MergeIcon, PlusIcon, @@ -272,6 +273,13 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { sizeThresholdSecond: 60, storageKey: 'secondaryEditorFlex', }); + const incrementalPayloadsResize = useDragResize({ + defaultSizeRelation: 3, + direction: 'vertical', + initiallyHidden: 'second', + sizeThresholdSecond: 60, + storageKey: 'responseToolsFlex', + }); const [activeSecondaryEditor, setActiveSecondaryEditor] = useState< 'variables' | 'headers' @@ -328,6 +336,9 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { isChildComponentType(child, GraphiQL.Footer), ); + const incrementalPayloads = + editorContext.tabs[editorContext.activeTabIndex].incrementalPayloads || []; + const onClickReference = useCallback(() => { if (pluginResize.hiddenElement === 'first') { pluginResize.setHiddenElement(null); @@ -413,6 +424,13 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) { ); }, [editorToolsResize]); + const toggleIncrementalPayloads: MouseEventHandler = + useCallback(() => { + incrementalPayloadsResize.setHiddenElement( + incrementalPayloadsResize.hiddenElement === 'second' ? null : 'second', + ); + }, [incrementalPayloadsResize]); + const handleOpenShortKeysDialog = useCallback((isOpen: boolean) => { if (!isOpen) { setShowDialog(null); @@ -702,12 +720,60 @@ export function GraphiQLInterface(props: GraphiQLInterfaceProps) {
- {executionContext.isFetching ? : null} - +
+ {executionContext.isFetching ? : null} + +
+ + {incrementalPayloads.length === 0 ? null : ( +
+ Incremental payloads + + {incrementalPayloadsResize.hiddenElement === + 'second' ? ( + +
+ )} + +
+ +
{footer}
diff --git a/packages/graphiql/src/style.css b/packages/graphiql/src/style.css index aa3120e4a59..b40b8917160 100644 --- a/packages/graphiql/src/style.css +++ b/packages/graphiql/src/style.css @@ -130,8 +130,6 @@ button.graphiql-tab-add > svg { /* The query editor and the toolbar */ .graphiql-container .graphiql-query-editor { - border-bottom: 1px solid - hsla(var(--color-neutral), var(--alpha-background-heavy)); padding: var(--px-16); column-gap: var(--px-16); display: flex; @@ -157,6 +155,8 @@ button.graphiql-tab-add > svg { /* The tab bar for editor tools */ .graphiql-container .graphiql-editor-tools { + border-top: 1px solid + hsla(var(--color-neutral), var(--alpha-background-heavy)); cursor: row-resize; display: flex; width: 100%; @@ -176,7 +176,8 @@ button.graphiql-tab-add > svg { .graphiql-container .graphiql-editor-tools > button:not(.graphiql-toggle-editor-tools) { - padding: var(--px-8) var(--px-12); + padding-left: var(--px-12); + padding-right: var(--px-12); } .graphiql-container .graphiql-editor-tools .graphiql-toggle-editor-tools { @@ -338,3 +339,20 @@ button.graphiql-tab-add > svg { .graphiql-container svg { pointer-events: none; } + +/* The section for incremental payloads */ +.graphiql-incremental-payloads-toggle { + border-top: 1px solid + hsla(var(--color-neutral), var(--alpha-background-heavy)); + padding: var(--px-8); + display: flex; + justify-content: space-between; + align-items: center; + cursor: row-resize; +} + +.graphiql-incremental-payloads { + overflow-y: auto; + flex-direction: column; + flex-basis: 0px !important; +}