@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
@@ -0,0 +1,422 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Form-based request body editor driven by JSON Schema.
5
+ *
6
+ * Replaces the ``{"key":"value"}`` textarea prompt with a real form:
7
+ * one input per property, typed widgets for primitives, nested objects
8
+ * indented, arrays with add/remove. The component is controlled — the
9
+ * parent owns the body value (as any JSON) and persists to localStorage.
10
+ *
11
+ * Intentionally not a full JSON-Schema-Form: we don't cover oneOf/anyOf,
12
+ * pattern validation, min/max — the playground just needs a usable
13
+ * interactive shape. Users who hit a corner case can flip the ``JSON``
14
+ * toggle in RequestPanel and edit raw.
15
+ */
16
+
17
+ import { Minus, Plus } from 'lucide-react';
18
+ import React, { useCallback } from 'react';
19
+
20
+ import { Combobox, Input, Switch, Textarea } from '@djangocfg/ui-core/components';
21
+ import { cn } from '@djangocfg/ui-core/lib';
22
+
23
+ import { SectionLabel } from './ui';
24
+
25
+ type JsonSchemaNode = Record<string, unknown> & {
26
+ type?: string;
27
+ properties?: Record<string, JsonSchemaNode>;
28
+ required?: string[];
29
+ items?: JsonSchemaNode;
30
+ enum?: unknown[];
31
+ description?: string;
32
+ format?: string;
33
+ };
34
+
35
+ const MAX_DEPTH = 6;
36
+
37
+ // ─── Value helpers ────────────────────────────────────────────────────────────
38
+
39
+ function defaultForSchema(schema: JsonSchemaNode | undefined): unknown {
40
+ if (!schema) return null;
41
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
42
+ switch (schema.type) {
43
+ case 'object': {
44
+ const out: Record<string, unknown> = {};
45
+ for (const [k, v] of Object.entries(schema.properties ?? {})) {
46
+ out[k] = defaultForSchema(v);
47
+ }
48
+ return out;
49
+ }
50
+ case 'array':
51
+ return [];
52
+ case 'integer':
53
+ case 'number':
54
+ return 0;
55
+ case 'boolean':
56
+ return false;
57
+ case 'string':
58
+ return '';
59
+ default:
60
+ if (schema.properties) {
61
+ const out: Record<string, unknown> = {};
62
+ for (const [k, v] of Object.entries(schema.properties)) {
63
+ out[k] = defaultForSchema(v);
64
+ }
65
+ return out;
66
+ }
67
+ return '';
68
+ }
69
+ }
70
+
71
+ // ─── Root ─────────────────────────────────────────────────────────────────────
72
+
73
+ export interface BodyFormEditorProps {
74
+ schema: JsonSchemaNode;
75
+ value: unknown;
76
+ onChange: (next: unknown) => void;
77
+ }
78
+
79
+ export function BodyFormEditor({ schema, value, onChange }: BodyFormEditorProps) {
80
+ return (
81
+ <SchemaField
82
+ schema={schema}
83
+ value={value}
84
+ onChange={onChange}
85
+ depth={0}
86
+ required={false}
87
+ />
88
+ );
89
+ }
90
+
91
+ // ─── Recursive renderer ───────────────────────────────────────────────────────
92
+
93
+ interface SchemaFieldProps {
94
+ schema: JsonSchemaNode;
95
+ value: unknown;
96
+ onChange: (next: unknown) => void;
97
+ depth: number;
98
+ required: boolean;
99
+ label?: string;
100
+ }
101
+
102
+ function SchemaField({ schema, value, onChange, depth, required, label }: SchemaFieldProps) {
103
+ // Depth cutoff: collapse further nesting into a raw JSON textarea —
104
+ // deeper forms get impossible to navigate and lose value for the UX
105
+ // we're trying to offer (quick exploration).
106
+ if (depth > MAX_DEPTH) {
107
+ return <RawJsonField label={label} value={value} onChange={onChange} />;
108
+ }
109
+
110
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
111
+ return <EnumField schema={schema} value={value} onChange={onChange} label={label} required={required} />;
112
+ }
113
+
114
+ switch (schema.type) {
115
+ case 'object':
116
+ return <ObjectField schema={schema} value={value} onChange={onChange} depth={depth} label={label} />;
117
+ case 'array':
118
+ return <ArrayField schema={schema} value={value} onChange={onChange} depth={depth} label={label} required={required} />;
119
+ case 'boolean':
120
+ return <BooleanField value={value} onChange={onChange} label={label} schema={schema} required={required} />;
121
+ case 'integer':
122
+ case 'number':
123
+ return <NumberField value={value} onChange={onChange} label={label} schema={schema} required={required} />;
124
+ case 'string':
125
+ default:
126
+ // Untyped / string-ish — plain text input. Covers the
127
+ // "body is a free-form string" case too (e.g. text/plain).
128
+ if (!schema.type && schema.properties) {
129
+ return <ObjectField schema={schema} value={value} onChange={onChange} depth={depth} label={label} />;
130
+ }
131
+ return <StringField value={value} onChange={onChange} label={label} schema={schema} required={required} />;
132
+ }
133
+ }
134
+
135
+ // ─── Primitive widgets ────────────────────────────────────────────────────────
136
+
137
+ function FieldHeader({
138
+ label,
139
+ type,
140
+ required,
141
+ description,
142
+ }: {
143
+ label?: string;
144
+ type: string;
145
+ required: boolean;
146
+ description?: string;
147
+ }) {
148
+ if (!label) return null;
149
+ return (
150
+ <div className="space-y-0.5">
151
+ <div className="flex items-baseline gap-1.5">
152
+ <span className="font-mono text-[11px] text-foreground/80">{label}</span>
153
+ {required && <span className="text-[9px] text-destructive font-bold leading-none">*</span>}
154
+ <span className="font-mono text-[10px] text-muted-foreground/50">{type}</span>
155
+ </div>
156
+ {description && (
157
+ <p className="text-[10px] text-muted-foreground/70 leading-snug">{description}</p>
158
+ )}
159
+ </div>
160
+ );
161
+ }
162
+
163
+ function StringField({
164
+ value, onChange, label, schema, required,
165
+ }: {
166
+ value: unknown;
167
+ onChange: (next: string) => void;
168
+ label?: string;
169
+ schema: JsonSchemaNode;
170
+ required: boolean;
171
+ }) {
172
+ const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value);
173
+ const placeholder = schema.format ? `${schema.type ?? 'string'} (${schema.format})` : schema.description || schema.type || 'string';
174
+ return (
175
+ <div className="space-y-1">
176
+ <FieldHeader label={label} type={schema.format ? `string (${schema.format})` : 'string'} required={required} description={schema.description} />
177
+ <Input
178
+ value={stringValue}
179
+ onChange={(e) => onChange(e.target.value)}
180
+ placeholder={placeholder}
181
+ className="h-8 text-xs font-mono"
182
+ />
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function NumberField({
188
+ value, onChange, label, schema, required,
189
+ }: {
190
+ value: unknown;
191
+ onChange: (next: number | null) => void;
192
+ label?: string;
193
+ schema: JsonSchemaNode;
194
+ required: boolean;
195
+ }) {
196
+ const raw = value == null ? '' : String(value);
197
+ const type = schema.type === 'integer' ? 'integer' : 'number';
198
+ return (
199
+ <div className="space-y-1">
200
+ <FieldHeader label={label} type={schema.format ? `${type} (${schema.format})` : type} required={required} description={schema.description} />
201
+ <Input
202
+ type="number"
203
+ value={raw}
204
+ onChange={(e) => {
205
+ const v = e.target.value;
206
+ if (v === '') return onChange(null);
207
+ const n = schema.type === 'integer' ? parseInt(v, 10) : parseFloat(v);
208
+ onChange(Number.isNaN(n) ? null : n);
209
+ }}
210
+ placeholder={type}
211
+ className="h-8 text-xs font-mono"
212
+ />
213
+ </div>
214
+ );
215
+ }
216
+
217
+ function BooleanField({
218
+ value, onChange, label, schema, required,
219
+ }: {
220
+ value: unknown;
221
+ onChange: (next: boolean) => void;
222
+ label?: string;
223
+ schema: JsonSchemaNode;
224
+ required: boolean;
225
+ }) {
226
+ const bool = value === true;
227
+ return (
228
+ <div className="flex items-start justify-between gap-3">
229
+ <FieldHeader label={label} type="boolean" required={required} description={schema.description} />
230
+ <Switch checked={bool} onCheckedChange={onChange} className="mt-0.5 shrink-0" />
231
+ </div>
232
+ );
233
+ }
234
+
235
+ function EnumField({
236
+ schema, value, onChange, label, required,
237
+ }: {
238
+ schema: JsonSchemaNode;
239
+ value: unknown;
240
+ onChange: (next: unknown) => void;
241
+ label?: string;
242
+ required: boolean;
243
+ }) {
244
+ const options = (schema.enum ?? []).map((v) => ({
245
+ value: String(v),
246
+ label: String(v),
247
+ }));
248
+ const strValue = value == null ? '' : String(value);
249
+ return (
250
+ <div className="space-y-1">
251
+ <FieldHeader label={label} type={`${schema.type ?? 'enum'} enum`} required={required} description={schema.description} />
252
+ <Combobox
253
+ options={options}
254
+ value={strValue}
255
+ onValueChange={(v) => {
256
+ // Preserve original type if schema declares integer/number.
257
+ if (schema.type === 'integer') onChange(parseInt(v, 10));
258
+ else if (schema.type === 'number') onChange(parseFloat(v));
259
+ else onChange(v);
260
+ }}
261
+ placeholder="Select…"
262
+ searchPlaceholder="Search…"
263
+ className="h-8 text-xs"
264
+ />
265
+ </div>
266
+ );
267
+ }
268
+
269
+ function RawJsonField({
270
+ label, value, onChange,
271
+ }: {
272
+ label?: string;
273
+ value: unknown;
274
+ onChange: (next: unknown) => void;
275
+ }) {
276
+ const [text, setText] = React.useState(() => JSON.stringify(value ?? null, null, 2));
277
+ // Resync when value changes from outside (e.g. endpoint switch).
278
+ React.useEffect(() => {
279
+ setText(JSON.stringify(value ?? null, null, 2));
280
+ }, [value]);
281
+ return (
282
+ <div className="space-y-1">
283
+ {label && <SectionLabel>{label} (raw)</SectionLabel>}
284
+ <Textarea
285
+ value={text}
286
+ onChange={(e) => {
287
+ setText(e.target.value);
288
+ try { onChange(JSON.parse(e.target.value)); } catch { /* keep last valid */ }
289
+ }}
290
+ className="font-mono text-[11px] min-h-[80px]"
291
+ rows={4}
292
+ />
293
+ </div>
294
+ );
295
+ }
296
+
297
+ // ─── Composite widgets ────────────────────────────────────────────────────────
298
+
299
+ function ObjectField({
300
+ schema, value, onChange, depth, label,
301
+ }: {
302
+ schema: JsonSchemaNode;
303
+ value: unknown;
304
+ onChange: (next: Record<string, unknown>) => void;
305
+ depth: number;
306
+ label?: string;
307
+ }) {
308
+ const obj = (value && typeof value === 'object' && !Array.isArray(value))
309
+ ? (value as Record<string, unknown>)
310
+ : {};
311
+ const required = new Set(schema.required ?? []);
312
+ const entries = Object.entries(schema.properties ?? {});
313
+
314
+ const setKey = useCallback(
315
+ (key: string) => (next: unknown) => {
316
+ onChange({ ...obj, [key]: next });
317
+ },
318
+ [obj, onChange],
319
+ );
320
+
321
+ if (entries.length === 0) {
322
+ return <RawJsonField label={label} value={obj} onChange={onChange as (v: unknown) => void} />;
323
+ }
324
+
325
+ // Root-level object (depth === 0) renders flat; nested gets an
326
+ // indented, bordered group so the hierarchy is visible.
327
+ const wrapperClass = depth === 0
328
+ ? 'space-y-3'
329
+ : 'space-y-2 border-l-2 border-border/60 pl-3 ml-px';
330
+
331
+ return (
332
+ <div className="space-y-1.5">
333
+ {label && depth > 0 && (
334
+ <div className="flex items-baseline gap-1.5">
335
+ <span className="font-mono text-[11px] text-foreground/80">{label}</span>
336
+ <span className="font-mono text-[10px] text-muted-foreground/50">object</span>
337
+ </div>
338
+ )}
339
+ <div className={cn(wrapperClass)}>
340
+ {entries.map(([key, subSchema]) => (
341
+ <SchemaField
342
+ key={key}
343
+ schema={subSchema}
344
+ value={obj[key]}
345
+ onChange={setKey(key)}
346
+ depth={depth + 1}
347
+ required={required.has(key)}
348
+ label={key}
349
+ />
350
+ ))}
351
+ </div>
352
+ </div>
353
+ );
354
+ }
355
+
356
+ function ArrayField({
357
+ schema, value, onChange, depth, label, required,
358
+ }: {
359
+ schema: JsonSchemaNode;
360
+ value: unknown;
361
+ onChange: (next: unknown[]) => void;
362
+ depth: number;
363
+ label?: string;
364
+ required: boolean;
365
+ }) {
366
+ const arr = Array.isArray(value) ? value : [];
367
+ const items = schema.items ?? { type: 'string' };
368
+
369
+ const addItem = () => onChange([...arr, defaultForSchema(items)]);
370
+ const removeAt = (i: number) => onChange(arr.filter((_, idx) => idx !== i));
371
+ const setAt = (i: number) => (next: unknown) =>
372
+ onChange(arr.map((v, idx) => (idx === i ? next : v)));
373
+
374
+ return (
375
+ <div className="space-y-1.5">
376
+ {label && (
377
+ <div className="flex items-baseline gap-1.5">
378
+ <span className="font-mono text-[11px] text-foreground/80">{label}</span>
379
+ {required && <span className="text-[9px] text-destructive font-bold leading-none">*</span>}
380
+ <span className="font-mono text-[10px] text-muted-foreground/50">
381
+ {`array<${items.type ?? 'any'}>`}
382
+ </span>
383
+ </div>
384
+ )}
385
+ <div className="space-y-2 border-l-2 border-border/60 pl-3 ml-px">
386
+ {arr.length === 0 && (
387
+ <p className="text-[10px] text-muted-foreground/50 italic">Empty array</p>
388
+ )}
389
+ {arr.map((v, i) => (
390
+ <div key={i} className="flex items-start gap-2">
391
+ <div className="flex-1 min-w-0">
392
+ <SchemaField
393
+ schema={items}
394
+ value={v}
395
+ onChange={setAt(i)}
396
+ depth={depth + 1}
397
+ required={false}
398
+ label={`${label ?? ''}[${i}]`}
399
+ />
400
+ </div>
401
+ <button
402
+ type="button"
403
+ onClick={() => removeAt(i)}
404
+ title="Remove item"
405
+ className="shrink-0 h-7 w-7 inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
406
+ >
407
+ <Minus className="h-3.5 w-3.5" />
408
+ </button>
409
+ </div>
410
+ ))}
411
+ <button
412
+ type="button"
413
+ onClick={addItem}
414
+ className="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors py-1"
415
+ >
416
+ <Plus className="h-3 w-3" />
417
+ Add item
418
+ </button>
419
+ </div>
420
+ </div>
421
+ );
422
+ }
@@ -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
+ }