mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-25 01:58:39 +02:00
Add GraphQL variables editor
This commit is contained in:
@@ -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 })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user