@djangocfg/ui-tools 2.1.284 → 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.
Files changed (71) hide show
  1. package/dist/DocsLayout-BCVU6TTX.cjs +2027 -0
  2. package/dist/DocsLayout-BCVU6TTX.cjs.map +1 -0
  3. package/dist/DocsLayout-ERETJLLV.mjs +2020 -0
  4. package/dist/DocsLayout-ERETJLLV.mjs.map +1 -0
  5. package/dist/{PlaygroundLayout-O52C6HK5.css → DocsLayout-MBFIB4NO.css} +1 -1
  6. package/dist/{PrettyCode.client-SGDGQTYT.cjs → PrettyCode.client-5GABIN2I.cjs} +57 -35
  7. package/dist/PrettyCode.client-5GABIN2I.cjs.map +1 -0
  8. package/dist/{PrettyCode.client-DW5LTG47.mjs → PrettyCode.client-IZTXXYHG.mjs} +57 -35
  9. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +1 -0
  10. package/dist/chunk-IULI4XII.cjs +1129 -0
  11. package/dist/chunk-IULI4XII.cjs.map +1 -0
  12. package/dist/chunk-VZGQC3NG.mjs +1100 -0
  13. package/dist/chunk-VZGQC3NG.mjs.map +1 -0
  14. package/dist/index.cjs +88 -552
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +18 -6
  17. package/dist/index.d.ts +18 -6
  18. package/dist/index.mjs +25 -496
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +6 -6
  21. package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +4 -0
  22. package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
  23. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
  24. package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
  25. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
  26. package/src/tools/OpenapiViewer/README.md +104 -51
  27. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
  28. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
  29. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
  30. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
  31. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
  32. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
  33. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
  34. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
  35. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
  39. package/src/tools/OpenapiViewer/components/index.ts +5 -2
  40. package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
  41. package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
  42. package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
  43. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
  44. package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
  45. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
  46. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
  47. package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
  48. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
  49. package/src/tools/OpenapiViewer/index.tsx +3 -7
  50. package/src/tools/OpenapiViewer/lazy.tsx +6 -27
  51. package/src/tools/OpenapiViewer/types.ts +44 -0
  52. package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
  53. package/src/tools/OpenapiViewer/utils/index.ts +3 -1
  54. package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
  55. package/src/tools/OpenapiViewer/utils/url.ts +202 -0
  56. package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
  57. package/src/tools/PrettyCode/index.tsx +6 -0
  58. package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
  59. package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
  60. package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
  61. package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
  62. package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
  63. package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
  64. package/dist/chunk-5FKE7OME.cjs +0 -369
  65. package/dist/chunk-5FKE7OME.cjs.map +0 -1
  66. package/dist/chunk-BKWDHJKF.mjs +0 -356
  67. package/dist/chunk-BKWDHJKF.mjs.map +0 -1
  68. package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
  69. package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
  70. /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
  71. /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
@@ -1,13 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { Key, Loader2, Send, Sparkles, Terminal } from 'lucide-react';
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} "${state.requestUrl}"`;
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
- const displayUrl = state.requestUrl || ep?.path || '';
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
- const urlChanged = displayUrl !== epPath;
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
- {/* Endpoint header */}
153
- <div className="shrink-0 border-b px-4 py-3 bg-muted/20 space-y-1.5">
154
- <div className="flex items-center gap-2">
155
- <MethodBadge method={ep.method} />
156
- <span className="font-mono text-xs text-foreground/70 truncate min-w-0 flex-1">{epPath}</span>
157
- <Button
158
- onClick={sendRequest}
159
- disabled={isSendDisabled}
160
- size="sm"
161
- className="shrink-0 gap-1.5 h-7 text-xs px-3"
162
- >
163
- {state.loading
164
- ? <><Loader2 className="h-3 w-3 animate-spin" /> Sending…</>
165
- : <><Send className="h-3 w-3" /> Send</>
166
- }
167
- </Button>
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
- {urlChanged && (
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
- <div className="space-y-1.5">
185
- <div className="flex items-center justify-between gap-2">
186
- <div className="flex items-baseline gap-2">
187
- <SectionLabel>Body</SectionLabel>
188
- {bodyType && (
189
- <span className="text-[10px] text-muted-foreground/40 font-mono">{bodyType}</span>
190
- )}
191
- </div>
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="rounded-md overflow-hidden mt-2">
292
- <PrettyCode data={curlCommand} language="bash" isCompact />
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
- <div className="h-1" />
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
- {/* Send footer */}
301
- <div className="shrink-0 border-t px-4 py-3 bg-background/95 backdrop-blur-sm">
302
- <Button onClick={sendRequest} disabled={isSendDisabled} size="sm" className="w-full gap-2 h-9">
303
- {state.loading
304
- ? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> Sending…</>
305
- : <><Send className="h-3.5 w-3.5" /> Send Request</>
306
- }
307
- </Button>
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 across all PlaygroundLayout panels.
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
- export function relativePath(full: string): string {
37
- try { return new URL(full).pathname; } catch { return full; }
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, substituteUrlParameters } from '../utils';
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
- // Auto-select first API key
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 (!isLoadingApiKeys && apiKeys.length > 0 && !state.selectedApiKey) {
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 = substituteUrlParameters(state.selectedEndpoint.path, state.parameters);
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
  };