@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,121 @@
1
+ /**
2
+ * Flatten a JSON Schema node into a flat list of (name, type, required,
3
+ * description) rows, ready to feed into the existing parameter table.
4
+ *
5
+ * Rules:
6
+ * - ``type: object`` → one row per property.
7
+ * - ``type: array`` → recurse into items; prefix each row with ``[].``
8
+ * so the caller sees "[].username string" etc.
9
+ * - Nested objects → dot-joined path: ``category.name``. We expand
10
+ * one level; deeper graphs get a single summary
11
+ * row ("category.* (object)") to keep the table
12
+ * readable.
13
+ *
14
+ * This is a presentation helper — not a full form generator. It powers
15
+ * the read-only fields table shown in the docs longread.
16
+ */
17
+
18
+ type JsonSchemaNode = Record<string, unknown> & {
19
+ type?: string;
20
+ properties?: Record<string, JsonSchemaNode>;
21
+ required?: string[];
22
+ items?: JsonSchemaNode;
23
+ enum?: unknown[];
24
+ description?: string;
25
+ format?: string;
26
+ };
27
+
28
+ export interface SchemaField {
29
+ name: string;
30
+ type: string;
31
+ required: boolean;
32
+ description?: string;
33
+ }
34
+
35
+ const MAX_DEPTH = 2;
36
+
37
+ function describeType(node: JsonSchemaNode): string {
38
+ if (!node.type && node.properties) return 'object';
39
+ const base = node.type || 'any';
40
+ if (base === 'array') {
41
+ const itemType = node.items ? describeType(node.items) : 'any';
42
+ return `array<${itemType}>`;
43
+ }
44
+ if (Array.isArray(node.enum) && node.enum.length > 0) {
45
+ return `${base} enum`;
46
+ }
47
+ if (node.format) return `${base} (${node.format})`;
48
+ return base;
49
+ }
50
+
51
+ export function schemaToFields(
52
+ schema: JsonSchemaNode | undefined,
53
+ prefix = '',
54
+ depth = 0,
55
+ ): SchemaField[] {
56
+ if (!schema || depth > MAX_DEPTH) return [];
57
+
58
+ // Unwrap arrays: show the inner item's fields, prefixed with ``[]``.
59
+ if (schema.type === 'array') {
60
+ if (!schema.items) {
61
+ return [{ name: prefix || '[]', type: 'array', required: false }];
62
+ }
63
+ const inner = schemaToFields(schema.items, prefix ? `${prefix}[]` : '[]', depth);
64
+ if (inner.length === 0) {
65
+ return [
66
+ {
67
+ name: prefix || '[]',
68
+ type: describeType(schema),
69
+ required: false,
70
+ description: schema.description,
71
+ },
72
+ ];
73
+ }
74
+ return inner;
75
+ }
76
+
77
+ // Primitives and unknown — single row.
78
+ if (schema.type !== 'object' && !schema.properties) {
79
+ return [
80
+ {
81
+ name: prefix || '(body)',
82
+ type: describeType(schema),
83
+ required: false,
84
+ description: schema.description,
85
+ },
86
+ ];
87
+ }
88
+
89
+ // Objects — one row per property, recurse for nested objects/arrays.
90
+ const required = new Set(schema.required ?? []);
91
+ const rows: SchemaField[] = [];
92
+ const props = schema.properties ?? {};
93
+ for (const [key, node] of Object.entries(props)) {
94
+ const fullName = prefix ? `${prefix}.${key}` : key;
95
+ const isRequired = required.has(key);
96
+
97
+ const isNestedExpandable =
98
+ (node.type === 'object' && node.properties) ||
99
+ (node.type === 'array' && node.items);
100
+
101
+ if (isNestedExpandable && depth < MAX_DEPTH) {
102
+ // Leaf summary row for the parent itself (so the user sees
103
+ // its description) + recurse for inner fields.
104
+ rows.push({
105
+ name: fullName,
106
+ type: describeType(node),
107
+ required: isRequired,
108
+ description: node.description,
109
+ });
110
+ rows.push(...schemaToFields(node, fullName, depth + 1));
111
+ } else {
112
+ rows.push({
113
+ name: fullName,
114
+ type: describeType(node),
115
+ required: isRequired,
116
+ description: node.description,
117
+ });
118
+ }
119
+ }
120
+ return rows;
121
+ }
@@ -0,0 +1,60 @@
1
+ import type { ApiEndpoint } from '../../types';
2
+
3
+ /**
4
+ * Given a list of full endpoint paths, return the longest ``/``-aligned
5
+ * prefix they all share. Used to strip the redundant group prefix
6
+ * (``/api/v3/pet``) from sidebar labels so the meaningful tail is visible.
7
+ *
8
+ * Works on full URLs too — if every path begins with the same origin,
9
+ * the origin is part of the common prefix and gets stripped.
10
+ */
11
+ export function longestCommonPrefix(paths: string[]): string {
12
+ if (paths.length === 0) return '';
13
+ if (paths.length === 1) return '';
14
+
15
+ const segments = paths.map((p) => p.split('/'));
16
+ const minLen = Math.min(...segments.map((s) => s.length));
17
+
18
+ const shared: string[] = [];
19
+ for (let i = 0; i < minLen; i++) {
20
+ const first = segments[0]![i];
21
+ if (segments.every((s) => s[i] === first)) {
22
+ shared.push(first!);
23
+ } else {
24
+ break;
25
+ }
26
+ }
27
+ // Don't strip everything — we always want at least a leading "/" or
28
+ // the method on the visible side. If the group has one endpoint,
29
+ // the caller guards with paths.length check already.
30
+ const joined = shared.join('/');
31
+ // Trim trailing slash so the tail is clean (``/foo`` not ``foo``).
32
+ return joined;
33
+ }
34
+
35
+ /**
36
+ * Compute the label to show in the sidebar for a given endpoint.
37
+ *
38
+ * Priority:
39
+ * 1. ``ep.summary`` — human-readable, from OpenAPI ``operation.summary``.
40
+ * 2. The tail of ``ep.path`` after stripping the group's common prefix.
41
+ * 3. Full ``ep.path`` if the group has a single endpoint (no prefix to strip).
42
+ */
43
+ export function sidebarLabel(ep: ApiEndpoint, groupCommonPrefix: string): string {
44
+ if (ep.summary) return ep.summary;
45
+
46
+ if (groupCommonPrefix && ep.path.startsWith(groupCommonPrefix)) {
47
+ const tail = ep.path.slice(groupCommonPrefix.length) || '/';
48
+ return tail;
49
+ }
50
+ return relativePath(ep.path);
51
+ }
52
+
53
+ function relativePath(full: string): string {
54
+ try { return new URL(full).pathname; } catch { return full; }
55
+ }
56
+
57
+ /** Tooltip text: always the definitive ``METHOD relative/path``. */
58
+ export function sidebarTooltip(ep: ApiEndpoint): string {
59
+ return `${ep.method} ${relativePath(ep.path)}`;
60
+ }
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Playground Components
2
+ * OpenapiViewer — component exports.
3
+ * Only ``DocsLayout`` remains; the legacy 3-column ``PlaygroundLayout``
4
+ * has been removed. Shared panels live under ``./shared`` and are
5
+ * consumed by the docs layout + its mobile sheet.
3
6
  */
4
7
 
5
- export { PlaygroundLayout } from './PlaygroundLayout';
8
+ export { DocsLayout } from './DocsLayout';
@@ -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
+ }