Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 117 additions & 77 deletions client/src/components/DynamicJsonForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
} from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import { generateDefaultValue, normalizeUnionType } from "@/utils/schemaUtils";
import type {
JsonValue,
JsonSchemaType,
Expand Down Expand Up @@ -87,6 +89,9 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
// - Arrays with defined items are form-capable
// - Primitive types are form-capable
const canRenderTopLevelForm = (s: JsonSchemaType): boolean => {
// Unwrap Optional[X] at the top level so anyOf:[X,null] is treated as X
s = normalizeUnionType(s);

const primitiveTypes = ["string", "number", "integer", "boolean", "null"];

const hasType = Array.isArray(s.type) ? s.type.length > 0 : !!s.type;
Expand Down Expand Up @@ -303,6 +308,19 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
parentSchema?: JsonSchemaType,
propertyName?: string,
) => {
// Unwrap Optional[X] / nullable unions (anyOf: [X, null]) before ANY type checks
// so that maxDepth enforcement and the type switch both see the real type.
propSchema = normalizeUnionType(propSchema);

// Trim description to remove leading/trailing whitespace from multi-line
// Python triple-quoted strings (e.g. """\n - text\n """)
if (propSchema.description) {
propSchema = {
...propSchema,
description: propSchema.description.trim(),
};
}

if (
depth >= maxDepth &&
(propSchema.type === "object" || propSchema.type === "array")
Expand Down Expand Up @@ -422,39 +440,41 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
);
}

let inputType = "text";
switch (propSchema.format) {
case "email":
inputType = "email";
break;
case "uri":
inputType = "url";
break;
case "date":
inputType = "date";
break;
case "date-time":
inputType = "datetime-local";
break;
default:
inputType = "text";
break;
// Special formats keep a typed <Input>; plain text uses <Textarea> to
// match the height and style of direct-parameter string inputs.
type SpecialFormat = "email" | "uri" | "date" | "date-time";
const specialFormatMap: Record<SpecialFormat, string> = {
email: "email",
uri: "url",
date: "date",
"date-time": "datetime-local",
};
const specialInputType =
specialFormatMap[propSchema.format as SpecialFormat];

if (specialInputType) {
return (
<Input
type={specialInputType}
value={(currentValue as string) ?? ""}
onChange={(e) => handleFieldChange(path, e.target.value)}
placeholder={propSchema.description}
required={isRequired}
minLength={propSchema.minLength}
maxLength={propSchema.maxLength}
pattern={propSchema.pattern}
/>
);
}

return (
<Input
type={inputType}
<Textarea
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Always allow setting string values, including empty strings
handleFieldChange(path, val);
}}
onChange={(e) => handleFieldChange(path, e.target.value)}
placeholder={propSchema.description}
required={isRequired}
minLength={propSchema.minLength}
maxLength={propSchema.maxLength}
pattern={propSchema.pattern}
/>
);
}
Expand Down Expand Up @@ -536,19 +556,23 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(

case "boolean":
return (
<div className="space-y-2">
<div className="space-y-1">
{propSchema.description && (
<p className="text-sm text-gray-600">
<p className="text-xs text-gray-500 dark:text-gray-400">
{propSchema.description}
</p>
)}
<Input
type="checkbox"
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={isRequired}
/>
<div className="flex items-center gap-3 py-1">
<Switch
checked={(currentValue as boolean) ?? false}
onCheckedChange={(checked) =>
handleFieldChange(path, checked)
}
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
{(currentValue as boolean) ? "True" : "False"}
</span>
</div>
</div>
);
case "null":
Expand Down Expand Up @@ -601,7 +625,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
if (!propSchema.items) return null;

// Special handling: array of enums -> render multi-select control
const itemSchema = propSchema.items as JsonSchemaType;
// Normalize items so Optional[X] (anyOf:[X,null]) is unwrapped correctly.
const itemSchema = normalizeUnionType(
propSchema.items as JsonSchemaType,
);
let multiOptions: { value: string; label: string }[] | null = null;

const titledMulti = (
Expand Down Expand Up @@ -666,56 +693,69 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
);
}

// If the array items are simple, render as form fields, otherwise use JSON editor
if (isSimpleObject(propSchema.items)) {
// Typed object items → structured form with Add/Remove; untyped → JSON fallback
const itemIsObject =
itemSchema.type === "object" && !!itemSchema.properties;

if (isSimpleObject(itemSchema) || itemIsObject) {
return (
<div className="space-y-4">
{propSchema.description && (
<p className="text-sm text-gray-600">
{propSchema.description}
</p>
)}

{propSchema.items?.description && (
<p className="text-sm text-gray-500">
Items: {propSchema.items.description}
</p>
)}

<div className="space-y-2">
{arrayValue.map((item, index) => (
<div key={index} className="flex items-center gap-2">
{renderFormFields(
propSchema.items as JsonSchemaType,
item,
[...path, index.toString()],
depth + 1,
)}
<Button
variant="outline"
size="sm"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleFieldChange(path, newArray);
}}
>
Remove
</Button>
</div>
))}
{arrayValue.map((item, index) =>
itemIsObject ? (
<div key={index} className="space-y-2">
{renderFormFields(
itemSchema,
item,
[...path, index.toString()],
depth + 1,
)}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleFieldChange(path, newArray);
}}
>
Remove
</Button>
</div>
</div>
) : (
<div key={index} className="flex items-center gap-2">
{renderFormFields(
itemSchema,
item,
[...path, index.toString()],
depth + 1,
)}
<Button
variant="outline"
size="sm"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleFieldChange(path, newArray);
}}
>
Remove
</Button>
</div>
),
)}
<Button
variant="outline"
size="sm"
onClick={() => {
const defaultValue = getArrayItemDefault(
propSchema.items as JsonSchemaType,
);
const defaultValue = getArrayItemDefault(itemSchema);
handleFieldChange(path, [...arrayValue, defaultValue]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
itemSchema.description
? `Add new ${itemSchema.description}`
: "Add new item"
}
>
Expand All @@ -726,7 +766,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
);
}

// For complex arrays, fall back to JSON editor
// For truly unstructured arrays (no type or no properties), fall back to JSON editor
return (
<JsonEditor
value={JSON.stringify(currentValue ?? [], null, 2)}
Expand Down
Loading
Loading