Show response headers

This commit is contained in:
Gregory Schier
2023-04-01 23:43:22 -07:00
parent 3f713d878c
commit ceefbd1de1
19 changed files with 270 additions and 129 deletions

View File

@@ -81,19 +81,22 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
placeholder="..." placeholder="..."
ref={editorViewRef} ref={editorViewRef}
actions={ actions={
introspection.error && ( (introspection.error || introspection.isLoading) && (
<Button <Button
size="xs" size="xs"
color="danger" color={introspection.error ? 'danger' : 'gray'}
isLoading={introspection.isLoading}
onClick={() => { onClick={() => {
dialog.show({ dialog.show({
title: 'Introspection Failed', title: 'Introspection Failed',
size: 'sm', size: 'sm',
render: () => <div>{introspection.error?.message}</div>, render: () => (
<div className="whitespace-pre-wrap">{introspection.error?.message}</div>
),
}); });
}} }}
> >
Introspection Failed {introspection.error ? 'Introspection Failed' : 'Introspecting'}
</Button> </Button>
) )
} }

View File

@@ -21,6 +21,7 @@ import {
} from '../lib/models'; } from '../lib/models';
import { BasicAuth } from './BasicAuth'; import { BasicAuth } from './BasicAuth';
import { BearerAuth } from './BearerAuth'; import { BearerAuth } from './BearerAuth';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor'; import { Editor } from './core/Editor';
import type { TabItem } from './core/Tabs/Tabs'; import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs';
@@ -90,7 +91,17 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
}, },
}, },
// { value: 'params', label: 'URL Params' }, // { value: 'params', label: 'URL Params' },
{ value: 'headers', label: 'Headers' }, {
value: 'headers',
label: (
<div className="flex items-center">
Headers
<CountBadge
count={activeRequest.headers.filter((h) => h.name && h.value).length}
/>
</div>
),
},
{ {
value: 'auth', value: 'auth',
label: 'Auth', label: 'Auth',
@@ -150,10 +161,10 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
<UrlBar id={activeRequest.id} url={activeRequest.url} method={activeRequest.method} /> <UrlBar id={activeRequest.id} url={activeRequest.url} method={activeRequest.method} />
<Tabs <Tabs
value={activeTab} value={activeTab}
label="Request"
onChangeValue={setActiveTab} onChangeValue={setActiveTab}
tabs={tabs} tabs={tabs}
className="mt-2" className="mt-1"
label="Request body"
> >
<TabContent value="auth"> <TabContent value="auth">
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? ( {activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
@@ -188,7 +199,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
onChange={() => null} onChange={() => null}
/> />
</TabContent> </TabContent>
<TabContent value="body" className="pl-3 mt-1"> <TabContent value="body" className="mt-1">
{activeRequest.bodyType === BODY_TYPE_JSON ? ( {activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor <Editor
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}

View File

@@ -1,6 +1,7 @@
import classnames from 'classnames'; import classnames from 'classnames';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { memo, useEffect, useMemo, useState } from 'react'; import { memo, useEffect, useMemo, useState } from 'react';
import { createGlobalState } from 'react-use';
import { useActiveRequestId } from '../hooks/useActiveRequestId'; import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useDeleteResponse } from '../hooks/useDeleteResponse'; import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useDeleteResponses } from '../hooks/useDeleteResponses'; import { useDeleteResponses } from '../hooks/useDeleteResponses';
@@ -9,12 +10,15 @@ import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { tryFormatJson } from '../lib/formatters'; import { tryFormatJson } from '../lib/formatters';
import type { HttpResponse } from '../lib/models'; import type { HttpResponse } from '../lib/models';
import { pluralize } from '../lib/pluralize'; import { pluralize } from '../lib/pluralize';
import { Banner } from './core/Banner';
import { CountBadge } from './core/CountBadge';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
import { Editor } from './core/Editor'; import { Editor } from './core/Editor';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { StatusColor } from './core/StatusColor'; import { StatusColor } from './core/StatusColor';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { Webview } from './core/Webview'; import { Webview } from './core/Webview';
import { EmptyStateText } from './EmptyStateText'; import { EmptyStateText } from './EmptyStateText';
@@ -23,6 +27,8 @@ interface Props {
className?: string; className?: string;
} }
const useActiveTab = createGlobalState<string>('body');
export const ResponsePane = memo(function ResponsePane({ style, className }: Props) { export const ResponsePane = memo(function ResponsePane({ style, className }: Props) {
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null); const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const activeRequestId = useActiveRequestId(); const activeRequestId = useActiveRequestId();
@@ -33,10 +39,10 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId); const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null); const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId); const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
useEffect(() => { // Unset pinned response when a new one comes in
setPinnedResponseId(null); useEffect(() => setPinnedResponseId(null), [responses.length]);
}, [responses.length]);
const contentType = useMemo( const contentType = useMemo(
() => () =>
@@ -45,23 +51,41 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
[activeResponse], [activeResponse],
); );
const tabs = useMemo(
() => [
{ label: 'Body', value: 'body' },
{
label: (
<div className="flex items-center">
Headers
<CountBadge
count={activeResponse?.headers.filter((h) => h.name && h.value).length ?? 0}
/>
</div>
),
value: 'headers',
},
],
[activeResponse?.headers],
);
return ( return (
<div <div
style={style} style={style}
className={classnames( className={classnames(
className, className,
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ', 'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1',
'dark:bg-gray-100 rounded-md border border-highlight', 'dark:bg-gray-100 rounded-md border border-highlight',
'shadow shadow-gray-100 dark:shadow-gray-0 relative', 'shadow shadow-gray-100 dark:shadow-gray-0 relative',
)} )}
> >
<HStack <HStack
alignItems="center" alignItems="center"
className="italic text-gray-700 text-sm w-full mb-1 flex-shrink-0 pl-2" className="italic text-gray-700 text-sm w-full flex-shrink-0 -mb-1"
> >
{activeResponse && ( {activeResponse && (
<> <>
<div className="whitespace-nowrap"> <div className="whitespace-nowrap p-3 py-2">
<StatusColor statusCode={activeResponse.status}> <StatusColor statusCode={activeResponse.status}>
{activeResponse.status} {activeResponse.status}
{activeResponse.statusReason && ` ${activeResponse.statusReason}`} {activeResponse.statusReason && ` ${activeResponse.statusReason}`}
@@ -71,71 +95,93 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
{Math.round(activeResponse.body.length / 1000)} KB {Math.round(activeResponse.body.length / 1000)} KB
</div> </div>
<HStack alignItems="center" className="ml-auto h-8"> <Dropdown
<Dropdown items={[
items={[ {
{ label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified',
label: viewMode === 'pretty' ? 'View Raw' : 'View Prettified', onSelect: toggleViewMode,
onSelect: toggleViewMode, },
}, { type: 'separator', label: 'Actions' },
{ type: 'separator', label: 'Actions' }, {
{ label: 'Clear Response',
label: 'Clear Response', onSelect: deleteResponse.mutate,
onSelect: deleteResponse.mutate, disabled: responses.length === 0,
disabled: responses.length === 0, },
}, {
{ label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`, onSelect: deleteAllResponses.mutate,
onSelect: deleteAllResponses.mutate, hidden: responses.length <= 1,
hidden: responses.length <= 1, disabled: responses.length === 0,
disabled: responses.length === 0, },
}, { type: 'separator', label: 'History' },
{ type: 'separator', label: 'History' }, ...responses.slice(0, 10).map((r) => ({
...responses.slice(0, 10).map((r) => ({ label: r.status + ' - ' + r.elapsed + ' ms',
label: r.status + ' - ' + r.elapsed + ' ms', leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>, onSelect: () => setPinnedResponseId(r.id),
onSelect: () => setPinnedResponseId(r.id), })),
})), ]}
]} >
> <IconButton
<IconButton title="Show response history"
title="Show response history" icon="triangleDown"
icon="triangleDown" className="ml-auto"
className="ml-auto" size="sm"
size="sm" iconSize="md"
iconSize="md" />
/> </Dropdown>
</Dropdown>
</HStack>
</> </>
)} )}
</HStack> </HStack>
{activeResponse === null ? ( {activeResponse?.error ? (
<EmptyStateText>No Response</EmptyStateText> <Banner className="m-2">{activeResponse.error}</Banner>
) : activeResponse?.error ? ( ) : (
<div className="p-1"> <Tabs
<div className="text-white bg-red-500 px-3 py-3 rounded">{activeResponse.error}</div> value={activeTab}
</div> onChangeValue={setActiveTab}
) : viewMode === 'pretty' && contentType.includes('html') ? ( label="Response"
<Webview body={activeResponse.body} contentType={contentType} url={activeResponse.url} /> className="px-3"
) : viewMode === 'pretty' && contentType.includes('json') ? ( tabs={tabs}
<Editor >
readOnly <TabContent value="body">
forceUpdateKey={`pretty::${activeResponse.updatedAt}`} {activeResponse === null ? (
className="bg-gray-50 dark:!bg-gray-100" <EmptyStateText>No Response</EmptyStateText>
defaultValue={tryFormatJson(activeResponse?.body)} ) : viewMode === 'pretty' && contentType.includes('html') ? (
contentType={contentType} <Webview
/> body={activeResponse.body}
) : activeResponse?.body ? ( contentType={contentType}
<Editor url={activeResponse.url}
readOnly />
forceUpdateKey={activeResponse.updatedAt} ) : viewMode === 'pretty' && contentType.includes('json') ? (
className="bg-gray-50 dark:!bg-gray-100" <Editor
defaultValue={activeResponse?.body} readOnly
contentType={contentType} forceUpdateKey={`pretty::${activeResponse.updatedAt}`}
/> className="bg-gray-50 dark:!bg-gray-100"
) : null} defaultValue={tryFormatJson(activeResponse?.body)}
contentType={contentType}
/>
) : activeResponse?.body ? (
<Editor
readOnly
forceUpdateKey={activeResponse.updatedAt}
className="bg-gray-50 dark:!bg-gray-100"
defaultValue={activeResponse?.body}
contentType={contentType}
/>
) : null}
</TabContent>
<TabContent value="headers">
<ul>
{activeResponse?.headers.map((h) => (
<li key={h.name} className="font-mono text-xs">
<span className="text-violet-600 select-text cursor-text">{h.name}</span>:{' '}
<span className="text-blue-600 select-text cursor-text">{h.value}</span>
</li>
))}
</ul>
</TabContent>
</Tabs>
)}
</div> </div>
); );
}); });

View File

@@ -7,7 +7,6 @@ import { responsesQueryKey } from '../hooks/useResponses';
import { useTauriEvent } from '../hooks/useTauriEvent'; import { useTauriEvent } from '../hooks/useTauriEvent';
import { workspacesQueryKey } from '../hooks/useWorkspaces'; import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants'; import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { debounce } from '../lib/debounce';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore'; import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models'; import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models'; import { modelsEq } from '../lib/models';
@@ -42,40 +41,37 @@ export function TauriListeners() {
} }
}); });
useTauriEvent<Model>( useTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
'updated_model', if (shouldIgnoreEvent(payload, windowLabel)) return;
debounce(({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey = const queryKey =
payload.model === 'http_request' payload.model === 'http_request'
? requestsQueryKey(payload) ? requestsQueryKey(payload)
: payload.model === 'http_response' : payload.model === 'http_response'
? responsesQueryKey(payload) ? responsesQueryKey(payload)
: payload.model === 'workspace' : payload.model === 'workspace'
? workspacesQueryKey(payload) ? workspacesQueryKey(payload)
: payload.model === 'key_value' : payload.model === 'key_value'
? keyValueQueryKey(payload) ? keyValueQueryKey(payload)
: null; : null;
if (queryKey === null) { if (queryKey === null) {
if (payload.model) { if (payload.model) {
console.log('Unrecognized updated model:', payload); console.log('Unrecognized updated model:', payload);
}
return;
} }
return;
}
if (payload.model === 'http_request') { if (payload.model === 'http_request') {
wasUpdatedExternally(payload.id); wasUpdatedExternally(payload.id);
} }
if (!shouldIgnoreModel(payload)) { if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(queryKey, (values) => queryClient.setQueryData<Model[]>(queryKey, (values) =>
values?.map((v) => (modelsEq(v, payload) ? payload : v)), values?.map((v) => (modelsEq(v, payload) ? payload : v)),
); );
} }
}, 500), });
);
useTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => { useTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return; if (shouldIgnoreEvent(payload, windowLabel)) return;
@@ -107,7 +103,8 @@ export function TauriListeners() {
document.documentElement.style.fontSize = `${newFontSize}px`; document.documentElement.style.fontSize = `${newFontSize}px`;
}); });
return <></>;
return null;
} }
function removeById<T extends { id: string }>(model: T) { function removeById<T extends { id: string }>(model: T) {

View File

@@ -0,0 +1,21 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
}
export function Banner({ children, className }: Props) {
return (
<div>
<div
className={classnames(
className,
'border border-red-500 bg-red-300/10 text-red-800 px-3 py-2 rounded select-auto cursor-text',
)}
>
{children}
</div>
</div>
);
}

View File

@@ -18,6 +18,7 @@ const colorStyles = {
export type ButtonProps = HTMLAttributes<HTMLElement> & { export type ButtonProps = HTMLAttributes<HTMLElement> & {
to?: string; to?: string;
color?: keyof typeof colorStyles; color?: keyof typeof colorStyles;
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs'; size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center'; justify?: 'start' | 'center';
type?: 'button' | 'submit'; type?: 'button' | 'submit';
@@ -30,6 +31,7 @@ export type ButtonProps = HTMLAttributes<HTMLElement> & {
const _Button = forwardRef<any, ButtonProps>(function Button( const _Button = forwardRef<any, ButtonProps>(function Button(
{ {
to, to,
isLoading,
className, className,
children, children,
forDropdown, forDropdown,
@@ -68,8 +70,9 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
} else { } else {
return ( return (
<button ref={ref} className={classes} {...props}> <button ref={ref} className={classes} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{children} {children}
{forDropdown && <Icon icon="chevronDown" size="sm" className="ml-1 -mr-1" />} {forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</button> </button>
); );
} }

View File

@@ -0,0 +1,12 @@
interface Props {
count: number;
}
export function CountBadge({ count }: Props) {
if (count === 0) return null;
return (
<div aria-hidden className="opacity-80 text-2xs border rounded px-1 mb-0.5 ml-1 h-4">
{count}
</div>
);
}

View File

@@ -47,7 +47,7 @@
} }
.placeholder-widget { .placeholder-widget {
@apply text-[0.9em] text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow; @apply text-xs text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow;
/* NOTE: Background and border are translucent so we can see text selection through it */ /* NOTE: Background and border are translucent so we can see text selection through it */
@apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80; @apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80;

View File

@@ -33,6 +33,7 @@ export interface EditorProps {
useTemplating?: boolean; useTemplating?: boolean;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void;
singleLine?: boolean; singleLine?: boolean;
format?: (v: string) => string; format?: (v: string) => string;
autocomplete?: GenericCompletionConfig; autocomplete?: GenericCompletionConfig;
@@ -52,6 +53,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
forceUpdateKey, forceUpdateKey,
onChange, onChange,
onFocus, onFocus,
onBlur,
className, className,
singleLine, singleLine,
format, format,
@@ -75,6 +77,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
handleFocus.current = onFocus; handleFocus.current = onFocus;
}, [onFocus]); }, [onFocus]);
// Use ref so we can update the onChange handler without re-initializing the editor
const handleBlur = useRef<EditorProps['onBlur']>(onBlur);
useEffect(() => {
handleBlur.current = onBlur;
}, [onBlur]);
// Update placeholder // Update placeholder
const placeholderCompartment = useRef(new Compartment()); const placeholderCompartment = useRef(new Compartment());
useEffect(() => { useEffect(() => {
@@ -125,6 +133,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
container, container,
onChange: handleChange, onChange: handleChange,
onFocus: handleFocus, onFocus: handleFocus,
onBlur: handleBlur,
readOnly, readOnly,
singleLine, singleLine,
}), }),
@@ -195,10 +204,12 @@ function getExtensions({
singleLine, singleLine,
onChange, onChange,
onFocus, onFocus,
onBlur,
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & { }: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
container: HTMLDivElement | null; container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>; onChange: MutableRefObject<EditorProps['onChange']>;
onFocus: MutableRefObject<EditorProps['onFocus']>; onFocus: MutableRefObject<EditorProps['onFocus']>;
onBlur: MutableRefObject<EditorProps['onBlur']>;
}) { }) {
// TODO: Ensure tooltips render inside the dialog if we are in one. // TODO: Ensure tooltips render inside the dialog if we are in one.
const parent = const parent =
@@ -234,9 +245,8 @@ function getExtensions({
// Handle onFocus // Handle onFocus
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: () => { focus: onFocus.current,
onFocus.current?.(); blur: onBlur.current,
},
}), }),
// Handle onChange // Handle onChange

View File

@@ -17,6 +17,7 @@ export type InputProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'on
containerClassName?: string; containerClassName?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void;
defaultValue?: string; defaultValue?: string;
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
@@ -45,6 +46,8 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
defaultValue, defaultValue,
validate, validate,
require, require,
onFocus,
onBlur,
forceUpdateKey, forceUpdateKey,
...props ...props
}: InputProps, }: InputProps,
@@ -52,6 +55,18 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
) { ) {
const [obscured, setObscured] = useState(type === 'password'); const [obscured, setObscured] = useState(type === 'password');
const [currentValue, setCurrentValue] = useState(defaultValue ?? ''); const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const handleOnFocus = useCallback(() => {
setFocused(true);
onFocus?.();
}, [onFocus]);
const handleOnBlur = useCallback(() => {
setFocused(false);
onBlur?.();
}, [onBlur]);
const id = `input-${name}`; const id = `input-${name}`;
const inputClassName = classnames( const inputClassName = classnames(
className, className,
@@ -92,7 +107,8 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
className={classnames( className={classnames(
containerClassName, containerClassName,
'relative w-full rounded-md text-gray-900', 'relative w-full rounded-md text-gray-900',
'border border-highlight focus-within:border-focus', 'border border-highlight',
focused && 'border-focus',
!isValid && '!border-invalid', !isValid && '!border-invalid',
size === 'md' && 'h-md leading-md', size === 'md' && 'h-md leading-md',
size === 'sm' && 'h-sm leading-sm', size === 'sm' && 'h-sm leading-sm',
@@ -109,6 +125,8 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
placeholder={placeholder} placeholder={placeholder}
onChange={handleChange} onChange={handleChange}
className={inputClassName} className={inputClassName}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
{...props} {...props}
/> />
{type === 'password' && ( {type === 'password' && (

View File

@@ -337,7 +337,7 @@ const FormRow = memo(function FormRow({
size="sm" size="sm"
title="Delete header" title="Delete header"
onClick={handleDelete} onClick={handleDelete}
className="ml-0.5 opacity-0 group-hover:opacity-100 focus-visible:opacity-100" className="ml-0.5 !opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"
/> />
</div> </div>
); );

View File

@@ -10,7 +10,7 @@ import { HStack } from '../Stacks';
export type TabItem = export type TabItem =
| { | {
value: string; value: string;
label: string; label: ReactNode;
} }
| { | {
value: string; value: string;
@@ -73,16 +73,17 @@ export function Tabs({
aria-label={label} aria-label={label}
className={classnames( className={classnames(
tabListClassName, tabListClassName,
'h-md flex items-center overflow-x-auto pb-0.5 hide-scrollbars', 'h-md flex items-center overflow-x-auto hide-scrollbars',
// Give space for button focus states within overflow boundary // Give space for button focus states within overflow boundary
'px-2 -mx-2', 'px-2 -mx-2',
)} )}
> >
<HStack space={1} className="flex-shrink-0"> <HStack space={3} className="-ml-1 flex-shrink-0">
{tabs.map((t) => { {tabs.map((t) => {
const isActive = t.value === value; const isActive = t.value === value;
const btnClassName = classnames( const btnClassName = classnames(
isActive ? 'bg-gray-100 text-gray-800' : 'text-gray-600 hover:text-gray-900', '!px-1',
isActive ? 'text-gray-900' : 'text-gray-600 hover:text-gray-800',
); );
if ('options' in t) { if ('options' in t) {
const option = t.options.items.find( const option = t.options.items.find(

View File

@@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { debounce } from '../lib/debounce'; import { debounce } from '../lib/debounce';
export function useDebouncedSetState<T extends string | number>( export function useDebouncedSetState<T>(
defaultValue: T, defaultValue: T,
delay?: number, delay?: number,
): [T, Dispatch<SetStateAction<T>>] { ): [T, Dispatch<SetStateAction<T>>] {

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useDebouncedSetState } from './useDebouncedSetState'; import { useDebouncedSetState } from './useDebouncedSetState';
export function useDebouncedValue<T extends string | number>(value: T, delay?: number) { export function useDebouncedValue<T>(value: T, delay?: number) {
const [state, setState] = useDebouncedSetState<T>(value, delay); const [state, setState] = useDebouncedSetState<T>(value, delay);
useEffect(() => setState(value), [setState, value]); useEffect(() => setState(value), [setState, value]);
return state; return state;

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { GraphQLSchema } from 'graphql'; import type { GraphQLSchema } from 'graphql';
import { buildClientSchema, getIntrospectionQuery } from '../components/core/Editor'; import { buildClientSchema, getIntrospectionQuery } from '../components/core/Editor';
import { minPromiseMillis } from '../lib/minPromiseMillis';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest'; import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
import { useDebouncedValue } from './useDebouncedValue'; import { useDebouncedValue } from './useDebouncedValue';
@@ -13,23 +14,27 @@ const introspectionRequestBody = JSON.stringify({
export function useIntrospectGraphQL(baseRequest: HttpRequest) { export function useIntrospectGraphQL(baseRequest: HttpRequest) {
// Debounce the URL because it can change rapidly, and we don't // Debounce the URL because it can change rapidly, and we don't
// want to send so many requests. // want to send so many requests.
const debouncedUrl = useDebouncedValue(baseRequest.url); const request = useDebouncedValue(baseRequest);
return useQuery<GraphQLSchema, Error>({ return useQuery<GraphQLSchema, Error>({
queryKey: ['introspectGraphQL', { url: debouncedUrl }], queryKey: ['introspectGraphQL', { url: request.url, method: request.method }],
refetchOnWindowFocus: true,
// staleTime: 1000 * 60 * 60, // 1 hour
refetchInterval: 1000 * 60, // Refetch every minute refetchInterval: 1000 * 60, // Refetch every minute
queryFn: async () => { queryFn: async () => {
const response = await sendEphemeralRequest({ const response = await minPromiseMillis(
...baseRequest, sendEphemeralRequest({ ...baseRequest, body: introspectionRequestBody }),
body: introspectionRequestBody, 700,
}); );
if (response.error) { if (response.error) {
return Promise.reject(new Error(response.error)); return Promise.reject(new Error(response.error));
} }
if (response.status < 200 || response.status >= 300) {
return Promise.reject(
new Error(`Request failed with status ${response.status}.\n\n${response.body}`),
);
}
const { data } = JSON.parse(response.body); const { data } = JSON.parse(response.body);
return buildClientSchema(data); return buildClientSchema(data);
}, },

View File

@@ -0,0 +1,9 @@
import { sleep } from './sleep';
export async function minPromiseMillis<T>(promise: Promise<T>, millis: number) {
const start = Date.now();
const result = await promise;
const delayFor = millis - (Date.now() - start);
await sleep(delayFor);
return result;
}

View File

@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api';
import type { HttpRequest, HttpResponse } from './models'; import type { HttpRequest, HttpResponse } from './models';
export function sendEphemeralRequest(request: HttpRequest): Promise<HttpResponse> { export function sendEphemeralRequest(request: HttpRequest): Promise<HttpResponse> {
// Ensure it's not associated with an ID // Remove some things that we don't want to associate
const newRequest = { ...request, id: '' }; const newRequest = { ...request, id: '', requestId: '', workspaceId: '' };
return invoke('send_ephemeral_request', { request: newRequest }); return invoke('send_ephemeral_request', { request: newRequest });
} }

3
src-web/lib/sleep.ts Normal file
View File

@@ -0,0 +1,3 @@
export async function sleep(millis: number) {
await new Promise((resolve) => setTimeout(resolve, millis));
}

View File

@@ -32,6 +32,8 @@ module.exports = {
"sans": ["Inter", "sans-serif"] "sans": ["Inter", "sans-serif"]
}, },
fontSize: { fontSize: {
'2xs': "0.7rem",
xs: "0.8rem",
sm: "0.9rem", sm: "0.9rem",
base: "1rem", base: "1rem",
xl: "1.25rem", xl: "1.25rem",