mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-14 06:16:08 +01:00
Show response headers
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
21
src-web/components/core/Banner.tsx
Normal file
21
src-web/components/core/Banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
12
src-web/components/core/CountBadge.tsx
Normal file
12
src-web/components/core/CountBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>>] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
9
src-web/lib/minPromiseMillis.ts
Normal file
9
src-web/lib/minPromiseMillis.ts
Normal 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;
|
||||
}
|
||||
@@ -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
3
src-web/lib/sleep.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function sleep(millis: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, millis));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user