Add GraphQL variables editor

This commit is contained in:
Gregory Schier
2023-03-14 19:56:02 -07:00
parent 25005eef1b
commit d159f62138
8 changed files with 89 additions and 53 deletions

View File

@@ -1,5 +1,4 @@
import classnames from 'classnames'; import classnames from 'classnames';
import { act } from 'react-dom/test-utils';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useSendRequest } from '../hooks/useSendRequest'; import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest';
@@ -67,7 +66,6 @@ export function RequestPane({ fullHeight, className }: Props) {
key={activeRequest.id} key={activeRequest.id}
useTemplating useTemplating
className="!bg-gray-50" className="!bg-gray-50"
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={activeRequest.body ?? ''} defaultValue={activeRequest.body ?? ''}
contentType="application/json" contentType="application/json"
onChange={(body) => updateRequest.mutate({ body })} onChange={(body) => updateRequest.mutate({ body })}
@@ -78,7 +76,6 @@ export function RequestPane({ fullHeight, className }: Props) {
useTemplating useTemplating
className="!bg-gray-50" className="!bg-gray-50"
defaultValue={activeRequest?.body ?? ''} defaultValue={activeRequest?.body ?? ''}
heightMode={fullHeight ? 'full' : 'auto'}
onChange={(body) => updateRequest.mutate({ body })} onChange={(body) => updateRequest.mutate({ body })}
/> />
) : ( ) : (

View File

@@ -26,7 +26,7 @@ export function Sidebar({ className }: Props) {
<div <div
className={classnames( className={classnames(
className, className,
'w-[15rem] bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto,1fr]', 'w-[12rem] bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto,1fr]',
)} )}
> >
<HStack as={WindowDragRegion} alignItems="center" justifyContent="end"> <HStack as={WindowDragRegion} alignItems="center" justifyContent="end">

View File

@@ -1,5 +1,5 @@
import { Button } from './core/Button'; import { Button } from './core/Button';
import { DropdownMenuRadio } from './core/Dropdown'; import { DropdownMenuRadio, DropdownMenuTrigger } from './core/Dropdown';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { Input } from './core/Input'; import { Input } from './core/Input';
@@ -45,9 +45,11 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
{ label: 'HEAD', value: 'HEAD' }, { label: 'HEAD', value: 'HEAD' },
]} ]}
> >
<Button type="button" disabled={loading} size="sm" className="mx-0.5" justify="start"> <DropdownMenuTrigger>
{method.toUpperCase()} <Button type="button" disabled={loading} size="sm" className="mx-0.5" justify="start">
</Button> {method.toUpperCase()}
</Button>
</DropdownMenuTrigger>
</DropdownMenuRadio> </DropdownMenuRadio>
} }
rightSlot={ rightSlot={

View File

@@ -10,7 +10,7 @@ import { useActiveRequest } from '../hooks/useActiveRequest';
export default function Workspace() { export default function Workspace() {
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
const { width } = useWindowSize(); const { width } = useWindowSize();
const isH = width > 900; const isSideBySide = width > 900;
return ( return (
<div className="grid grid-cols-[auto_1fr] grid-rows-1 h-full text-gray-900"> <div className="grid grid-cols-[auto_1fr] grid-rows-1 h-full text-gray-900">
@@ -26,12 +26,12 @@ export default function Workspace() {
<div <div
className={classnames( className={classnames(
'grid', 'grid',
isH isSideBySide
? 'grid-cols-[1fr_1fr] grid-rows-1' ? 'grid-cols-[1fr_1fr] grid-rows-[minmax(0,1fr)]'
: 'grid-cols-1 grid-rows-[minmax(0,auto)_minmax(0,100%)]', : 'grid-cols-1 grid-rows-[minmax(0,auto)_minmax(0,100%)]',
)} )}
> >
<RequestPane fullHeight={isH} className={classnames(!isH && 'pr-2')} /> <RequestPane fullHeight={isSideBySide} className={classnames(!isSideBySide && 'pr-2')} />
<ResponsePane /> <ResponsePane />
</div> </div>
</div> </div>

View File

@@ -12,7 +12,7 @@ export function Divider({ className, orientation = 'horizontal', decorative }: P
<Separator.Root <Separator.Root
className={classnames( className={classnames(
className, className,
'bg-gray-50', 'bg-gray-300/40',
orientation === 'horizontal' && 'w-full h-[1px]', orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]', orientation === 'vertical' && 'h-full w-[1px]',
)} )}

View File

@@ -80,6 +80,13 @@
.cm-scroller { .cm-scroller {
@apply font-mono text-[0.75rem]; @apply font-mono text-[0.75rem];
/*
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine.
*/
@apply rounded-lg;
} }
} }
} }

View File

@@ -3,10 +3,9 @@ import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view'; import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames'; import classnames from 'classnames';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import { formatSdl } from 'format-graphql'; import type { MutableRefObject } from 'react';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useUnmount } from 'react-use'; import { useUnmount } from 'react-use';
import { IconButton } from '../IconButton';
import './Editor.css'; import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions'; import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import { singleLineExt } from './singleLine'; import { singleLineExt } from './singleLine';
@@ -48,6 +47,18 @@ export function _Editor({
cm.current = null; cm.current = null;
}); });
// Use ref so we can update the onChange handler without re-initializing the editor
const handleChange = useRef<_EditorProps['onChange']>(onChange);
useEffect(() => {
handleChange.current = onChange;
}, [onChange]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleFocus = useRef<_EditorProps['onFocus']>(onFocus);
useEffect(() => {
handleFocus.current = onFocus;
}, [onFocus]);
// Update language extension when contentType changes // Update language extension when contentType changes
useEffect(() => { useEffect(() => {
if (cm.current === null) return; if (cm.current === null) return;
@@ -69,11 +80,11 @@ export function _Editor({
langHolder.of(langExt), langHolder.of(langExt),
...getExtensions({ ...getExtensions({
container: el, container: el,
onChange: handleChange,
onFocus: handleFocus,
readOnly, readOnly,
placeholder, placeholder,
singleLine, singleLine,
onChange,
onFocus,
contentType, contentType,
useTemplating, useTemplating,
}), }),
@@ -91,6 +102,7 @@ export function _Editor({
return ( return (
<div <div
ref={initDivRef} ref={initDivRef}
dangerouslySetInnerHTML={{ __html: '' }}
className={classnames( className={classnames(
className, className,
'cm-wrapper text-base bg-gray-50', 'cm-wrapper text-base bg-gray-50',
@@ -98,19 +110,7 @@ export function _Editor({
singleLine ? 'cm-singleline' : 'cm-multiline', singleLine ? 'cm-singleline' : 'cm-multiline',
readOnly && 'cm-readonly', readOnly && 'cm-readonly',
)} )}
> />
{contentType?.includes('graphql') && (
<IconButton
icon="eye"
className="absolute right-0 bottom-0 z-10"
onClick={() => {
const doc = cm.current?.view.state.doc ?? '';
const insert = formatSdl(doc.toString());
cm.current?.view.dispatch({ changes: { from: 0, to: doc.length, insert } });
}}
/>
)}
</div>
); );
} }
@@ -125,14 +125,12 @@ function getExtensions({
useTemplating, useTemplating,
}: Pick< }: Pick<
_EditorProps, _EditorProps,
| 'singleLine' 'singleLine' | 'contentType' | 'useTemplating' | 'placeholder' | 'readOnly'
| 'onChange' > & {
| 'contentType' container: HTMLDivElement | null;
| 'useTemplating' onChange: MutableRefObject<_EditorProps['onChange']>;
| 'placeholder' onFocus: MutableRefObject<_EditorProps['onFocus']>;
| 'readOnly' }) {
| 'onFocus'
> & { container: HTMLDivElement | null }) {
const ext = getLanguageExtension({ contentType, useTemplating }); const ext = getLanguageExtension({ contentType, useTemplating });
// TODO: Ensure tooltips render inside the dialog if we are in one. // TODO: Ensure tooltips render inside the dialog if we are in one.
@@ -172,14 +170,14 @@ function getExtensions({
// Handle onFocus // Handle onFocus
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: () => { focus: () => {
onFocus?.(); onFocus.current?.();
}, },
}), }),
// Handle onChange // Handle onChange
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (typeof onChange === 'function' && update.docChanged) { if (onChange && update.docChanged) {
onChange(update.state.doc.toString()); onChange.current?.(update.state.doc.toString());
} }
}), }),
]; ];

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Divider } from '../core/Divider';
import type { EditorProps } from '../core/Editor'; import type { EditorProps } from '../core/Editor';
import { Editor } from '../core/Editor'; import { Editor } from '../core/Editor';
@@ -7,26 +8,57 @@ type Props = Pick<
'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'useTemplating' 'heightMode' | 'onChange' | 'defaultValue' | 'className' | 'useTemplating'
>; >;
export function GraphQLEditor({ defaultValue, onChange, ...props }: Props) { interface GraphQLBody {
const { query } = useMemo(() => { query: string;
variables?: Record<string, string | number | boolean | null>;
operationName?: string;
}
export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: Props) {
const { query, variables } = useMemo<GraphQLBody>(() => {
try { try {
const { query } = JSON.parse(defaultValue ?? '{}'); const p = JSON.parse(defaultValue ?? '{}');
return { query }; const query = p.query ?? '';
const variables = p.variables;
const operationName = p.operationName;
return { query, variables, operationName };
} catch (err) { } catch (err) {
return { query: 'failed to parse' }; return { query: 'failed to parse' };
} }
}, [defaultValue]); }, [defaultValue]);
const handleChange = (query: string) => { const handleChange = (b: GraphQLBody) => {
onChange?.(JSON.stringify({ query }, null, 2)); onChange?.(JSON.stringify(b, null, 2));
};
const handleChangeQuery = (query: string) => {
handleChange({ query, variables });
};
const handleChangeVariables = (variables: string) => {
handleChange({ query, variables: JSON.parse(variables) });
}; };
return ( return (
<Editor <div className="pb-1 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
defaultValue={query ?? ''} <Editor
onChange={handleChange} heightMode="auto"
contentType="application/graphql" defaultValue={query ?? ''}
{...props} onChange={handleChangeQuery}
/> contentType="application/graphql"
{...extraEditorProps}
/>
<div className="pl-2">
<Divider />
</div>
<p className="pl-2 pt-1 text-gray-500 text-sm">Variables</p>
<Editor
heightMode="auto"
defaultValue={JSON.stringify(variables, null, 2)}
onChange={handleChangeVariables}
contentType="application/json"
{...extraEditorProps}
/>
</div>
); );
} }