@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.
- 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
|
@@ -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
|
+
}
|