Skip to content

Commit 0f5ab70

Browse files
incremental payloads UI
1 parent e3ca774 commit 0f5ab70

File tree

10 files changed

+422
-87
lines changed

10 files changed

+422
-87
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react';
2+
3+
import { ChevronDownIcon, ChevronUpIcon } from '../../icons';
4+
import { UnStyledButton } from '../../ui';
5+
import {
6+
commonKeys,
7+
DEFAULT_EDITOR_THEME,
8+
DEFAULT_KEY_MAP,
9+
importCodeMirror,
10+
} from '../common';
11+
import { useSynchronizeOption } from '../hooks';
12+
import { IncrementalPayload } from '../tabs';
13+
import { CodeMirrorEditor, CommonEditorProps } from '../types';
14+
15+
import '../style/codemirror.css';
16+
import '../style/fold.css';
17+
import '../style/lint.css';
18+
import '../style/hint.css';
19+
import '../style/info.css';
20+
import '../style/jump.css';
21+
import '../style/auto-insertion.css';
22+
import '../style/editor.css';
23+
import '../style/increments-editors.css';
24+
25+
type UseIncrementsEditorArgs = CommonEditorProps & {
26+
increment: IncrementalPayload;
27+
};
28+
29+
function useIncrementsEditor({
30+
editorTheme = DEFAULT_EDITOR_THEME,
31+
keyMap = DEFAULT_KEY_MAP,
32+
increment,
33+
}: UseIncrementsEditorArgs) {
34+
const [editor, setEditor] = useState<CodeMirrorEditor | null>(null);
35+
36+
const ref = useRef<HTMLDivElement>(null);
37+
38+
useEffect(() => {
39+
let isActive = true;
40+
void importCodeMirror(
41+
[
42+
import('codemirror/addon/fold/foldgutter'),
43+
import('codemirror/addon/fold/brace-fold'),
44+
import('codemirror/addon/dialog/dialog'),
45+
import('codemirror/addon/search/search'),
46+
import('codemirror/addon/search/searchcursor'),
47+
import('codemirror/addon/search/jump-to-line'),
48+
// @ts-expect-error
49+
import('codemirror/keymap/sublime'),
50+
import('codemirror-graphql/esm/results/mode'),
51+
import('codemirror-graphql/esm/utils/info-addon'),
52+
],
53+
{ useCommonAddons: false },
54+
).then(CodeMirror => {
55+
// Don't continue if the effect has already been cleaned up
56+
if (!isActive) {
57+
return;
58+
}
59+
60+
const container = ref.current;
61+
if (!container) {
62+
return;
63+
}
64+
65+
const newEditor = CodeMirror(container, {
66+
value: JSON.stringify(increment.payload, null, 2),
67+
lineWrapping: true,
68+
readOnly: true,
69+
theme: editorTheme,
70+
mode: 'graphql-results',
71+
foldGutter: true,
72+
gutters: ['CodeMirror-foldgutter'],
73+
// @ts-expect-error
74+
info: true,
75+
extraKeys: commonKeys,
76+
});
77+
78+
setEditor(newEditor);
79+
});
80+
81+
return () => {
82+
isActive = false;
83+
};
84+
}, [editorTheme]);
85+
86+
useSynchronizeOption(editor, 'keyMap', keyMap);
87+
88+
return ref;
89+
}
90+
91+
function IncrementEditor(
92+
props: UseIncrementsEditorArgs & { isInitial: boolean },
93+
) {
94+
const [isOpen, setIsOpen] = useState(false);
95+
const incrementEditor = useIncrementsEditor(props);
96+
97+
const toggleEditor = useCallback(() => setIsOpen(current => !current), []);
98+
99+
return (
100+
<div
101+
className="graphiql-increment-editor"
102+
style={isOpen ? { height: '30vh' } : {}}
103+
>
104+
<UnStyledButton
105+
className="graphiql-increment-editor-toggle"
106+
onClick={toggleEditor}
107+
>
108+
{props.isInitial ? 'Initial payload' : 'Increment'} (after{' '}
109+
{props.increment.timing / 1000}s)
110+
{isOpen ? (
111+
<ChevronUpIcon className="graphiql-increment-editor-chevron" />
112+
) : (
113+
<ChevronDownIcon className="graphiql-increment-editor-chevron" />
114+
)}
115+
</UnStyledButton>
116+
<div
117+
ref={incrementEditor}
118+
className={`graphiql-editor ${isOpen ? '' : 'hidden'}`}
119+
/>
120+
</div>
121+
);
122+
}
123+
124+
export type IncrementsEditorsProps = CommonEditorProps & {
125+
incrementalPayloads: IncrementalPayload[];
126+
};
127+
128+
export function IncrementsEditors(props: IncrementsEditorsProps) {
129+
return (
130+
<div className="graphiql-increments-editors">
131+
{props.incrementalPayloads.map((increment, index) => (
132+
<IncrementEditor
133+
key={increment.timing}
134+
isInitial={index === 0}
135+
increment={increment}
136+
{...props}
137+
/>
138+
))}
139+
</div>
140+
);
141+
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
export { HeaderEditor } from './header-editor';
22
export { ImagePreview } from './image-preview';
3+
export { IncrementsEditors } from './increments-editors';
34
export { QueryEditor } from './query-editor';
45
export { ResponseEditor } from './response-editor';
56
export { VariableEditor } from './variable-editor';
7+
8+
export type { IncrementsEditorsProps } from './increments-editors';

packages/graphiql-react/src/editor/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export {
22
HeaderEditor,
33
ImagePreview,
4+
IncrementsEditors,
45
QueryEditor,
56
ResponseEditor,
67
VariableEditor,
@@ -26,14 +27,15 @@ export { useQueryEditor } from './query-editor';
2627
export { useResponseEditor } from './response-editor';
2728
export { useVariableEditor } from './variable-editor';
2829

30+
export type { IncrementsEditorsProps } from './components';
2931
export type { EditorContextType, EditorContextProviderProps } from './context';
3032
export type { UseHeaderEditorArgs } from './header-editor';
3133
export type { UseQueryEditorArgs } from './query-editor';
3234
export type {
3335
ResponseTooltipType,
3436
UseResponseEditorArgs,
3537
} from './response-editor';
36-
export type { TabsState } from './tabs';
38+
export type { IncrementalPayload, TabsState } from './tabs';
3739
export type { UseVariableEditorArgs } from './variable-editor';
3840

3941
export type { CommonEditorProps, KeyMap, WriteableEditorProps } from './types';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
.graphiql-increments-editors {
2+
display: flex;
3+
flex-direction: column;
4+
padding: var(--px-16);
5+
}
6+
7+
.graphiql-increment-editor {
8+
padding: var(--px-4) 0;
9+
display: flex;
10+
flex-direction: column;
11+
position: relative;
12+
}
13+
14+
.graphiql-increment-editor + .graphiql-increment-editor {
15+
border-top: 1px solid
16+
hsla(var(--color-neutral), var(--alpha-background-heavy));
17+
}
18+
19+
.graphiql-increment-editor-toggle,
20+
button.graphiql-increment-editor-toggle {
21+
padding: var(--px-2) var(--px-4);
22+
display: flex;
23+
justify-content: space-between;
24+
align-items: center;
25+
}
26+
27+
.graphiql-increment-editor-chevron {
28+
height: var(--px-12);
29+
width: var(--px-12);
30+
margin-left: var(--px-4);
31+
}

packages/graphiql-react/src/editor/tabs.ts

+53-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { StorageAPI } from '@graphiql/toolkit';
2+
import { ExecutionResult } from 'graphql';
23
import { useCallback, useMemo } from 'react';
34

45
import debounce from '../utility/debounce';
6+
import { IncrementalResult } from '../utility/incremental';
57
import { CodeMirrorEditorWithOperationFacts } from './context';
68
import { CodeMirrorEditor } from './types';
79

@@ -47,6 +49,27 @@ export type TabState = TabDefinition & {
4749
* The contents of the response editor of this tab.
4850
*/
4951
response: string | null;
52+
/**
53+
* While being subscribed to a multi-part request (subscription, defer,
54+
* stream, etc.) this list will accumulate all incremental results received
55+
* from the server, including a client-generated timestamp for when the
56+
* increment was received. Each time a new request starts to run, this list
57+
* will be cleared.
58+
*/
59+
incrementalPayloads?: IncrementalPayload[] | null;
60+
};
61+
62+
export type IncrementalPayload = {
63+
/**
64+
* The number of milliseconds that went by between sending the request and
65+
* receiving this increment.
66+
*/
67+
timing: number;
68+
/**
69+
* The execution result (for subscriptions), or the list of incremental
70+
* results (for @defer/@stream).
71+
*/
72+
payload: ExecutionResult | IncrementalResult[];
5073
};
5174

5275
/**
@@ -125,6 +148,7 @@ export function getDefaultTabState({
125148
headers,
126149
operationName,
127150
response: null,
151+
incrementalPayloads: [],
128152
});
129153
parsed.activeTabIndex = parsed.tabs.length - 1;
130154
}
@@ -172,7 +196,8 @@ function isTabState(obj: any): obj is TabState {
172196
hasStringOrNullKey(obj, 'variables') &&
173197
hasStringOrNullKey(obj, 'headers') &&
174198
hasStringOrNullKey(obj, 'operationName') &&
175-
hasStringOrNullKey(obj, 'response')
199+
hasStringOrNullKey(obj, 'response') &&
200+
hasIncrementalPayloads(obj)
176201
);
177202
}
178203

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

216+
function hasIncrementalPayloads(obj: Record<string, any>) {
217+
const { incrementalPayloads } = obj;
218+
219+
// Not having any values is fine
220+
if (incrementalPayloads === undefined || incrementalPayloads === null) {
221+
return true;
222+
}
223+
224+
// Anything other than an array is bad
225+
if (!Array.isArray(incrementalPayloads)) {
226+
return false;
227+
}
228+
229+
return incrementalPayloads.every(
230+
item =>
231+
item &&
232+
typeof item === 'object' &&
233+
'timing' in item &&
234+
typeof item.timing === 'number' &&
235+
'payload' in item &&
236+
item.payload &&
237+
typeof item.payload === 'object',
238+
);
239+
}
240+
191241
export function useSynchronizeActiveTabValues({
192242
queryEditor,
193243
variableEditor,
@@ -225,6 +275,7 @@ export function serializeTabState(
225275
return JSON.stringify(tabState, (key, value) =>
226276
key === 'hash' ||
227277
key === 'response' ||
278+
key === 'incrementalPayloads' ||
228279
(!shouldPersistHeaders && key === 'headers')
229280
? null
230281
: value,
@@ -299,6 +350,7 @@ export function createTab({
299350
headers,
300351
operationName: null,
301352
response: null,
353+
incrementalPayloads: [],
302354
};
303355
}
304356

0 commit comments

Comments
 (0)