@djangocfg/ui-tools 2.1.285 → 2.1.287

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 (79) 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 +6 -0
  22. package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
  23. package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
  24. package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
  25. package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
  26. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
  27. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
  28. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
  29. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
  30. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
  31. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +5 -0
  32. package/src/tools/OpenapiViewer/.claude/project-map.md +23 -0
  33. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +28 -2
  34. package/src/tools/OpenapiViewer/README.md +104 -51
  35. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +64 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +137 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +268 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +139 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +211 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +101 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +57 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +11 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +71 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +166 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/schemaFields.ts +121 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/sidebarLabel.ts +60 -0
  47. package/src/tools/OpenapiViewer/components/index.ts +5 -2
  48. package/src/tools/OpenapiViewer/components/shared/BodyFormEditor.tsx +422 -0
  49. package/src/tools/OpenapiViewer/components/shared/EndpointDraftSync.tsx +108 -0
  50. package/src/tools/OpenapiViewer/components/shared/EndpointResetButton.tsx +50 -0
  51. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/RequestPanel.tsx +174 -87
  52. package/src/tools/OpenapiViewer/components/shared/SendButton.tsx +91 -0
  53. package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ui.tsx +5 -4
  54. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +82 -8
  55. package/src/tools/OpenapiViewer/hooks/useEndpointDraft.ts +142 -0
  56. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +126 -13
  57. package/src/tools/OpenapiViewer/index.tsx +3 -7
  58. package/src/tools/OpenapiViewer/lazy.tsx +6 -27
  59. package/src/tools/OpenapiViewer/types.ts +44 -0
  60. package/src/tools/OpenapiViewer/utils/formatters.ts +2 -23
  61. package/src/tools/OpenapiViewer/utils/index.ts +3 -1
  62. package/src/tools/OpenapiViewer/utils/schemaExport.ts +206 -0
  63. package/src/tools/OpenapiViewer/utils/url.ts +202 -0
  64. package/src/tools/PrettyCode/PrettyCode.client.tsx +42 -8
  65. package/src/tools/PrettyCode/index.tsx +6 -0
  66. package/dist/PlaygroundLayout-DHUATCHB.cjs +0 -798
  67. package/dist/PlaygroundLayout-DHUATCHB.cjs.map +0 -1
  68. package/dist/PlaygroundLayout-NONWOVQR.mjs +0 -791
  69. package/dist/PlaygroundLayout-NONWOVQR.mjs.map +0 -1
  70. package/dist/PrettyCode.client-DW5LTG47.mjs.map +0 -1
  71. package/dist/PrettyCode.client-SGDGQTYT.cjs.map +0 -1
  72. package/dist/chunk-5FKE7OME.cjs +0 -369
  73. package/dist/chunk-5FKE7OME.cjs.map +0 -1
  74. package/dist/chunk-BKWDHJKF.mjs +0 -356
  75. package/dist/chunk-BKWDHJKF.mjs.map +0 -1
  76. package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +0 -228
  77. package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +0 -107
  78. /package/dist/{PlaygroundLayout-O52C6HK5.css.map → DocsLayout-MBFIB4NO.css.map} +0 -0
  79. /package/src/tools/OpenapiViewer/components/{PlaygroundLayout → shared}/ResponsePanel.tsx +0 -0
@@ -0,0 +1,108 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef } from 'react';
4
+
5
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
6
+ import { useEndpointDraft } from '../../hooks/useEndpointDraft';
7
+
8
+ interface EndpointDraftSyncProps {
9
+ /** Active schema id (so drafts don't bleed between different APIs).
10
+ * When null — drafts are disabled (hook returns empty draft, writes
11
+ * are no-ops). Pass the current schema's ``id`` from useOpenApiSchema. */
12
+ schemaId: string | null;
13
+ }
14
+
15
+ /**
16
+ * Headless component: keeps ``RequestPanel`` state mirrored with a
17
+ * per-endpoint draft in localStorage.
18
+ *
19
+ * Flow:
20
+ * 1. On endpoint change ⇒ read draft ⇒ push into context state.
21
+ * 2. On context state change (user edits params/body) ⇒ write draft.
22
+ *
23
+ * Step 1 is gated by a ref so "just-loaded" drafts don't immediately
24
+ * trigger step 2. Step 2 additionally compares against a serialised
25
+ * snapshot of the last persisted value so a re-render without a real
26
+ * value change doesn't touch storage.
27
+ *
28
+ * Had an infinite-render-loop bug here earlier: the persist callbacks
29
+ * were driven by ``useLocalStorage`` whose setter identity changed on
30
+ * every internal state update, so depending on them from an effect
31
+ * created a cycle. Fixed by making ``useEndpointDraft``'s writers
32
+ * stable (ref-based), and by gating writes with value comparison below.
33
+ */
34
+ export function EndpointDraftSync({ schemaId }: EndpointDraftSyncProps) {
35
+ const { state, setParameters, setRequestBody, setActiveSchemaId } = usePlaygroundContext();
36
+ const ep = state.selectedEndpoint;
37
+
38
+ // Mirror schemaId into context so other components (e.g. the Reset
39
+ // button in RequestPanel) can read it without receiving props from
40
+ // a parent they don't own.
41
+ useEffect(() => {
42
+ setActiveSchemaId(schemaId);
43
+ }, [schemaId, setActiveSchemaId]);
44
+
45
+ const { draft, setParameters: persistParams, setRequestBody: persistBody } =
46
+ useEndpointDraft(schemaId, ep);
47
+
48
+ const lastLoadedKeyRef = useRef<string | null>(null);
49
+ const lastPersistedParamsRef = useRef<string>('');
50
+ const lastPersistedBodyRef = useRef<string>('');
51
+ const currentKey = ep ? `${ep.method}|${ep.path}` : null;
52
+
53
+ // Step 1 — hydrate context from draft on endpoint switch.
54
+ //
55
+ // IMPORTANT: only apply fields that actually exist in the saved draft.
56
+ // SELECT_ENDPOINT in the reducer pre-fills ``requestBody`` with an
57
+ // auto-generated schema example, and ``parameters`` with ``{}``.
58
+ // If we blindly overwrite those with an empty draft we wipe the
59
+ // example before the user ever sees it. Tracked via a bug: opening
60
+ // POST /pet showed ``{"key":"value"}`` instead of the Pet example.
61
+ useEffect(() => {
62
+ if (!ep || !currentKey) {
63
+ lastLoadedKeyRef.current = null;
64
+ return;
65
+ }
66
+ if (lastLoadedKeyRef.current === currentKey) return;
67
+ lastLoadedKeyRef.current = currentKey;
68
+
69
+ const hasStoredParams =
70
+ draft.parameters && Object.keys(draft.parameters).length > 0;
71
+ const hasStoredBody = typeof draft.requestBody === 'string' && draft.requestBody !== '';
72
+
73
+ if (hasStoredParams) {
74
+ setParameters(draft.parameters);
75
+ lastPersistedParamsRef.current = JSON.stringify(draft.parameters);
76
+ } else {
77
+ // Keep whatever the reducer put there; mirror it into the
78
+ // "last persisted" ref so step-2 treats it as a baseline.
79
+ lastPersistedParamsRef.current = JSON.stringify(state.parameters);
80
+ }
81
+
82
+ if (hasStoredBody) {
83
+ setRequestBody(draft.requestBody);
84
+ lastPersistedBodyRef.current = draft.requestBody;
85
+ } else {
86
+ lastPersistedBodyRef.current = state.requestBody;
87
+ }
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ }, [currentKey]);
90
+
91
+ // Step 2 — persist user edits.
92
+ useEffect(() => {
93
+ if (!ep || lastLoadedKeyRef.current !== currentKey) return;
94
+ const serialised = JSON.stringify(state.parameters);
95
+ if (serialised === lastPersistedParamsRef.current) return;
96
+ lastPersistedParamsRef.current = serialised;
97
+ persistParams(state.parameters);
98
+ }, [state.parameters, ep, currentKey, persistParams]);
99
+
100
+ useEffect(() => {
101
+ if (!ep || lastLoadedKeyRef.current !== currentKey) return;
102
+ if (state.requestBody === lastPersistedBodyRef.current) return;
103
+ lastPersistedBodyRef.current = state.requestBody;
104
+ persistBody(state.requestBody);
105
+ }, [state.requestBody, ep, currentKey, persistBody]);
106
+
107
+ return null;
108
+ }
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { RotateCcw } from 'lucide-react';
4
+ import React, { useCallback } from 'react';
5
+
6
+ import { cn } from '@djangocfg/ui-core/lib';
7
+
8
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
9
+ import { useEndpointDraft } from '../../hooks/useEndpointDraft';
10
+
11
+ /**
12
+ * "Reset" ghost button for the current endpoint's draft.
13
+ *
14
+ * Wipes both the in-memory context state (parameters + body) and the
15
+ * persisted localStorage entry. We call reset() *after* clearing the
16
+ * context so the EndpointDraftSync effect that mirrors state → storage
17
+ * doesn't immediately re-create a now-empty draft with an empty object
18
+ * (benign, but uglier in storage).
19
+ */
20
+ export function EndpointResetButton() {
21
+ const { state, setParameters, setRequestBody } = usePlaygroundContext();
22
+ const ep = state.selectedEndpoint;
23
+ const { reset } = useEndpointDraft(state.activeSchemaId, ep);
24
+
25
+ const hasDraft =
26
+ Object.keys(state.parameters).length > 0 || state.requestBody.length > 0;
27
+
28
+ const onClick = useCallback(() => {
29
+ setParameters({});
30
+ setRequestBody('');
31
+ reset();
32
+ }, [setParameters, setRequestBody, reset]);
33
+
34
+ if (!ep || !hasDraft) return null;
35
+
36
+ return (
37
+ <button
38
+ type="button"
39
+ onClick={onClick}
40
+ title="Reset parameters & body (keeps auth)"
41
+ className={cn(
42
+ 'inline-flex items-center gap-1 text-[10px] text-muted-foreground',
43
+ 'hover:text-foreground transition-colors',
44
+ )}
45
+ >
46
+ <RotateCcw className="h-2.5 w-2.5" />
47
+ Reset
48
+ </button>
49
+ );
50
+ }
@@ -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