@djangocfg/ui-tools 2.1.285 → 2.1.286
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DocsLayout-BCVU6TTX.cjs +2027 -0
- package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
- package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
- package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
- package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
- package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
- package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
- package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
- package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
- package/dist/chunk-IULI4XII.cjs +1129 -0
- package/dist/chunk-IULI4XII.cjs.map +1 -0
- package/dist/chunk-VZGQC3NG.mjs +1100 -0
- package/dist/chunk-VZGQC3NG.mjs.map +1 -0
- package/dist/index.cjs +88 -552
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -6
- package/dist/index.d.ts +18 -6
- package/dist/index.mjs +25 -496
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +4 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
- package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
- package/src/tools/OpenapiViewer/README.md +104 -51
- package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
- package/src/tools/OpenapiViewer/components/index.ts +5 -2
- package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
- package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
- package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
- package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
- package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
- package/src/tools/OpenapiViewer/index.tsx +3 -7
- package/src/tools/OpenapiViewer/lazy.tsx +6 -27
- package/src/tools/OpenapiViewer/types.ts +44 -0
- package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
- package/src/tools/OpenapiViewer/utils/index.ts +3 -1
- package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
- package/src/tools/OpenapiViewer/utils/url.ts +202 -0
- package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
- package/src/tools/PrettyCode/index.tsx +6 -0
- package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
- package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
- package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
- package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
- package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
- package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
- package/dist/chunk-5FKE7OME.cjs +0 -369
- package/dist/chunk-5FKE7OME.cjs.map +0 -1
- package/dist/chunk-BKWDHJKF.mjs +0 -356
- package/dist/chunk-BKWDHJKF.mjs.map +0 -1
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
- /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
- /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Key,
|
|
4
|
-
import React, { useMemo } from 'react';
|
|
3
|
+
import { Key, Send, Sparkles, Terminal } from 'lucide-react';
|
|
4
|
+
import React, { useCallback, useMemo } from 'react';
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
-
Button,
|
|
8
7
|
Combobox,
|
|
9
8
|
type ComboboxOption,
|
|
10
|
-
CopyButton,
|
|
11
9
|
Input,
|
|
12
10
|
Textarea,
|
|
13
11
|
} from '@djangocfg/ui-core/components';
|
|
@@ -16,13 +14,14 @@ import { cn } from '@djangocfg/ui-core/lib';
|
|
|
16
14
|
import PrettyCode from '../../../PrettyCode';
|
|
17
15
|
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
18
16
|
import { findApiKeyById, isValidJson, parseRequestHeaders } from '../../utils';
|
|
17
|
+
import { resolveAbsolute } from '../../utils/url';
|
|
18
|
+
import { BodyFormEditor } from './BodyFormEditor';
|
|
19
|
+
import { EndpointResetButton } from './EndpointResetButton';
|
|
19
20
|
import {
|
|
20
21
|
CollapsibleSection,
|
|
21
22
|
EmptyState,
|
|
22
|
-
MethodBadge,
|
|
23
23
|
ScrollArea,
|
|
24
24
|
SectionLabel,
|
|
25
|
-
StatusBadge,
|
|
26
25
|
relativePath,
|
|
27
26
|
} from './ui';
|
|
28
27
|
|
|
@@ -107,13 +106,15 @@ export function RequestPanel() {
|
|
|
107
106
|
|
|
108
107
|
const curlCommand = useMemo(() => {
|
|
109
108
|
if (!state.requestUrl) return '';
|
|
109
|
+
// Resolve to an absolute URL so the snippet is runnable from a
|
|
110
|
+
// shell. The live ``fetch`` inside the playground resolves the
|
|
111
|
+
// relative form itself via the browser; copy-pasting to curl
|
|
112
|
+
// doesn't get that treatment.
|
|
113
|
+
const absoluteUrl = resolveAbsolute(state.requestUrl);
|
|
110
114
|
const apiKey = state.selectedApiKey ? findApiKeyById(apiKeys, state.selectedApiKey) : null;
|
|
111
115
|
const hdrs = parseRequestHeaders(state.requestHeaders);
|
|
112
|
-
// Use ``secret`` (the raw key) for the cURL header — matches
|
|
113
|
-
// what the playground sends over the wire. Fall back to
|
|
114
|
-
// ``id`` for legacy callers still on the old ApiKey shape.
|
|
115
116
|
if (apiKey) hdrs['X-API-Key'] = apiKey.secret || apiKey.id;
|
|
116
|
-
let cmd = `curl -X ${state.requestMethod} "${
|
|
117
|
+
let cmd = `curl -X ${state.requestMethod} "${absoluteUrl}"`;
|
|
117
118
|
Object.entries(hdrs).forEach(([k, v]) => { cmd += ` \\\n -H "${k}: ${v}"`; });
|
|
118
119
|
if (state.requestBody && state.requestMethod !== 'GET' && isJsonValid) {
|
|
119
120
|
cmd += ` \\\n -d '${state.requestBody}'`;
|
|
@@ -132,14 +133,19 @@ export function RequestPanel() {
|
|
|
132
133
|
|
|
133
134
|
// ── Derived ───────────────────────────────────────────────────────────────
|
|
134
135
|
const isSendDisabled = state.loading || !state.requestUrl || !isJsonValid;
|
|
135
|
-
|
|
136
|
+
// Show the absolute URL in the meta row so the user sees exactly
|
|
137
|
+
// what will go over the wire — same rewrite we do for cURL.
|
|
138
|
+
const displayUrl = resolveAbsolute(state.requestUrl || ep?.path || '');
|
|
136
139
|
const hasBody = ep?.method !== 'GET';
|
|
137
140
|
const bodyType = ep?.requestBody?.type ?? '';
|
|
138
141
|
const hasPathParams = pathParams.length > 0;
|
|
139
142
|
const hasQueryParams = queryParams.length > 0;
|
|
140
143
|
const hasCurl = Boolean(curlCommand);
|
|
141
144
|
const epPath = ep ? relativePath(ep.path) : '';
|
|
142
|
-
|
|
145
|
+
// Show the URL meta row only when it differs from the endpoint's
|
|
146
|
+
// template shape — i.e. the user has substituted path params or the
|
|
147
|
+
// URL host is worth showing explicitly.
|
|
148
|
+
const urlChanged = displayUrl !== '' && displayUrl !== epPath;
|
|
143
149
|
|
|
144
150
|
// ── Early return ──────────────────────────────────────────────────────────
|
|
145
151
|
if (!ep) {
|
|
@@ -149,29 +155,25 @@ export function RequestPanel() {
|
|
|
149
155
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
150
156
|
return (
|
|
151
157
|
<>
|
|
152
|
-
{/*
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
158
|
+
{/* Inline meta row — shows the effective URL when it differs
|
|
159
|
+
from the endpoint's path template (e.g. after substituting
|
|
160
|
+
path parameters), plus the Reset action. We dropped the
|
|
161
|
+
full endpoint header because the containing surface (SidePanel
|
|
162
|
+
in docs layout, column header in classic layout) already
|
|
163
|
+
shows the method + path, and duplicating the Send button
|
|
164
|
+
next to a full-width Send at the bottom read as confusing. */}
|
|
165
|
+
{(urlChanged || ep) && (
|
|
166
|
+
<div className="shrink-0 border-b px-4 py-2 bg-muted/10 flex items-center gap-2 min-h-[28px]">
|
|
167
|
+
{urlChanged ? (
|
|
168
|
+
<span className="font-mono text-[10px] text-muted-foreground/60 break-all leading-snug truncate min-w-0 flex-1">
|
|
169
|
+
{displayUrl}
|
|
170
|
+
</span>
|
|
171
|
+
) : (
|
|
172
|
+
<span className="flex-1" />
|
|
173
|
+
)}
|
|
174
|
+
<EndpointResetButton />
|
|
168
175
|
</div>
|
|
169
|
-
|
|
170
|
-
<div className="font-mono text-[10px] text-muted-foreground/50 break-all leading-snug pl-0.5">
|
|
171
|
-
{displayUrl}
|
|
172
|
-
</div>
|
|
173
|
-
)}
|
|
174
|
-
</div>
|
|
176
|
+
)}
|
|
175
177
|
|
|
176
178
|
{/* Scrollable fields */}
|
|
177
179
|
<ScrollArea className="px-4 py-3 space-y-3">
|
|
@@ -181,41 +183,14 @@ export function RequestPanel() {
|
|
|
181
183
|
|
|
182
184
|
{/* Body */}
|
|
183
185
|
{hasBody && (
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
{isJsonValid && state.requestBody && (
|
|
193
|
-
<button
|
|
194
|
-
type="button"
|
|
195
|
-
onClick={() => {
|
|
196
|
-
try {
|
|
197
|
-
setRequestBody(JSON.stringify(JSON.parse(state.requestBody), null, 2));
|
|
198
|
-
} catch {}
|
|
199
|
-
}}
|
|
200
|
-
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
201
|
-
>
|
|
202
|
-
<Sparkles className="h-2.5 w-2.5" />
|
|
203
|
-
Format
|
|
204
|
-
</button>
|
|
205
|
-
)}
|
|
206
|
-
</div>
|
|
207
|
-
<Textarea
|
|
208
|
-
placeholder={'{\n "key": "value"\n}'}
|
|
209
|
-
value={state.requestBody}
|
|
210
|
-
onChange={(e) => setRequestBody(e.target.value)}
|
|
211
|
-
className={cn(
|
|
212
|
-
'font-mono text-[11px] min-h-[90px] resize-y',
|
|
213
|
-
!isJsonValid && 'border-destructive focus-visible:ring-destructive/30',
|
|
214
|
-
)}
|
|
215
|
-
rows={4}
|
|
216
|
-
/>
|
|
217
|
-
{!isJsonValid && <p className="text-[10px] text-destructive">Invalid JSON</p>}
|
|
218
|
-
</div>
|
|
186
|
+
<BodySection
|
|
187
|
+
schema={ep.requestBody?.schema}
|
|
188
|
+
bodyType={bodyType}
|
|
189
|
+
bodyDescription={ep.requestBody?.description}
|
|
190
|
+
value={state.requestBody}
|
|
191
|
+
onChange={setRequestBody}
|
|
192
|
+
isJsonValid={isJsonValid}
|
|
193
|
+
/>
|
|
219
194
|
)}
|
|
220
195
|
|
|
221
196
|
{/* Auth & Headers — collapsed by default */}
|
|
@@ -273,7 +248,8 @@ export function RequestPanel() {
|
|
|
273
248
|
</div>
|
|
274
249
|
</CollapsibleSection>
|
|
275
250
|
|
|
276
|
-
{/* cURL — collapsed by default
|
|
251
|
+
{/* cURL — collapsed by default. PrettyCode has its own
|
|
252
|
+
hover-toolbar with Copy, so no duplicate action here. */}
|
|
277
253
|
{hasCurl && (
|
|
278
254
|
<CollapsibleSection
|
|
279
255
|
label={
|
|
@@ -282,30 +258,141 @@ export function RequestPanel() {
|
|
|
282
258
|
cURL
|
|
283
259
|
</span>
|
|
284
260
|
}
|
|
285
|
-
action={
|
|
286
|
-
<CopyButton value={curlCommand} variant="ghost" size="sm" className="h-5 px-2 text-[10px] text-muted-foreground">
|
|
287
|
-
Copy
|
|
288
|
-
</CopyButton>
|
|
289
|
-
}
|
|
290
261
|
>
|
|
291
|
-
<div className="
|
|
292
|
-
<PrettyCode
|
|
262
|
+
<div className="mt-2">
|
|
263
|
+
<PrettyCode
|
|
264
|
+
data={curlCommand}
|
|
265
|
+
language="bash"
|
|
266
|
+
isCompact
|
|
267
|
+
maxLines={50}
|
|
268
|
+
/>
|
|
293
269
|
</div>
|
|
294
270
|
</CollapsibleSection>
|
|
295
271
|
)}
|
|
296
272
|
|
|
297
|
-
|
|
273
|
+
{/* Bottom breathing room — the Send footer lives outside
|
|
274
|
+
this component (in SlideInPlayground / TryItSheet),
|
|
275
|
+
so we just leave a little space so the last section
|
|
276
|
+
doesn't crash against the container edge. */}
|
|
277
|
+
<div className="h-4" />
|
|
298
278
|
</ScrollArea>
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
299
282
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
283
|
+
// ─── Body section ─────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
interface BodySectionProps {
|
|
286
|
+
schema: Record<string, unknown> | undefined;
|
|
287
|
+
bodyType: string;
|
|
288
|
+
bodyDescription?: string;
|
|
289
|
+
/** JSON-serialised body kept in context. Form edits are re-serialised
|
|
290
|
+
* before being written back so one source of truth survives across
|
|
291
|
+
* the two view modes. */
|
|
292
|
+
value: string;
|
|
293
|
+
onChange: (raw: string) => void;
|
|
294
|
+
isJsonValid: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
type BodyViewMode = 'form' | 'json';
|
|
298
|
+
|
|
299
|
+
function BodySection({ schema, bodyType, bodyDescription, value, onChange, isJsonValid }: BodySectionProps) {
|
|
300
|
+
// Default to form view when we have a schema to drive it. Fall back
|
|
301
|
+
// to raw JSON for schemaless endpoints (or binary bodies, etc.).
|
|
302
|
+
const hasSchema = !!schema;
|
|
303
|
+
const [mode, setMode] = React.useState<BodyViewMode>(hasSchema ? 'form' : 'json');
|
|
304
|
+
|
|
305
|
+
// Parse the context's JSON string once per value change so the form
|
|
306
|
+
// sees a structured object. Invalid JSON is tolerated — the form
|
|
307
|
+
// simply shows empty fields until the user fixes it.
|
|
308
|
+
const parsed = React.useMemo(() => {
|
|
309
|
+
if (!value) return null;
|
|
310
|
+
try { return JSON.parse(value); } catch { return null; }
|
|
311
|
+
}, [value]);
|
|
312
|
+
|
|
313
|
+
const handleFormChange = useCallback(
|
|
314
|
+
(next: unknown) => {
|
|
315
|
+
onChange(JSON.stringify(next, null, 2));
|
|
316
|
+
},
|
|
317
|
+
[onChange],
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div className="space-y-2">
|
|
322
|
+
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
323
|
+
<div className="flex items-baseline gap-2 min-w-0">
|
|
324
|
+
<SectionLabel>Body</SectionLabel>
|
|
325
|
+
{bodyType && (
|
|
326
|
+
<span className="text-[10px] text-muted-foreground/40 font-mono">{bodyType}</span>
|
|
327
|
+
)}
|
|
328
|
+
{bodyDescription && (
|
|
329
|
+
<span className="text-[10px] text-muted-foreground/60 truncate">{bodyDescription}</span>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
{hasSchema && (
|
|
333
|
+
<div className="inline-flex rounded-md border overflow-hidden text-[10px]">
|
|
334
|
+
<ModeButton active={mode === 'form'} onClick={() => setMode('form')}>Form</ModeButton>
|
|
335
|
+
<ModeButton active={mode === 'json'} onClick={() => setMode('json')}>JSON</ModeButton>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
{mode === 'json' && isJsonValid && value && (
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => {
|
|
342
|
+
try { onChange(JSON.stringify(JSON.parse(value), null, 2)); } catch { /* noop */ }
|
|
343
|
+
}}
|
|
344
|
+
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
345
|
+
>
|
|
346
|
+
<Sparkles className="h-2.5 w-2.5" />
|
|
347
|
+
Format
|
|
348
|
+
</button>
|
|
349
|
+
)}
|
|
308
350
|
</div>
|
|
309
|
-
|
|
351
|
+
|
|
352
|
+
{mode === 'form' && hasSchema ? (
|
|
353
|
+
<BodyFormEditor
|
|
354
|
+
schema={schema as Record<string, unknown>}
|
|
355
|
+
value={parsed}
|
|
356
|
+
onChange={handleFormChange}
|
|
357
|
+
/>
|
|
358
|
+
) : (
|
|
359
|
+
<>
|
|
360
|
+
<Textarea
|
|
361
|
+
placeholder={'{\n "key": "value"\n}'}
|
|
362
|
+
value={value}
|
|
363
|
+
onChange={(e) => onChange(e.target.value)}
|
|
364
|
+
className={cn(
|
|
365
|
+
'font-mono text-[11px] min-h-[90px] resize-y',
|
|
366
|
+
!isJsonValid && 'border-destructive focus-visible:ring-destructive/30',
|
|
367
|
+
)}
|
|
368
|
+
rows={4}
|
|
369
|
+
/>
|
|
370
|
+
{!isJsonValid && <p className="text-[10px] text-destructive">Invalid JSON</p>}
|
|
371
|
+
</>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function ModeButton({
|
|
378
|
+
active, onClick, children,
|
|
379
|
+
}: {
|
|
380
|
+
active: boolean;
|
|
381
|
+
onClick: () => void;
|
|
382
|
+
children: React.ReactNode;
|
|
383
|
+
}) {
|
|
384
|
+
return (
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
onClick={onClick}
|
|
388
|
+
className={cn(
|
|
389
|
+
'px-2 py-0.5 font-medium transition-colors',
|
|
390
|
+
active
|
|
391
|
+
? 'bg-primary/10 text-foreground'
|
|
392
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
393
|
+
)}
|
|
394
|
+
>
|
|
395
|
+
{children}
|
|
396
|
+
</button>
|
|
310
397
|
);
|
|
311
398
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertCircle, Loader2, Send } from 'lucide-react';
|
|
4
|
+
import React, { useMemo } from 'react';
|
|
5
|
+
|
|
6
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
|
|
9
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
10
|
+
import { isValidJson } from '../../utils';
|
|
11
|
+
import { UrlBuilder } from '../../utils/url';
|
|
12
|
+
|
|
13
|
+
interface SendButtonProps {
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Standalone Send button that reads from and acts on the playground
|
|
19
|
+
* context. Disabled + explainer-banner when required inputs are missing.
|
|
20
|
+
*
|
|
21
|
+
* Validation rules (in order — first failure wins the banner text):
|
|
22
|
+
* 1. Required path/query parameters must be non-empty.
|
|
23
|
+
* 2. Path template must be fully substituted (no leftover ``{name}``).
|
|
24
|
+
* 3. Request body, when present, must be valid JSON.
|
|
25
|
+
*
|
|
26
|
+
* We validate on the client so the user isn't punished with a 404 from
|
|
27
|
+
* the server for a trivially detectable mistake (e.g. empty ``{petId}``).
|
|
28
|
+
*/
|
|
29
|
+
export function SendButton({ className }: SendButtonProps) {
|
|
30
|
+
const { state, sendRequest } = usePlaygroundContext();
|
|
31
|
+
|
|
32
|
+
const ep = state.selectedEndpoint;
|
|
33
|
+
|
|
34
|
+
// Single source of truth for URL-level validation lives in UrlBuilder.
|
|
35
|
+
// Recomputing every render is cheap (pure string work).
|
|
36
|
+
const builder = useMemo(
|
|
37
|
+
() => (ep ? new UrlBuilder(ep, state.parameters) : null),
|
|
38
|
+
[ep, state.parameters],
|
|
39
|
+
);
|
|
40
|
+
const missingRequired = builder?.missingRequired() ?? [];
|
|
41
|
+
const unsubstituted = builder?.unfilledPlaceholders() ?? [];
|
|
42
|
+
|
|
43
|
+
const isJsonValid = state.requestBody ? isValidJson(state.requestBody) : true;
|
|
44
|
+
|
|
45
|
+
const blockers: string[] = [];
|
|
46
|
+
if (missingRequired.length > 0) {
|
|
47
|
+
blockers.push(
|
|
48
|
+
`Fill required parameter${missingRequired.length > 1 ? 's' : ''}: ${missingRequired.join(', ')}`,
|
|
49
|
+
);
|
|
50
|
+
} else if (unsubstituted.length > 0) {
|
|
51
|
+
blockers.push(`URL still has unfilled placeholder${unsubstituted.length > 1 ? 's' : ''}: ${unsubstituted.map((n) => `{${n}}`).join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
if (!isJsonValid) blockers.push('Request body is not valid JSON');
|
|
54
|
+
|
|
55
|
+
const disabled =
|
|
56
|
+
state.loading ||
|
|
57
|
+
!state.requestUrl ||
|
|
58
|
+
blockers.length > 0;
|
|
59
|
+
|
|
60
|
+
const tooltip = blockers.length > 0 ? blockers.join('\n') : undefined;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className={cn('space-y-2', className)}>
|
|
64
|
+
{blockers.length > 0 && (
|
|
65
|
+
<div className="flex items-start gap-2 rounded-md border border-amber-500/25 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-600 dark:text-amber-400">
|
|
66
|
+
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-px" />
|
|
67
|
+
<span className="leading-snug">{blockers[0]}</span>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
<Button
|
|
71
|
+
onClick={sendRequest}
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
size="sm"
|
|
74
|
+
title={tooltip}
|
|
75
|
+
className="w-full gap-2 h-9"
|
|
76
|
+
>
|
|
77
|
+
{state.loading ? (
|
|
78
|
+
<>
|
|
79
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
80
|
+
Sending…
|
|
81
|
+
</>
|
|
82
|
+
) : (
|
|
83
|
+
<>
|
|
84
|
+
<Send className="h-3.5 w-3.5" />
|
|
85
|
+
Send Request
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
</Button>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Shared primitive UI components used
|
|
4
|
+
* Shared primitive UI components used by the playground panels
|
|
5
|
+
* (Request, Response) in both the slide-in and mobile-sheet surfaces.
|
|
5
6
|
* Keep this file free of any business logic or context reads.
|
|
6
7
|
*/
|
|
7
8
|
|
|
@@ -33,9 +34,9 @@ export function getStatusStyle(status: number): string {
|
|
|
33
34
|
return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25';
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
37
|
+
// ``relativePath`` lives in utils/url.ts now — re-exported here so every
|
|
38
|
+
// component that used to import it from ``./ui`` keeps working.
|
|
39
|
+
export { relativePath } from '../../utils/url';
|
|
39
40
|
|
|
40
41
|
// ─── Atoms ────────────────────────────────────────────────────────────────────
|
|
41
42
|
|
|
@@ -5,13 +5,21 @@ import React, {
|
|
|
5
5
|
createContext, ReactNode, useCallback, useContext, useEffect, useReducer, useRef
|
|
6
6
|
} from 'react';
|
|
7
7
|
|
|
8
|
+
import { useSessionStorage } from '@djangocfg/ui-core/hooks';
|
|
9
|
+
|
|
8
10
|
import type {
|
|
9
11
|
ApiEndpoint, ApiResponse, PlaygroundConfig, PlaygroundContextType, PlaygroundState,
|
|
10
12
|
PlaygroundStep
|
|
11
13
|
} from '../types';
|
|
12
|
-
import { parseRequestHeaders
|
|
14
|
+
import { parseRequestHeaders } from '../utils';
|
|
15
|
+
import { UrlBuilder } from '../utils/url';
|
|
13
16
|
import { getDefaultVersion } from '../utils/versionManager';
|
|
14
17
|
|
|
18
|
+
// Session-scoped auth persistence. sessionStorage (not localStorage) so
|
|
19
|
+
// the token dies when the browser tab closes — safer default for secrets.
|
|
20
|
+
const AUTH_KEY_STORAGE = 'openapi-playground:auth:apiKeyId';
|
|
21
|
+
const AUTH_BEARER_STORAGE = 'openapi-playground:auth:bearer';
|
|
22
|
+
|
|
15
23
|
// ─── Initial state ────────────────────────────────────────────────────────────
|
|
16
24
|
|
|
17
25
|
const createInitialState = (): PlaygroundState => ({
|
|
@@ -31,6 +39,7 @@ const createInitialState = (): PlaygroundState => ({
|
|
|
31
39
|
response: null,
|
|
32
40
|
loading: false,
|
|
33
41
|
sidebarOpen: false,
|
|
42
|
+
activeSchemaId: null,
|
|
34
43
|
});
|
|
35
44
|
|
|
36
45
|
// ─── Actions ──────────────────────────────────────────────────────────────────
|
|
@@ -59,6 +68,7 @@ type Action =
|
|
|
59
68
|
// Batched: set error response + loading=false atomically
|
|
60
69
|
| { type: 'REQUEST_ERROR'; response: ApiResponse }
|
|
61
70
|
| { type: 'SET_SIDEBAR'; open: boolean }
|
|
71
|
+
| { type: 'SET_ACTIVE_SCHEMA_ID'; id: string | null }
|
|
62
72
|
| { type: 'SYNC_API_KEY_HEADER'; headers: string }
|
|
63
73
|
| { type: 'CLEAR_API_KEY_SELECTION' }
|
|
64
74
|
| { type: 'SYNC_URL'; url: string }
|
|
@@ -83,16 +93,34 @@ function reducer(state: PlaygroundState, action: Action): PlaygroundState {
|
|
|
83
93
|
return i > 0 ? { ...state, currentStep: state.steps[i - 1]! } : state;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
case 'SELECT_ENDPOINT':
|
|
96
|
+
case 'SELECT_ENDPOINT': {
|
|
87
97
|
if (!action.endpoint) return { ...state, selectedEndpoint: null };
|
|
98
|
+
// Guard: selecting the same endpoint is a no-op for response state.
|
|
99
|
+
// Without this, clicking "Try it" on the already-loaded endpoint
|
|
100
|
+
// would wipe its response for no reason.
|
|
101
|
+
const same =
|
|
102
|
+
state.selectedEndpoint?.method === action.endpoint.method &&
|
|
103
|
+
state.selectedEndpoint?.path === action.endpoint.path;
|
|
104
|
+
// Pre-fill request body from the OpenAPI example when available.
|
|
105
|
+
// For a brand-new endpoint selection, a realistic example is more
|
|
106
|
+
// useful than the {"key":"value"} placeholder. If the user already
|
|
107
|
+
// has a saved draft, EndpointDraftSync will overwrite this with the
|
|
108
|
+
// persisted value once it hydrates.
|
|
109
|
+
const exampleBody = action.endpoint.requestBody?.example ?? '';
|
|
88
110
|
return {
|
|
89
111
|
...state,
|
|
90
112
|
selectedEndpoint: action.endpoint,
|
|
91
113
|
requestMethod: action.endpoint.method,
|
|
92
114
|
requestUrl: action.endpoint.path,
|
|
93
|
-
parameters: {},
|
|
115
|
+
parameters: same ? state.parameters : {},
|
|
116
|
+
requestBody: same ? state.requestBody : exampleBody,
|
|
117
|
+
// Switching to a different endpoint: the previous response no
|
|
118
|
+
// longer belongs here. Clear it so the playground panel collapses
|
|
119
|
+
// back to single-column until the user sends a new request.
|
|
120
|
+
response: same ? state.response : null,
|
|
94
121
|
currentStep: 'request',
|
|
95
122
|
};
|
|
123
|
+
}
|
|
96
124
|
|
|
97
125
|
case 'SET_CATEGORY': return { ...state, selectedCategory: action.category };
|
|
98
126
|
case 'SET_SEARCH': return { ...state, searchTerm: action.term };
|
|
@@ -117,6 +145,7 @@ function reducer(state: PlaygroundState, action: Action): PlaygroundState {
|
|
|
117
145
|
return { ...state, loading: false, response: action.response };
|
|
118
146
|
|
|
119
147
|
case 'SET_SIDEBAR': return { ...state, sidebarOpen: action.open };
|
|
148
|
+
case 'SET_ACTIVE_SCHEMA_ID': return { ...state, activeSchemaId: action.id };
|
|
120
149
|
case 'SYNC_API_KEY_HEADER': return { ...state, requestHeaders: action.headers };
|
|
121
150
|
case 'CLEAR_API_KEY_SELECTION': return { ...state, selectedApiKey: null };
|
|
122
151
|
case 'SYNC_URL': return { ...state, requestUrl: action.url };
|
|
@@ -156,12 +185,52 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
|
|
|
156
185
|
);
|
|
157
186
|
const isLoadingApiKeys = config.apiKeysLoading ?? false;
|
|
158
187
|
|
|
159
|
-
//
|
|
188
|
+
// ── Auth persistence (session-scoped) ─────────────────────────────────────
|
|
189
|
+
// Use sessionStorage so the chosen API key / bearer token survive reload
|
|
190
|
+
// within the same tab but die when the tab closes. That matches how
|
|
191
|
+
// users expect auth sessions to work and keeps secrets out of localStorage.
|
|
192
|
+
const [storedApiKeyId, setStoredApiKeyId] = useSessionStorage<string | null>(
|
|
193
|
+
AUTH_KEY_STORAGE,
|
|
194
|
+
null,
|
|
195
|
+
);
|
|
196
|
+
const [storedBearer, setStoredBearer] = useSessionStorage<string>(
|
|
197
|
+
AUTH_BEARER_STORAGE,
|
|
198
|
+
'',
|
|
199
|
+
);
|
|
200
|
+
const hasHydratedAuthRef = useRef(false);
|
|
201
|
+
|
|
202
|
+
// Hydrate auth state from sessionStorage exactly once.
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (hasHydratedAuthRef.current) return;
|
|
205
|
+
hasHydratedAuthRef.current = true;
|
|
206
|
+
if (storedApiKeyId) dispatch({ type: 'SET_API_KEY', apiKeyId: storedApiKeyId });
|
|
207
|
+
if (storedBearer) dispatch({ type: 'SET_MANUAL_TOKEN', token: storedBearer });
|
|
208
|
+
// We intentionally don't depend on the stored values — this effect
|
|
209
|
+
// runs once on mount; later changes are written out by the effects
|
|
210
|
+
// below, not re-hydrated.
|
|
211
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
// Persist selection → sessionStorage as it changes.
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (!hasHydratedAuthRef.current) return;
|
|
217
|
+
setStoredApiKeyId(state.selectedApiKey);
|
|
218
|
+
}, [state.selectedApiKey, setStoredApiKeyId]);
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (!hasHydratedAuthRef.current) return;
|
|
222
|
+
setStoredBearer(state.manualApiToken);
|
|
223
|
+
}, [state.manualApiToken, setStoredBearer]);
|
|
224
|
+
|
|
225
|
+
// Auto-select first API key — only when there's no persisted selection
|
|
226
|
+
// to restore. Otherwise the first-render auto-pick would clobber a
|
|
227
|
+
// session that had a non-first key chosen.
|
|
160
228
|
useEffect(() => {
|
|
161
|
-
if (!
|
|
229
|
+
if (!hasHydratedAuthRef.current) return;
|
|
230
|
+
if (!isLoadingApiKeys && apiKeys.length > 0 && !state.selectedApiKey && !storedApiKeyId) {
|
|
162
231
|
dispatch({ type: 'SET_API_KEY', apiKeyId: apiKeys[0]?.id || null });
|
|
163
232
|
}
|
|
164
|
-
}, [apiKeys, isLoadingApiKeys, state.selectedApiKey]);
|
|
233
|
+
}, [apiKeys, isLoadingApiKeys, state.selectedApiKey, storedApiKeyId]);
|
|
165
234
|
|
|
166
235
|
// Sync X-API-Key header when selected key changes
|
|
167
236
|
useEffect(() => {
|
|
@@ -191,10 +260,13 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
|
|
|
191
260
|
}
|
|
192
261
|
}, [state.selectedApiKey, apiKeys]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
193
262
|
|
|
194
|
-
// Sync URL when path parameters change
|
|
263
|
+
// Sync URL when path parameters or query parameters change. UrlBuilder
|
|
264
|
+
// handles BOTH path substitution and query string assembly — the old
|
|
265
|
+
// implementation only did the former, which silently dropped every
|
|
266
|
+
// non-path parameter the user entered.
|
|
195
267
|
useEffect(() => {
|
|
196
268
|
if (!state.selectedEndpoint) return;
|
|
197
|
-
const updated =
|
|
269
|
+
const updated = new UrlBuilder(state.selectedEndpoint, state.parameters).build();
|
|
198
270
|
if (updated !== state.requestUrl) {
|
|
199
271
|
dispatch({ type: 'SYNC_URL', url: updated });
|
|
200
272
|
}
|
|
@@ -226,6 +298,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
|
|
|
226
298
|
dispatch({ type: 'SET_RESPONSE', response }), []);
|
|
227
299
|
const setLoading = useCallback((loading: boolean) => dispatch({ type: 'SET_LOADING', loading }), []);
|
|
228
300
|
const setSidebarOpen = useCallback((open: boolean) => dispatch({ type: 'SET_SIDEBAR', open }), []);
|
|
301
|
+
const setActiveSchemaId = useCallback((id: string | null) => dispatch({ type: 'SET_ACTIVE_SCHEMA_ID', id }), []);
|
|
229
302
|
const clearAll = useCallback(() => dispatch({ type: 'RESET' }), []);
|
|
230
303
|
|
|
231
304
|
// ── Send request ──────────────────────────────────────────────────────────
|
|
@@ -327,6 +400,7 @@ export const PlaygroundProvider: React.FC<PlaygroundProviderProps> = ({ children
|
|
|
327
400
|
setResponse,
|
|
328
401
|
setLoading,
|
|
329
402
|
setSidebarOpen,
|
|
403
|
+
setActiveSchemaId,
|
|
330
404
|
clearAll,
|
|
331
405
|
sendRequest,
|
|
332
406
|
};
|