@@ -76,7 +76,38 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
7676
7777const 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 (
0 commit comments