'use client'; /** * Form-based request body editor driven by JSON Schema. * * Replaces the ``{"key":"value"}`` textarea prompt with a real form: * one input per property, typed widgets for primitives, nested objects * indented, arrays with add/remove. The component is controlled — the * parent owns the body value (as any JSON) and persists to localStorage. * * Intentionally not a full JSON-Schema-Form: we don't cover oneOf/anyOf, * pattern validation, min/max — the playground just needs a usable * interactive shape. Users who hit a corner case can flip the ``JSON`` * toggle in RequestPanel and edit raw. */ import { Minus, Plus } from 'lucide-react'; import React, { useCallback } from 'react'; import { Combobox, Input, Switch, Textarea } from '@djangocfg/ui-core/components'; import { cn } from '@djangocfg/ui-core/lib'; import { SectionLabel } from './ui'; type JsonSchemaNode = Record & { type?: string; properties?: Record; required?: string[]; items?: JsonSchemaNode; enum?: unknown[]; description?: string; format?: string; }; const MAX_DEPTH = 6; // ─── Value helpers ──────────────────────────────────────────────────────────── function defaultForSchema(schema: JsonSchemaNode | undefined): unknown { if (!schema) return null; if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0]; switch (schema.type) { case 'object': { const out: Record = {}; for (const [k, v] of Object.entries(schema.properties ?? {})) { out[k] = defaultForSchema(v); } return out; } case 'array': return []; case 'integer': case 'number': return 0; case 'boolean': return false; case 'string': return ''; default: if (schema.properties) { const out: Record = {}; for (const [k, v] of Object.entries(schema.properties)) { out[k] = defaultForSchema(v); } return out; } return ''; } } // ─── Root ───────────────────────────────────────────────────────────────────── export interface BodyFormEditorProps { schema: JsonSchemaNode; value: unknown; onChange: (next: unknown) => void; } export function BodyFormEditor({ schema, value, onChange }: BodyFormEditorProps) { return ( ); } // ─── Recursive renderer ─────────────────────────────────────────────────────── interface SchemaFieldProps { schema: JsonSchemaNode; value: unknown; onChange: (next: unknown) => void; depth: number; required: boolean; label?: string; } function SchemaField({ schema, value, onChange, depth, required, label }: SchemaFieldProps) { // Depth cutoff: collapse further nesting into a raw JSON textarea — // deeper forms get impossible to navigate and lose value for the UX // we're trying to offer (quick exploration). if (depth > MAX_DEPTH) { return ; } if (Array.isArray(schema.enum) && schema.enum.length > 0) { return ; } switch (schema.type) { case 'object': return ; case 'array': return ; case 'boolean': return ; case 'integer': case 'number': return ; case 'string': default: // Untyped / string-ish — plain text input. Covers the // "body is a free-form string" case too (e.g. text/plain). if (!schema.type && schema.properties) { return ; } return ; } } // ─── Primitive widgets ──────────────────────────────────────────────────────── function FieldHeader({ label, type, required, description, }: { label?: string; type: string; required: boolean; description?: string; }) { if (!label) return null; return (
{label} {required && *} {type}
{description && (

{description}

)}
); } function StringField({ value, onChange, label, schema, required, }: { value: unknown; onChange: (next: string) => void; label?: string; schema: JsonSchemaNode; required: boolean; }) { const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value); const placeholder = schema.format ? `${schema.type ?? 'string'} (${schema.format})` : schema.description || schema.type || 'string'; return (
onChange(e.target.value)} placeholder={placeholder} className="h-8 text-xs font-mono" />
); } function NumberField({ value, onChange, label, schema, required, }: { value: unknown; onChange: (next: number | null) => void; label?: string; schema: JsonSchemaNode; required: boolean; }) { const raw = value == null ? '' : String(value); const type = schema.type === 'integer' ? 'integer' : 'number'; return (
{ const v = e.target.value; if (v === '') return onChange(null); const n = schema.type === 'integer' ? parseInt(v, 10) : parseFloat(v); onChange(Number.isNaN(n) ? null : n); }} placeholder={type} className="h-8 text-xs font-mono" />
); } function BooleanField({ value, onChange, label, schema, required, }: { value: unknown; onChange: (next: boolean) => void; label?: string; schema: JsonSchemaNode; required: boolean; }) { const bool = value === true; return (
); } function EnumField({ schema, value, onChange, label, required, }: { schema: JsonSchemaNode; value: unknown; onChange: (next: unknown) => void; label?: string; required: boolean; }) { const options = (schema.enum ?? []).map((v) => ({ value: String(v), label: String(v), })); const strValue = value == null ? '' : String(value); return (
{ // Preserve original type if schema declares integer/number. if (schema.type === 'integer') onChange(parseInt(v, 10)); else if (schema.type === 'number') onChange(parseFloat(v)); else onChange(v); }} placeholder="Select…" searchPlaceholder="Search…" className="h-8 text-xs" />
); } function RawJsonField({ label, value, onChange, }: { label?: string; value: unknown; onChange: (next: unknown) => void; }) { const [text, setText] = React.useState(() => JSON.stringify(value ?? null, null, 2)); // Resync when value changes from outside (e.g. endpoint switch). React.useEffect(() => { setText(JSON.stringify(value ?? null, null, 2)); }, [value]); return (
{label && {label} (raw)}