DynamicJsonForm improvements and full enum schema support

In DynamicJsonForm.tsx,
  - Support all five enum shapes used by MCP SDK:
    - Titled single-select via `oneOf`/`anyOf` with `{ const, title }`
    - Untitled single-select via `enum`
    - Titled legacy single-select with optional `enumNames` for labels
    - Titled multi-select via `items.anyOf`/`oneOf`
    - Untitled multi-select via `items.enum`
  - Show field descriptions for string select fields and boolean checkboxes (consistent help text above inputs).
  - Prefer schema property `title` for object field labels; fall back to the JSON key when missing.
  - Allow top‑level form rendering for objects with properties, arrays with items, and primitive types (was overly strict before).
  - Multi-select UI: sensible list size and `minItems`/`maxItems` helper text when present.
  - JSON editor fallback, copy/format controls, and debounced parsing preserved.
  - apply defaults for optional fields during form init

In schemaUtils.ts
  - `generateDefaultValue` now includes optional properties that declare a `default`, ensuring fields like strings show their defaults on first render.

In jsonUtils.ts
  - Add legacy `enumNames?: string[]` and array constraints `minItems?`/`maxItems?`.
This commit is contained in:
cliffhall 2025-12-02 19:37:28 -05:00
commit fd99d879c5
3 changed files with 184 additions and 62 deletions

View file

@ -76,7 +76,38 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>( const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
({ schema, value, onChange, maxDepth = 3 }, ref) => { ({ schema, value, onChange, maxDepth = 3 }, ref) => {
const isOnlyJSON = !isSimpleObject(schema); // Determine if we can render a form at the top level.
// This is more permissive than isSimpleObject():
// - Objects with any properties are form-capable (individual complex fields may still fallback to JSON)
// - Arrays with defined items are form-capable
// - Primitive types are form-capable
const canRenderTopLevelForm = (s: JsonSchemaType): boolean => {
const primitiveTypes = ["string", "number", "integer", "boolean", "null"];
const hasType = Array.isArray(s.type) ? s.type.length > 0 : !!s.type;
if (!hasType) return false;
const includesType = (t: string) =>
Array.isArray(s.type) ? s.type.includes(t as any) : s.type === t;
// Primitive at top-level
if (primitiveTypes.some(includesType)) return true;
// Object with properties
if (includesType("object")) {
const keys = Object.keys(s.properties ?? {});
return keys.length > 0;
}
// Array with items
if (includesType("array")) {
return !!s.items;
}
return false;
};
const isOnlyJSON = !canRenderTopLevelForm(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>(); const [jsonError, setJsonError] = useState<string>();
const [copiedJson, setCopiedJson] = useState<boolean>(false); const [copiedJson, setCopiedJson] = useState<boolean>(false);
@ -267,63 +298,76 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
switch (fieldType) { switch (fieldType) {
case "string": { case "string": {
if ( // Titled single-select using oneOf/anyOf with const/title pairs
propSchema.oneOf && const titledOptions = (propSchema.oneOf ?? propSchema.anyOf)?.filter(
propSchema.oneOf.every( (opt) => (opt as any).const !== undefined,
(option) => ) as { const: string; title?: string }[] | undefined;
typeof option.const === "string" &&
typeof option.title === "string", if (titledOptions && titledOptions.length > 0) {
)
) {
return ( return (
<select <div className="space-y-2">
value={(currentValue as string) ?? ""} {propSchema.description && (
onChange={(e) => { <p className="text-sm text-gray-600">
const val = e.target.value; {propSchema.description}
if (!val && !isRequired) { </p>
handleFieldChange(path, undefined); )}
} else { <select
handleFieldChange(path, val); value={(currentValue as string) ?? ""}
} onChange={(e) => {
}} const val = e.target.value;
required={isRequired} if (!val && !isRequired) {
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800" handleFieldChange(path, undefined);
> } else {
<option value="">Select an option...</option> handleFieldChange(path, val);
{propSchema.oneOf.map((option) => ( }
<option }}
key={option.const as string} required={isRequired}
value={option.const as string} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
> >
{option.title as string} <option value="">Select an option...</option>
</option> {titledOptions.map((option) => (
))} <option key={option.const} value={option.const}>
</select> {option.title ?? String(option.const)}
</option>
))}
</select>
</div>
); );
} }
// Untitled single-select using enum (with optional legacy enumNames for labels)
if (propSchema.enum) { if (propSchema.enum) {
const names = Array.isArray((propSchema as any).enumNames)
? (propSchema as any).enumNames
: undefined;
return ( return (
<select <div className="space-y-2">
value={(currentValue as string) ?? ""} {propSchema.description && (
onChange={(e) => { <p className="text-sm text-gray-600">
const val = e.target.value; {propSchema.description}
if (!val && !isRequired) { </p>
handleFieldChange(path, undefined); )}
} else { <select
handleFieldChange(path, val); value={(currentValue as string) ?? ""}
} onChange={(e) => {
}} const val = e.target.value;
required={isRequired} if (!val && !isRequired) {
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800" handleFieldChange(path, undefined);
> } else {
<option value="">Select an option...</option> handleFieldChange(path, val);
{propSchema.enum.map((option) => ( }
<option key={option} value={option}> }}
{option} required={isRequired}
</option> className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
))} >
</select> <option value="">Select an option...</option>
{propSchema.enum.map((option, idx) => (
<option key={option} value={option}>
{names?.[idx] ?? option}
</option>
))}
</select>
</div>
); );
} }
@ -413,13 +457,20 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
case "boolean": case "boolean":
return ( return (
<Input <div className="space-y-2">
type="checkbox" {propSchema.description && (
checked={(currentValue as boolean) ?? false} <p className="text-sm text-gray-600">
onChange={(e) => handleFieldChange(path, e.target.checked)} {propSchema.description}
className="w-4 h-4" </p>
required={isRequired} )}
/> <Input
type="checkbox"
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={isRequired}
/>
</div>
); );
case "null": case "null":
return null; return null;
@ -449,7 +500,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
{Object.entries(propSchema.properties).map(([key, subSchema]) => ( {Object.entries(propSchema.properties).map(([key, subSchema]) => (
<div key={key}> <div key={key}>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
{key} {(subSchema as JsonSchemaType).title ?? key}
{propSchema.required?.includes(key) && ( {propSchema.required?.includes(key) && (
<span className="text-red-500 ml-1">*</span> <span className="text-red-500 ml-1">*</span>
)} )}
@ -470,6 +521,70 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
const arrayValue = Array.isArray(currentValue) ? currentValue : []; const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null; if (!propSchema.items) return null;
// Special handling: array of enums -> render multi-select control
const itemSchema = propSchema.items as JsonSchemaType;
let multiOptions: { value: string; label: string }[] | null = null;
const titledMulti = (itemSchema.anyOf ?? itemSchema.oneOf)?.filter(
(opt) => (opt as any).const !== undefined,
) as { const: string; title?: string }[] | undefined;
if (titledMulti && titledMulti.length > 0) {
multiOptions = titledMulti.map((o) => ({
value: o.const,
label: o.title ?? String(o.const),
}));
} else if (itemSchema.enum) {
const names = Array.isArray((itemSchema as any).enumNames)
? (itemSchema as any).enumNames
: undefined;
multiOptions = itemSchema.enum.map((v, i) => ({
value: v,
label: names?.[i] ?? v,
}));
}
if (multiOptions) {
const selectSize = Math.min(Math.max(multiOptions.length, 3), 8);
return (
<div className="space-y-2">
{propSchema.description && (
<p className="text-sm text-gray-600">
{propSchema.description}
</p>
)}
<select
multiple
size={selectSize}
value={arrayValue as string[]}
onChange={(e) => {
const selected = Array.from(
(e.target as HTMLSelectElement).selectedOptions,
).map((o) => o.value);
handleFieldChange(path, selected);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
{multiOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(propSchema.minItems || propSchema.maxItems) && (
<p className="text-xs text-gray-500">
{propSchema.minItems
? `Select at least ${propSchema.minItems}. `
: ""}
{propSchema.maxItems
? `Select at most ${propSchema.maxItems}.`
: ""}
</p>
)}
</div>
);
}
// If the array items are simple, render as form fields, otherwise use JSON editor // If the array items are simple, render as form fields, otherwise use JSON editor
if (isSimpleObject(propSchema.items)) { if (isSimpleObject(propSchema.items)) {
return ( return (

View file

@ -37,6 +37,9 @@ export type JsonSchemaType = {
default?: JsonValue; default?: JsonValue;
properties?: Record<string, JsonSchemaType>; properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType; items?: JsonSchemaType;
// Array validation constraints
minItems?: number;
maxItems?: number;
minimum?: number; minimum?: number;
maximum?: number; maximum?: number;
minLength?: number; minLength?: number;
@ -45,6 +48,8 @@ export type JsonSchemaType = {
pattern?: string; pattern?: string;
format?: string; format?: string;
enum?: string[]; enum?: string[];
// Non-standard legacy support: titles for enum values
enumNames?: string[];
const?: JsonValue; const?: JsonValue;
oneOf?: (JsonSchemaType | JsonSchemaConst)[]; oneOf?: (JsonSchemaType | JsonSchemaConst)[];
anyOf?: (JsonSchemaType | JsonSchemaConst)[]; anyOf?: (JsonSchemaType | JsonSchemaConst)[];

View file

@ -115,9 +115,11 @@ export function generateDefaultValue(
if (!schema.properties) return isRequired ? {} : undefined; if (!schema.properties) return isRequired ? {} : undefined;
const obj: JsonObject = {}; const obj: JsonObject = {};
// Only include properties that are required according to the schema's required array // Include required properties OR optional properties that declare a default
Object.entries(schema.properties).forEach(([key, prop]) => { Object.entries(schema.properties).forEach(([key, prop]) => {
if (isPropertyRequired(key, schema)) { const hasExplicitDefault =
"default" in prop && (prop as JsonSchemaType).default !== undefined;
if (isPropertyRequired(key, schema) || hasExplicitDefault) {
const value = generateDefaultValue(prop, key, schema); const value = generateDefaultValue(prop, key, schema);
if (value !== undefined) { if (value !== undefined) {
obj[key] = value; obj[key] = value;