Skip to content

Commit fd99d87

Browse files
committed
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?`.
1 parent 11e4e10 commit fd99d87

File tree

3 files changed

+184
-62
lines changed

3 files changed

+184
-62
lines changed

client/src/components/DynamicJsonForm.tsx

Lines changed: 175 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,38 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
7676

7777
const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
7878
({ schema, value, onChange, maxDepth = 3 }, ref) => {
79-
const isOnlyJSON = !isSimpleObject(schema);
79+
// Determine if we can render a form at the top level.
80+
// This is more permissive than isSimpleObject():
81+
// - Objects with any properties are form-capable (individual complex fields may still fallback to JSON)
82+
// - Arrays with defined items are form-capable
83+
// - Primitive types are form-capable
84+
const canRenderTopLevelForm = (s: JsonSchemaType): boolean => {
85+
const primitiveTypes = ["string", "number", "integer", "boolean", "null"];
86+
87+
const hasType = Array.isArray(s.type) ? s.type.length > 0 : !!s.type;
88+
if (!hasType) return false;
89+
90+
const includesType = (t: string) =>
91+
Array.isArray(s.type) ? s.type.includes(t as any) : s.type === t;
92+
93+
// Primitive at top-level
94+
if (primitiveTypes.some(includesType)) return true;
95+
96+
// Object with properties
97+
if (includesType("object")) {
98+
const keys = Object.keys(s.properties ?? {});
99+
return keys.length > 0;
100+
}
101+
102+
// Array with items
103+
if (includesType("array")) {
104+
return !!s.items;
105+
}
106+
107+
return false;
108+
};
109+
110+
const isOnlyJSON = !canRenderTopLevelForm(schema);
80111
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
81112
const [jsonError, setJsonError] = useState<string>();
82113
const [copiedJson, setCopiedJson] = useState<boolean>(false);
@@ -267,63 +298,76 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
267298

268299
switch (fieldType) {
269300
case "string": {
270-
if (
271-
propSchema.oneOf &&
272-
propSchema.oneOf.every(
273-
(option) =>
274-
typeof option.const === "string" &&
275-
typeof option.title === "string",
276-
)
277-
) {
301+
// Titled single-select using oneOf/anyOf with const/title pairs
302+
const titledOptions = (propSchema.oneOf ?? propSchema.anyOf)?.filter(
303+
(opt) => (opt as any).const !== undefined,
304+
) as { const: string; title?: string }[] | undefined;
305+
306+
if (titledOptions && titledOptions.length > 0) {
278307
return (
279-
<select
280-
value={(currentValue as string) ?? ""}
281-
onChange={(e) => {
282-
const val = e.target.value;
283-
if (!val && !isRequired) {
284-
handleFieldChange(path, undefined);
285-
} else {
286-
handleFieldChange(path, val);
287-
}
288-
}}
289-
required={isRequired}
290-
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"
291-
>
292-
<option value="">Select an option...</option>
293-
{propSchema.oneOf.map((option) => (
294-
<option
295-
key={option.const as string}
296-
value={option.const as string}
297-
>
298-
{option.title as string}
299-
</option>
300-
))}
301-
</select>
308+
<div className="space-y-2">
309+
{propSchema.description && (
310+
<p className="text-sm text-gray-600">
311+
{propSchema.description}
312+
</p>
313+
)}
314+
<select
315+
value={(currentValue as string) ?? ""}
316+
onChange={(e) => {
317+
const val = e.target.value;
318+
if (!val && !isRequired) {
319+
handleFieldChange(path, undefined);
320+
} else {
321+
handleFieldChange(path, val);
322+
}
323+
}}
324+
required={isRequired}
325+
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"
326+
>
327+
<option value="">Select an option...</option>
328+
{titledOptions.map((option) => (
329+
<option key={option.const} value={option.const}>
330+
{option.title ?? String(option.const)}
331+
</option>
332+
))}
333+
</select>
334+
</div>
302335
);
303336
}
304337

338+
// Untitled single-select using enum (with optional legacy enumNames for labels)
305339
if (propSchema.enum) {
340+
const names = Array.isArray((propSchema as any).enumNames)
341+
? (propSchema as any).enumNames
342+
: undefined;
306343
return (
307-
<select
308-
value={(currentValue as string) ?? ""}
309-
onChange={(e) => {
310-
const val = e.target.value;
311-
if (!val && !isRequired) {
312-
handleFieldChange(path, undefined);
313-
} else {
314-
handleFieldChange(path, val);
315-
}
316-
}}
317-
required={isRequired}
318-
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"
319-
>
320-
<option value="">Select an option...</option>
321-
{propSchema.enum.map((option) => (
322-
<option key={option} value={option}>
323-
{option}
324-
</option>
325-
))}
326-
</select>
344+
<div className="space-y-2">
345+
{propSchema.description && (
346+
<p className="text-sm text-gray-600">
347+
{propSchema.description}
348+
</p>
349+
)}
350+
<select
351+
value={(currentValue as string) ?? ""}
352+
onChange={(e) => {
353+
const val = e.target.value;
354+
if (!val && !isRequired) {
355+
handleFieldChange(path, undefined);
356+
} else {
357+
handleFieldChange(path, val);
358+
}
359+
}}
360+
required={isRequired}
361+
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"
362+
>
363+
<option value="">Select an option...</option>
364+
{propSchema.enum.map((option, idx) => (
365+
<option key={option} value={option}>
366+
{names?.[idx] ?? option}
367+
</option>
368+
))}
369+
</select>
370+
</div>
327371
);
328372
}
329373

@@ -413,13 +457,20 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
413457

414458
case "boolean":
415459
return (
416-
<Input
417-
type="checkbox"
418-
checked={(currentValue as boolean) ?? false}
419-
onChange={(e) => handleFieldChange(path, e.target.checked)}
420-
className="w-4 h-4"
421-
required={isRequired}
422-
/>
460+
<div className="space-y-2">
461+
{propSchema.description && (
462+
<p className="text-sm text-gray-600">
463+
{propSchema.description}
464+
</p>
465+
)}
466+
<Input
467+
type="checkbox"
468+
checked={(currentValue as boolean) ?? false}
469+
onChange={(e) => handleFieldChange(path, e.target.checked)}
470+
className="w-4 h-4"
471+
required={isRequired}
472+
/>
473+
</div>
423474
);
424475
case "null":
425476
return null;
@@ -449,7 +500,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
449500
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
450501
<div key={key}>
451502
<label className="block text-sm font-medium mb-1">
452-
{key}
503+
{(subSchema as JsonSchemaType).title ?? key}
453504
{propSchema.required?.includes(key) && (
454505
<span className="text-red-500 ml-1">*</span>
455506
)}
@@ -470,6 +521,70 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
470521
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
471522
if (!propSchema.items) return null;
472523

524+
// Special handling: array of enums -> render multi-select control
525+
const itemSchema = propSchema.items as JsonSchemaType;
526+
let multiOptions: { value: string; label: string }[] | null = null;
527+
528+
const titledMulti = (itemSchema.anyOf ?? itemSchema.oneOf)?.filter(
529+
(opt) => (opt as any).const !== undefined,
530+
) as { const: string; title?: string }[] | undefined;
531+
532+
if (titledMulti && titledMulti.length > 0) {
533+
multiOptions = titledMulti.map((o) => ({
534+
value: o.const,
535+
label: o.title ?? String(o.const),
536+
}));
537+
} else if (itemSchema.enum) {
538+
const names = Array.isArray((itemSchema as any).enumNames)
539+
? (itemSchema as any).enumNames
540+
: undefined;
541+
multiOptions = itemSchema.enum.map((v, i) => ({
542+
value: v,
543+
label: names?.[i] ?? v,
544+
}));
545+
}
546+
547+
if (multiOptions) {
548+
const selectSize = Math.min(Math.max(multiOptions.length, 3), 8);
549+
return (
550+
<div className="space-y-2">
551+
{propSchema.description && (
552+
<p className="text-sm text-gray-600">
553+
{propSchema.description}
554+
</p>
555+
)}
556+
<select
557+
multiple
558+
size={selectSize}
559+
value={arrayValue as string[]}
560+
onChange={(e) => {
561+
const selected = Array.from(
562+
(e.target as HTMLSelectElement).selectedOptions,
563+
).map((o) => o.value);
564+
handleFieldChange(path, selected);
565+
}}
566+
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"
567+
>
568+
{multiOptions.map((opt) => (
569+
<option key={opt.value} value={opt.value}>
570+
{opt.label}
571+
</option>
572+
))}
573+
</select>
574+
{(propSchema.minItems || propSchema.maxItems) && (
575+
<p className="text-xs text-gray-500">
576+
{propSchema.minItems
577+
? `Select at least ${propSchema.minItems}. `
578+
: ""}
579+
{propSchema.maxItems
580+
? `Select at most ${propSchema.maxItems}.`
581+
: ""}
582+
</p>
583+
)}
584+
</div>
585+
);
586+
}
587+
473588
// If the array items are simple, render as form fields, otherwise use JSON editor
474589
if (isSimpleObject(propSchema.items)) {
475590
return (

client/src/utils/jsonUtils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export type JsonSchemaType = {
3737
default?: JsonValue;
3838
properties?: Record<string, JsonSchemaType>;
3939
items?: JsonSchemaType;
40+
// Array validation constraints
41+
minItems?: number;
42+
maxItems?: number;
4043
minimum?: number;
4144
maximum?: number;
4245
minLength?: number;
@@ -45,6 +48,8 @@ export type JsonSchemaType = {
4548
pattern?: string;
4649
format?: string;
4750
enum?: string[];
51+
// Non-standard legacy support: titles for enum values
52+
enumNames?: string[];
4853
const?: JsonValue;
4954
oneOf?: (JsonSchemaType | JsonSchemaConst)[];
5055
anyOf?: (JsonSchemaType | JsonSchemaConst)[];

client/src/utils/schemaUtils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,11 @@ export function generateDefaultValue(
115115
if (!schema.properties) return isRequired ? {} : undefined;
116116

117117
const obj: JsonObject = {};
118-
// Only include properties that are required according to the schema's required array
118+
// Include required properties OR optional properties that declare a default
119119
Object.entries(schema.properties).forEach(([key, prop]) => {
120-
if (isPropertyRequired(key, schema)) {
120+
const hasExplicitDefault =
121+
"default" in prop && (prop as JsonSchemaType).default !== undefined;
122+
if (isPropertyRequired(key, schema) || hasExplicitDefault) {
121123
const value = generateDefaultValue(prop, key, schema);
122124
if (value !== undefined) {
123125
obj[key] = value;

0 commit comments

Comments
 (0)