@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.
- 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 +6 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/map_cache.json +30 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
- package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -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,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
|
-
*
|
|
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 {
|
|
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
|
+
}
|