From ceefbd1de11f403eb792c7a16ed2cbada21ac70f Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 1 Apr 2023 23:43:22 -0700 Subject: [PATCH] Show response headers --- src-web/components/GraphQLEditor.tsx | 11 +- src-web/components/RequestPane.tsx | 19 ++- src-web/components/ResponsePane.tsx | 180 ++++++++++++++-------- src-web/components/TauriListeners.tsx | 59 ++++--- src-web/components/core/Banner.tsx | 21 +++ src-web/components/core/Button.tsx | 5 +- src-web/components/core/CountBadge.tsx | 12 ++ src-web/components/core/Editor/Editor.css | 2 +- src-web/components/core/Editor/Editor.tsx | 16 +- src-web/components/core/Input.tsx | 20 ++- src-web/components/core/PairEditor.tsx | 2 +- src-web/components/core/Tabs/Tabs.tsx | 9 +- src-web/hooks/useDebouncedSetState.ts | 2 +- src-web/hooks/useDebouncedValue.ts | 2 +- src-web/hooks/useIntrospectGraphQL.ts | 21 ++- src-web/lib/minPromiseMillis.ts | 9 ++ src-web/lib/sendEphemeralRequest.ts | 4 +- src-web/lib/sleep.ts | 3 + tailwind.config.cjs | 2 + 19 files changed, 270 insertions(+), 129 deletions(-) create mode 100644 src-web/components/core/Banner.tsx create mode 100644 src-web/components/core/CountBadge.tsx create mode 100644 src-web/lib/minPromiseMillis.ts create mode 100644 src-web/lib/sleep.ts diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index 07d13bf8..d1c50d5e 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -81,19 +81,22 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi placeholder="..." ref={editorViewRef} actions={ - introspection.error && ( + (introspection.error || introspection.isLoading) && ( ) } diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index cff1b453..38371b9b 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -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: ( +
+ Headers + h.name && h.value).length} + /> +
+ ), + }, { value: 'auth', label: 'Auth', @@ -150,10 +161,10 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN {activeRequest.authenticationType === AUTH_TYPE_BASIC ? ( @@ -188,7 +199,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN onChange={() => null} /> - + {activeRequest.bodyType === BODY_TYPE_JSON ? ( ('body'); + export const ResponsePane = memo(function ResponsePane({ style, className }: Props) { const [pinnedResponseId, setPinnedResponseId] = useState(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: ( +
+ Headers + h.name && h.value).length ?? 0} + /> +
+ ), + value: 'headers', + }, + ], + [activeResponse?.headers], + ); + return (
{activeResponse && ( <> -
+
{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
- - ({ - label: r.status + ' - ' + r.elapsed + ' ms', - leftSlot: activeResponse?.id === r.id ? : <>, - onSelect: () => setPinnedResponseId(r.id), - })), - ]} - > - - - + ({ + label: r.status + ' - ' + r.elapsed + ' ms', + leftSlot: activeResponse?.id === r.id ? : <>, + onSelect: () => setPinnedResponseId(r.id), + })), + ]} + > + + )} - {activeResponse === null ? ( - No Response - ) : activeResponse?.error ? ( -
-
{activeResponse.error}
-
- ) : viewMode === 'pretty' && contentType.includes('html') ? ( - - ) : viewMode === 'pretty' && contentType.includes('json') ? ( - - ) : activeResponse?.body ? ( - - ) : null} + {activeResponse?.error ? ( + {activeResponse.error} + ) : ( + + + {activeResponse === null ? ( + No Response + ) : viewMode === 'pretty' && contentType.includes('html') ? ( + + ) : viewMode === 'pretty' && contentType.includes('json') ? ( + + ) : activeResponse?.body ? ( + + ) : null} + + +
    + {activeResponse?.headers.map((h) => ( +
  • + {h.name}:{' '} + {h.value} +
  • + ))} +
+
+
+ )}
); }); diff --git a/src-web/components/TauriListeners.tsx b/src-web/components/TauriListeners.tsx index 91e4e577..f48831a4 100644 --- a/src-web/components/TauriListeners.tsx +++ b/src-web/components/TauriListeners.tsx @@ -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( - 'updated_model', - debounce(({ payload, windowLabel }) => { - if (shouldIgnoreEvent(payload, windowLabel)) return; + useTauriEvent('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(queryKey, (values) => - values?.map((v) => (modelsEq(v, payload) ? payload : v)), - ); - } - }, 500), - ); + if (!shouldIgnoreModel(payload)) { + queryClient.setQueryData(queryKey, (values) => + values?.map((v) => (modelsEq(v, payload) ? payload : v)), + ); + } + }); useTauriEvent('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(model: T) { diff --git a/src-web/components/core/Banner.tsx b/src-web/components/core/Banner.tsx new file mode 100644 index 00000000..e993b2f6 --- /dev/null +++ b/src-web/components/core/Banner.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} diff --git a/src-web/components/core/Button.tsx b/src-web/components/core/Button.tsx index 1adae345..1803e23a 100644 --- a/src-web/components/core/Button.tsx +++ b/src-web/components/core/Button.tsx @@ -18,6 +18,7 @@ const colorStyles = { export type ButtonProps = HTMLAttributes & { 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 & { const _Button = forwardRef(function Button( { to, + isLoading, className, children, forDropdown, @@ -68,8 +70,9 @@ const _Button = forwardRef(function Button( } else { return ( ); } diff --git a/src-web/components/core/CountBadge.tsx b/src-web/components/core/CountBadge.tsx new file mode 100644 index 00000000..0b4a1f22 --- /dev/null +++ b/src-web/components/core/CountBadge.tsx @@ -0,0 +1,12 @@ +interface Props { + count: number; +} + +export function CountBadge({ count }: Props) { + if (count === 0) return null; + return ( +
+ {count} +
+ ); +} diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index c573d51e..969652c5 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -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; diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 9bb1be52..c1b5e648 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -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(function Editor( forceUpdateKey, onChange, onFocus, + onBlur, className, singleLine, format, @@ -75,6 +77,12 @@ const _Editor = forwardRef(function Editor( handleFocus.current = onFocus; }, [onFocus]); + // Use ref so we can update the onChange handler without re-initializing the editor + const handleBlur = useRef(onBlur); + useEffect(() => { + handleBlur.current = onBlur; + }, [onBlur]); + // Update placeholder const placeholderCompartment = useRef(new Compartment()); useEffect(() => { @@ -125,6 +133,7 @@ const _Editor = forwardRef(function Editor( container, onChange: handleChange, onFocus: handleFocus, + onBlur: handleBlur, readOnly, singleLine, }), @@ -195,10 +204,12 @@ function getExtensions({ singleLine, onChange, onFocus, + onBlur, }: Pick & { container: HTMLDivElement | null; onChange: MutableRefObject; onFocus: MutableRefObject; + onBlur: MutableRefObject; }) { // 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 diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index ba79d27b..90bf07e5 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -17,6 +17,7 @@ export type InputProps = Omit, '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(function Inp defaultValue, validate, require, + onFocus, + onBlur, forceUpdateKey, ...props }: InputProps, @@ -52,6 +55,18 @@ export const Input = forwardRef(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(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(function Inp placeholder={placeholder} onChange={handleChange} className={inputClassName} + onFocus={handleOnFocus} + onBlur={handleOnBlur} {...props} /> {type === 'password' && ( diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index 82259db8..25220783 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -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" />
); diff --git a/src-web/components/core/Tabs/Tabs.tsx b/src-web/components/core/Tabs/Tabs.tsx index a7fdeaa7..e0b1f088 100644 --- a/src-web/components/core/Tabs/Tabs.tsx +++ b/src-web/components/core/Tabs/Tabs.tsx @@ -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', )} > - + {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( diff --git a/src-web/hooks/useDebouncedSetState.ts b/src-web/hooks/useDebouncedSetState.ts index 89e436f5..8433e3bc 100644 --- a/src-web/hooks/useDebouncedSetState.ts +++ b/src-web/hooks/useDebouncedSetState.ts @@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { useMemo, useState } from 'react'; import { debounce } from '../lib/debounce'; -export function useDebouncedSetState( +export function useDebouncedSetState( defaultValue: T, delay?: number, ): [T, Dispatch>] { diff --git a/src-web/hooks/useDebouncedValue.ts b/src-web/hooks/useDebouncedValue.ts index 83aa1ace..65a0c4cb 100644 --- a/src-web/hooks/useDebouncedValue.ts +++ b/src-web/hooks/useDebouncedValue.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useDebouncedSetState } from './useDebouncedSetState'; -export function useDebouncedValue(value: T, delay?: number) { +export function useDebouncedValue(value: T, delay?: number) { const [state, setState] = useDebouncedSetState(value, delay); useEffect(() => setState(value), [setState, value]); return state; diff --git a/src-web/hooks/useIntrospectGraphQL.ts b/src-web/hooks/useIntrospectGraphQL.ts index 51b2fa82..49d675f3 100644 --- a/src-web/hooks/useIntrospectGraphQL.ts +++ b/src-web/hooks/useIntrospectGraphQL.ts @@ -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({ - 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); }, diff --git a/src-web/lib/minPromiseMillis.ts b/src-web/lib/minPromiseMillis.ts new file mode 100644 index 00000000..8287a429 --- /dev/null +++ b/src-web/lib/minPromiseMillis.ts @@ -0,0 +1,9 @@ +import { sleep } from './sleep'; + +export async function minPromiseMillis(promise: Promise, millis: number) { + const start = Date.now(); + const result = await promise; + const delayFor = millis - (Date.now() - start); + await sleep(delayFor); + return result; +} diff --git a/src-web/lib/sendEphemeralRequest.ts b/src-web/lib/sendEphemeralRequest.ts index 946bc121..8f429acd 100644 --- a/src-web/lib/sendEphemeralRequest.ts +++ b/src-web/lib/sendEphemeralRequest.ts @@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api'; import type { HttpRequest, HttpResponse } from './models'; export function sendEphemeralRequest(request: HttpRequest): Promise { - // 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 }); } diff --git a/src-web/lib/sleep.ts b/src-web/lib/sleep.ts new file mode 100644 index 00000000..8e339f37 --- /dev/null +++ b/src-web/lib/sleep.ts @@ -0,0 +1,3 @@ +export async function sleep(millis: number) { + await new Promise((resolve) => setTimeout(resolve, millis)); +} diff --git a/tailwind.config.cjs b/tailwind.config.cjs index b2638d43..3730d891 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -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",