Skip to content
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

WIP: incremental payloads UI #3601

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.
Jump to
Jump to file
Failed to load files.
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);

Check warning on line 34 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L34

Added line #L34 was not covered by tests

const ref = useRef<HTMLDivElement>(null);

Check warning on line 36 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L36

Added line #L36 was not covered by tests

useEffect(() => {
let isActive = true;
void importCodeMirror(

Check warning on line 40 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L38-L40

Added lines #L38 - L40 were not covered by tests
[
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;

Check warning on line 57 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L57

Added line #L57 was not covered by tests
}

const container = ref.current;

Check warning on line 60 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L60

Added line #L60 was not covered by tests
if (!container) {
return;

Check warning on line 62 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L62

Added line #L62 was not covered by tests
}

const newEditor = CodeMirror(container, {

Check warning on line 65 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L65

Added line #L65 was not covered by tests
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);

Check warning on line 78 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L78

Added line #L78 was not covered by tests
});

return () => {
isActive = false;

Check warning on line 82 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L81-L82

Added lines #L81 - L82 were not covered by tests
};
}, [editorTheme, increment.payload]);

useSynchronizeOption(editor, 'keyMap', keyMap);

Check warning on line 86 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L86

Added line #L86 was not covered by tests

return ref;

Check warning on line 88 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L88

Added line #L88 was not covered by tests
}

function IncrementEditor(
props: UseIncrementsEditorArgs & { isInitial: boolean },
) {
const [isOpen, setIsOpen] = useState(false);
const incrementEditor = useIncrementsEditor(props);

Check warning on line 95 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L94-L95

Added lines #L94 - L95 were not covered by tests

const toggleEditor = useCallback(() => setIsOpen(current => !current), []);

Check warning on line 97 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L97

Added line #L97 was not covered by tests

return (

Check warning on line 99 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L99

Added line #L99 was not covered by tests
<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

Check warning on line 132 in packages/graphiql-react/src/editor/components/increments-editors.tsx

View check run for this annotation

Codecov / codecov/patch

packages/graphiql-react/src/editor/components/increments-editors.tsx#L132

Added line #L132 was not covered by tests
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 @@
* 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 @@
headers,
operationName,
response: null,
incrementalPayloads: [],
});
parsed.activeTabIndex = parsed.tabs.length - 1;
}
Expand Down Expand Up @@ -172,7 +196,8 @@
hasStringOrNullKey(obj, 'variables') &&
hasStringOrNullKey(obj, 'headers') &&
hasStringOrNullKey(obj, 'operationName') &&
hasStringOrNullKey(obj, 'response')
hasStringOrNullKey(obj, 'response') &&
hasIncrementalPayloads(obj)
);
}

Expand All @@ -188,6 +213,31 @@
return key in obj && (typeof obj[key] === 'string' || obj[key] === null);
}

function hasIncrementalPayloads(obj: Record<string, any>) {
const { incrementalPayloads } = obj;

Check warning on line 217 in packages/graphiql-react/src/editor/tabs.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L217 was not covered by tests

// Not having any values is fine
if (incrementalPayloads === undefined || incrementalPayloads === null) {
return true;

Check warning on line 221 in packages/graphiql-react/src/editor/tabs.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L221 was not covered by tests
}

// Anything other than an array is bad
if (!Array.isArray(incrementalPayloads)) {
return false;

Check warning on line 226 in packages/graphiql-react/src/editor/tabs.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L226 was not covered by tests
}

return incrementalPayloads.every(

Check warning on line 229 in packages/graphiql-react/src/editor/tabs.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L229 was not covered by tests
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 @@
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 @@
headers,
operationName: null,
response: null,
incrementalPayloads: [],
};
}

Expand Down