Skip to content

WIP: incremental payloads UI #3601

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/eight-suns-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'graphiql': minor
'@graphiql/react': minor
---

Add a UI to the response pane that shows incremental payloads for streamed responses (subscriptions, defer, stream)
141 changes: 141 additions & 0 deletions packages/graphiql-react/src/editor/components/increments-editors.tsx
Original file line number Diff line number Diff line change
@@ -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<CodeMirrorEditor | null>(null);

const ref = useRef<HTMLDivElement>(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, increment.payload]);

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 (
<div
className="graphiql-increment-editor"
style={isOpen ? { height: '30vh' } : {}}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the editors to show properly I needed to give the wrapper a fixed height, which is not ideal. But I don't see a better way right now other than starting to do JS magic to calculate the container height on-runtime.

>
<UnStyledButton
className="graphiql-increment-editor-toggle"
onClick={toggleEditor}
>
{props.isInitial ? 'Initial payload' : 'Increment'} (after{' '}
{props.increment.timing / 1000}s)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Show in ms if the timing is <1s, otherwise show seconds

{isOpen ? (
<ChevronUpIcon className="graphiql-increment-editor-chevron" />
) : (
<ChevronDownIcon className="graphiql-increment-editor-chevron" />
)}
</UnStyledButton>
<div
ref={incrementEditor}
className={`graphiql-editor ${isOpen ? '' : 'hidden'}`}
/>
</div>
);
}

export type IncrementsEditorsProps = CommonEditorProps & {
incrementalPayloads: IncrementalPayload[];
};

export function IncrementsEditors(props: IncrementsEditorsProps) {
return (
<div className="graphiql-increments-editors">
{props.incrementalPayloads.map((increment, index) => (
<IncrementEditor
key={increment.timing}
isInitial={index === 0}
increment={increment}
{...props}
/>
))}
</div>
);
}
3 changes: 3 additions & 0 deletions packages/graphiql-react/src/editor/components/index.ts
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 3 additions & 1 deletion packages/graphiql-react/src/editor/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
HeaderEditor,
ImagePreview,
IncrementsEditors,
QueryEditor,
ResponseEditor,
VariableEditor,
Expand All @@ -26,14 +27,15 @@ 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';
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';
31 changes: 31 additions & 0 deletions packages/graphiql-react/src/editor/style/increments-editors.css
Original file line number Diff line number Diff line change
@@ -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);
}
54 changes: 53 additions & 1 deletion packages/graphiql-react/src/editor/tabs.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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[];
};

/**
Expand Down Expand Up @@ -125,6 +148,7 @@ export function getDefaultTabState({
headers,
operationName,
response: null,
incrementalPayloads: [],
});
parsed.activeTabIndex = parsed.tabs.length - 1;
}
Expand Down Expand Up @@ -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)
);
}

Expand All @@ -188,6 +213,31 @@ function hasStringOrNullKey(obj: Record<string, any>, key: string) {
return key in obj && (typeof obj[key] === 'string' || obj[key] === null);
}

function hasIncrementalPayloads(obj: Record<string, any>) {
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,
Expand Down Expand Up @@ -225,6 +275,7 @@ export function serializeTabState(
return JSON.stringify(tabState, (key, value) =>
key === 'hash' ||
key === 'response' ||
key === 'incrementalPayloads' ||
(!shouldPersistHeaders && key === 'headers')
? null
: value,
Expand Down Expand Up @@ -299,6 +350,7 @@ export function createTab({
headers,
operationName: null,
response: null,
incrementalPayloads: [],
};
}

Expand Down
Loading