mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-18 23:09:47 +02:00
Show response headers
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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> & {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
.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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>>] {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
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';
|
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
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"]
|
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user