Files
yaak-mountain-loop/apps/yaak-client/components/GrpcRequestPane.tsx
Gregory Schier 360c098b09 fix(grpc): remove the dead Refresh item from the method picker
It has never had an onSelect. Introduced inert in #193 and carried
through the codebase split, so clicking it only ever closed the dropdown.
Reload Schema in the message editor's schema dropdown does the job it
looked like it was for.
2026-08-31 10:54:40 -07:00

317 lines
10 KiB
TypeScript

import {
type GrpcRequest,
type HttpRequestHeader,
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
import { useAuthTab } from "../hooks/useAuthTab";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { RadioDropdown } from "./core/RadioDropdown";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { GrpcEditor } from "./GrpcEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
protoFiles: string[];
reflectionError?: string;
reflectionLoading?: boolean;
methodType:
| "unary"
| "client_streaming"
| "server_streaming"
| "streaming"
| "no-schema"
| "no-method";
isStreaming: boolean;
onCommit: () => void;
onCancel: () => void;
onSend: (v: { message: string }) => void;
onGo: () => void;
services: ReflectResponseService[] | null;
}
const TAB_MESSAGE = "message";
const TAB_METADATA = "metadata";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
export function GrpcRequestPane({
style,
services,
methodType,
activeRequest,
protoFiles,
reflectionError,
reflectionLoading,
isStreaming,
onGo,
onCommit,
onCancel,
onSend,
}: Props) {
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const urlContainerEl = useRef<HTMLDivElement>(null);
const { width: paneWidth } = useContainerSize(urlContainerEl);
const handleChangeUrl = useCallback(
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
const handleChangeMessage = useCallback(
// Not debounced: handleSend reads message from the store, so a pending
// debounced patch would send stale text
(message: string) => patchModel(activeRequest, { message }),
[activeRequest],
);
const select = useMemo(() => {
const options =
services?.flatMap((s) =>
s.methods.map((m) => ({
label: `${s.name.split(".").pop() ?? s.name}/${m.name}`,
value: `${s.name}/${m.name}`,
})),
) ?? [];
const value = `${activeRequest?.service ?? ""}/${activeRequest?.method ?? ""}`;
return { value, options };
}, [activeRequest?.method, activeRequest?.service, services]);
const handleChangeService = useCallback(
async (v: string) => {
const [serviceName, methodName] = v.split("/", 2);
if (serviceName == null || methodName == null) throw new Error("Should never happen");
await patchModel(activeRequest, {
service: serviceName,
method: methodName,
});
},
[activeRequest],
);
const handleConnect = useCallback(async () => {
if (activeRequest == null) return;
if (activeRequest.service == null || activeRequest.method == null) {
alert({
id: "grpc-invalid-service-method",
title: "Error",
body: "Service or method not selected",
});
}
onGo();
}, [activeRequest, onGo]);
const handleSend = useCallback(async () => {
if (activeRequest == null) return;
onSend({ message: activeRequest.message });
}, [activeRequest, onSend]);
const tabs: TabItem[] = useMemo(
() => [
{ value: TAB_MESSAGE, label: "Message" },
...metadataTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: activeRequest.description && <CountBadge count={true} />,
},
],
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
);
const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
[activeRequest],
);
const handleDescriptionChange = useCallback(
(description: string) => patchModelDebounced(activeRequest, { description }),
[activeRequest],
);
return (
<VStack style={style}>
<div
ref={urlContainerEl}
className={classNames(
"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
paneWidth === 0 && "opacity-0",
paneWidth > 0 && paneWidth < 400 && "grid-cols-1!",
)}
>
<UrlBar
key={forceUpdateKey}
url={activeRequest.url ?? ""}
submitIcon={null}
forceUpdateKey={forceUpdateKey}
placeholder="localhost:50051"
onSend={handleConnect}
onUrlChange={handleChangeUrl}
onCancel={onCancel}
isLoading={isStreaming}
stateKey={`grpc_url.${activeRequest.id}`}
/>
<HStack space={1.5}>
<RadioDropdown
value={select.value}
onChange={handleChangeService}
items={select.options.map((o) => ({
label: o.label,
value: o.value,
type: "default",
shortLabel: o.label,
}))}
>
<Button
size="sm"
variant="border"
rightSlot={<Icon size="sm" icon="chevron_down" />}
disabled={isStreaming || services == null}
className={classNames(
"font-mono text-editor min-w-20 ring-0!",
paneWidth < 400 && "flex-1",
)}
>
{select.options.find((o) => o.value === select.value)?.label ?? "No Schema"}
</Button>
</RadioDropdown>
{methodType === "client_streaming" || methodType === "streaming" ? (
<>
{isStreaming && (
<>
<IconButton
variant="border"
size="sm"
title="Cancel"
onClick={onCancel}
icon="x"
/>
<IconButton
variant="border"
size="sm"
title="Commit"
onClick={onCommit}
icon="check"
/>
</>
)}
<IconButton
size="sm"
variant="border"
title={isStreaming ? "Connect" : "Send"}
hotkeyAction="request.send"
onClick={isStreaming ? handleSend : handleConnect}
icon={isStreaming ? "send_horizontal" : "arrow_up_down"}
/>
</>
) : (
<IconButton
size="sm"
variant="border"
title={methodType === "unary" ? "Send" : "Connect"}
hotkeyAction="request.send"
onClick={isStreaming ? onCancel : handleConnect}
disabled={methodType === "no-schema" || methodType === "no-method"}
icon={
isStreaming
? "x"
: methodType.includes("streaming")
? "arrow_up_down"
: "send_horizontal"
}
/>
)}
</HStack>
</div>
<Tabs
label="Request"
tabs={tabs}
tabListClassName="mt-1 mb-1.5!"
storageKey="grpc_request_tabs"
activeTabKey={activeRequest.id}
>
<TabContent value="message">
<GrpcEditor
onChange={handleChangeMessage}
forceUpdateKey={forceUpdateKey}
services={services}
reflectionError={reflectionError}
reflectionLoading={reflectionLoading}
request={activeRequest}
protoFiles={protoFiles}
/>
</TabContent>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_METADATA}>
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={forceUpdateKey}
headers={activeRequest.metadata}
stateKey={`headers.${activeRequest.id}`}
onChange={handleMetadataChange}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
onChange={handleDescriptionChange}
/>
</div>
</TabContent>
</Tabs>
</VStack>
);
}