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="..."
ref={editorViewRef}
actions={
introspection.error && (
(introspection.error || introspection.isLoading) && (
<Button
size="xs"
color="danger"
color={introspection.error ? 'danger' : 'gray'}
isLoading={introspection.isLoading}
onClick={() => {
dialog.show({
title: 'Introspection Failed',
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>
)
}

View File

@@ -21,6 +21,7 @@ import {
} from '../lib/models';
import { BasicAuth } from './BasicAuth';
import { BearerAuth } from './BearerAuth';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import type { TabItem } 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: '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',
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} />
<Tabs
value={activeTab}
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
className="mt-2"
label="Request body"
className="mt-1"
>
<TabContent value="auth">
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
@@ -188,7 +199,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
onChange={() => null}
/>
</TabContent>
<TabContent value="body" className="pl-3 mt-1">
<TabContent value="body" className="mt-1">
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor
forceUpdateKey={forceUpdateKey}

View File

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

View File

@@ -7,7 +7,6 @@ import { responsesQueryKey } from '../hooks/useResponses';
import { useTauriEvent } from '../hooks/useTauriEvent';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { debounce } from '../lib/debounce';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
@@ -42,40 +41,37 @@ export function TauriListeners() {
}
});
useTauriEvent<Model>(
'updated_model',
debounce(({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
useTauriEvent<Model>('updated_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
: payload.model === 'http_response'
? responsesQueryKey(payload)
: payload.model === 'workspace'
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: null;
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
: payload.model === 'http_response'
? responsesQueryKey(payload)
: payload.model === 'workspace'
? workspacesQueryKey(payload)
: payload.model === 'key_value'
? keyValueQueryKey(payload)
: null;
if (queryKey === null) {
if (payload.model) {
console.log('Unrecognized updated model:', payload);
}
return;
if (queryKey === null) {
if (payload.model) {
console.log('Unrecognized updated model:', payload);
}
return;
}
if (payload.model === 'http_request') {
wasUpdatedExternally(payload.id);
}
if (payload.model === 'http_request') {
wasUpdatedExternally(payload.id);
}
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(queryKey, (values) =>
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
);
}
}, 500),
);
if (!shouldIgnoreModel(payload)) {
queryClient.setQueryData<Model[]>(queryKey, (values) =>
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
);
}
});
useTauriEvent<Model>('deleted_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;
@@ -107,7 +103,8 @@ export function TauriListeners() {
document.documentElement.style.fontSize = `${newFontSize}px`;
});
return <></>;
return null;
}
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> & {
to?: string;
color?: keyof typeof colorStyles;
isLoading?: boolean;
size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center';
type?: 'button' | 'submit';
@@ -30,6 +31,7 @@ export type ButtonProps = HTMLAttributes<HTMLElement> & {
const _Button = forwardRef<any, ButtonProps>(function Button(
{
to,
isLoading,
className,
children,
forDropdown,
@@ -68,8 +70,9 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
} else {
return (
<button ref={ref} className={classes} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{children}
{forDropdown && <Icon icon="chevronDown" size="sm" className="ml-1 -mr-1" />}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}
</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 {
@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 */
@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;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
singleLine?: boolean;
format?: (v: string) => string;
autocomplete?: GenericCompletionConfig;
@@ -52,6 +53,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
forceUpdateKey,
onChange,
onFocus,
onBlur,
className,
singleLine,
format,
@@ -75,6 +77,12 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
handleFocus.current = 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
const placeholderCompartment = useRef(new Compartment());
useEffect(() => {
@@ -125,6 +133,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
container,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
readOnly,
singleLine,
}),
@@ -195,10 +204,12 @@ function getExtensions({
singleLine,
onChange,
onFocus,
onBlur,
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>;
onFocus: MutableRefObject<EditorProps['onFocus']>;
onBlur: MutableRefObject<EditorProps['onBlur']>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
@@ -234,9 +245,8 @@ function getExtensions({
// Handle onFocus
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
},
focus: onFocus.current,
blur: onBlur.current,
}),
// Handle onChange

View File

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

View File

@@ -337,7 +337,7 @@ const FormRow = memo(function FormRow({
size="sm"
title="Delete header"
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>
);

View File

@@ -10,7 +10,7 @@ import { HStack } from '../Stacks';
export type TabItem =
| {
value: string;
label: string;
label: ReactNode;
}
| {
value: string;
@@ -73,16 +73,17 @@ export function Tabs({
aria-label={label}
className={classnames(
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
'px-2 -mx-2',
)}
>
<HStack space={1} className="flex-shrink-0">
<HStack space={3} className="-ml-1 flex-shrink-0">
{tabs.map((t) => {
const isActive = t.value === value;
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) {
const option = t.options.items.find(

View File

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

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
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);
useEffect(() => setState(value), [setState, value]);
return state;

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import type { GraphQLSchema } from 'graphql';
import { buildClientSchema, getIntrospectionQuery } from '../components/core/Editor';
import { minPromiseMillis } from '../lib/minPromiseMillis';
import type { HttpRequest } from '../lib/models';
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
import { useDebouncedValue } from './useDebouncedValue';
@@ -13,23 +14,27 @@ const introspectionRequestBody = JSON.stringify({
export function useIntrospectGraphQL(baseRequest: HttpRequest) {
// Debounce the URL because it can change rapidly, and we don't
// want to send so many requests.
const debouncedUrl = useDebouncedValue(baseRequest.url);
const request = useDebouncedValue(baseRequest);
return useQuery<GraphQLSchema, Error>({
queryKey: ['introspectGraphQL', { url: debouncedUrl }],
refetchOnWindowFocus: true,
// staleTime: 1000 * 60 * 60, // 1 hour
queryKey: ['introspectGraphQL', { url: request.url, method: request.method }],
refetchInterval: 1000 * 60, // Refetch every minute
queryFn: async () => {
const response = await sendEphemeralRequest({
...baseRequest,
body: introspectionRequestBody,
});
const response = await minPromiseMillis(
sendEphemeralRequest({ ...baseRequest, body: introspectionRequestBody }),
700,
);
if (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);
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';
export function sendEphemeralRequest(request: HttpRequest): Promise<HttpResponse> {
// Ensure it's not associated with an ID
const newRequest = { ...request, id: '' };
// Remove some things that we don't want to associate
const newRequest = { ...request, id: '', requestId: '', workspaceId: '' };
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"]
},
fontSize: {
'2xs': "0.7rem",
xs: "0.8rem",
sm: "0.9rem",
base: "1rem",
xl: "1.25rem",