diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 685401ab..1cfe46c3 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -11,7 +11,11 @@ import { Input } from "@/components/ui/input"; import JsonEditor from "./JsonEditor"; import { updateValueAtPath } from "@/utils/jsonUtils"; import { generateDefaultValue } from "@/utils/schemaUtils"; -import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; +import type { + JsonValue, + JsonSchemaType, + JsonSchemaConst, +} from "@/utils/jsonUtils"; import { useToast } from "@/lib/hooks/useToast"; import { CheckCheck, Copy } from "lucide-react"; @@ -76,7 +80,40 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => { const DynamicJsonForm = forwardRef( ({ 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 as ReadonlyArray).includes(t) + : 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 [jsonError, setJsonError] = useState(); const [copiedJson, setCopiedJson] = useState(false); @@ -267,63 +304,81 @@ const DynamicJsonForm = forwardRef( switch (fieldType) { case "string": { - if ( - propSchema.oneOf && - propSchema.oneOf.every( - (option) => - typeof option.const === "string" && - typeof option.title === "string", - ) - ) { + // Titled single-select using oneOf/anyOf with const/title pairs + const titledOptions = ( + (propSchema.oneOf ?? propSchema.anyOf) as + | (JsonSchemaType | JsonSchemaConst)[] + | undefined + )?.filter((opt): opt is JsonSchemaConst => "const" in opt); + + if (titledOptions && titledOptions.length > 0) { return ( - +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} + +
); } + // Untitled single-select using enum (with optional legacy enumNames for labels) if (propSchema.enum) { + const names = Array.isArray(propSchema.enumNames) + ? propSchema.enumNames + : undefined; return ( - +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} + +
); } @@ -413,13 +468,20 @@ const DynamicJsonForm = forwardRef( case "boolean": return ( - handleFieldChange(path, e.target.checked)} - className="w-4 h-4" - required={isRequired} - /> +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} + handleFieldChange(path, e.target.checked)} + className="w-4 h-4" + required={isRequired} + /> +
); case "null": return null; @@ -449,7 +511,7 @@ const DynamicJsonForm = forwardRef( {Object.entries(propSchema.properties).map(([key, subSchema]) => (