);
}
// ─── RequestPanel ─────────────────────────────────────────────────────────────
export function RequestPanel() {
const {
state,
apiKeys,
apiKeysLoading,
setRequestBody,
setRequestHeaders,
setSelectedApiKey,
setManualApiToken,
sendRequest,
} = usePlaygroundContext();
// Pre-compute every per-key view-model so the JSX stays a flat
// render — no boolean math in the markup.
const apiKeyOptions: ComboboxOption[] = useMemo(
() =>
apiKeys.map((k) => ({
value: k.id,
label: k.name || 'Unnamed key',
// Surface the first 8 chars of the secret so the user
// can tell two similarly-named keys apart at a glance.
description: k.secret
? `${k.secret.slice(0, 8)}…`
: undefined,
})),
[apiKeys],
);
const hasApiKeys = apiKeyOptions.length > 0;
const ep = state.selectedEndpoint;
// ── Data (hooks must not be conditional) ─────────────────────────────────
const isJsonValid = state.requestBody ? isValidJson(state.requestBody) : true;
const curlCommand = useMemo(() => {
if (!state.requestUrl) return '';
// Resolve to an absolute URL so the snippet is runnable from a
// shell. The live ``fetch`` inside the playground resolves the
// relative form itself via the browser; copy-pasting to curl
// doesn't get that treatment.
const absoluteUrl = resolveAbsolute(state.requestUrl);
const apiKey = state.selectedApiKey ? findApiKeyById(apiKeys, state.selectedApiKey) : null;
const hdrs = parseRequestHeaders(state.requestHeaders);
if (apiKey) hdrs['X-API-Key'] = apiKey.secret || apiKey.id;
let cmd = `curl -X ${state.requestMethod} "${absoluteUrl}"`;
Object.entries(hdrs).forEach(([k, v]) => { cmd += ` \\\n -H "${k}: ${v}"`; });
if (state.requestBody && state.requestMethod !== 'GET' && isJsonValid) {
cmd += ` \\\n -d '${state.requestBody}'`;
}
return cmd;
}, [state, apiKeys, isJsonValid]);
const pathParams = useMemo(
() => ep?.parameters?.filter((p) => ep.path.includes(`{${p.name}}`)) ?? [],
[ep],
);
const queryParams = useMemo(
() => ep?.parameters?.filter((p) => !ep.path.includes(`{${p.name}}`)) ?? [],
[ep],
);
// ── Derived ───────────────────────────────────────────────────────────────
const isSendDisabled = state.loading || !state.requestUrl || !isJsonValid;
// Show the absolute URL in the meta row so the user sees exactly
// what will go over the wire — same rewrite we do for cURL.
const displayUrl = resolveAbsolute(state.requestUrl || ep?.path || '');
const hasBody = ep?.method !== 'GET';
const bodyType = ep?.requestBody?.type ?? '';
const hasPathParams = pathParams.length > 0;
const hasQueryParams = queryParams.length > 0;
const hasCurl = Boolean(curlCommand);
const epPath = ep ? relativePath(ep.path) : '';
// Show the URL meta row only when it differs from the endpoint's
// template shape — i.e. the user has substituted path params or the
// URL host is worth showing explicitly.
const urlChanged = displayUrl !== '' && displayUrl !== epPath;
// ── Early return ──────────────────────────────────────────────────────────
if (!ep) {
return ;
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<>
{/* Inline meta row — shows the effective URL when it differs
from the endpoint's path template (e.g. after substituting
path parameters), plus the Reset action. We dropped the
full endpoint header because the containing surface (SidePanel
in docs layout, column header in classic layout) already
shows the method + path, and duplicating the Send button
next to a full-width Send at the bottom read as confusing. */}
{(urlChanged || ep) && (
{/* cURL — collapsed by default. PrettyCode has its own
hover-toolbar with Copy, so no duplicate action here. */}
{hasCurl && (
cURL
}
>
)}
{/* Bottom breathing room — the Send footer lives outside
this component (in SlideInPlayground / TryItSheet),
so we just leave a little space so the last section
doesn't crash against the container edge. */}
>
);
}
// ─── Body section ─────────────────────────────────────────────────────────────
interface BodySectionProps {
schema: Record | undefined;
bodyType: string;
bodyDescription?: string;
/** JSON-serialised body kept in context. Form edits are re-serialised
* before being written back so one source of truth survives across
* the two view modes. */
value: string;
onChange: (raw: string) => void;
isJsonValid: boolean;
}
type BodyViewMode = 'form' | 'json';
function BodySection({ schema, bodyType, bodyDescription, value, onChange, isJsonValid }: BodySectionProps) {
// Default to form view when we have a schema to drive it. Fall back
// to raw JSON for schemaless endpoints (or binary bodies, etc.).
const hasSchema = !!schema;
const [mode, setMode] = React.useState(hasSchema ? 'form' : 'json');
// Parse the context's JSON string once per value change so the form
// sees a structured object. Invalid JSON is tolerated — the form
// simply shows empty fields until the user fixes it.
const parsed = React.useMemo(() => {
if (!value) return null;
try { return JSON.parse(value); } catch { return null; }
}, [value]);
const handleFormChange = useCallback(
(next: unknown) => {
onChange(JSON.stringify(next, null, 2));
},
[onChange],
);
return (