@@ -34,6 +34,14 @@ import {
3434 FIELD_SUBTYPE_DATE ,
3535 FIELD_SUBTYPE_TIME ,
3636 FIELD_SUBTYPE_TIMESTAMP ,
37+ FIELD_ATTR_FORM_EXCLUDE ,
38+ FIELD_ATTR_VALUES ,
39+ FIELD_ATTR_REQUIRED ,
40+ FIELD_SUBTYPE_ENUM ,
41+ VIEW_SUBTYPE_TEXTAREA ,
42+ VIEW_SUBTYPE_DROPDOWN ,
43+ VIEW_SUBTYPE_CHECKBOX ,
44+ VIEW_SUBTYPE_RADIO ,
3745} from "@metaobjectsdev/metadata" ;
3846import { type RenderContext , entityModuleSpecifier , GENERATED_HEADER , tphDiscriminatorPin } from "@metaobjectsdev/codegen-ts" ;
3947
@@ -69,7 +77,7 @@ function visibleFields(entity: MetaObject, discField?: string): MetaField[] {
6977 // fields() returns effective fields, so inherited fields (from extends:/super:) are included in forms.
7078 for ( const child of entity . fields ( ) ) {
7179 // ADR-0039: resolving — a field may inherit @formExclude via extends.
72- if ( child . attr ( "formExclude" ) === true ) continue ;
80+ if ( child . attr ( FIELD_ATTR_FORM_EXCLUDE ) === true ) continue ;
7381 if ( pkNames . has ( child . name ) ) continue ;
7482 if ( isAutoManaged ( child ) ) continue ;
7583 if ( discField !== undefined && child . name === discField ) continue ;
@@ -256,6 +264,15 @@ ${inner.join("\n")}
256264 } ;
257265}
258266
267+ /** The view kind that drives control selection: an explicit view child wins;
268+ * an enum with no view defaults to a dropdown; otherwise null (typed <input>). */
269+ function viewKindFor ( field : MetaField ) : string | null {
270+ const view = field . views ( ) [ 0 ] ; // resolving accessor (ADR-0039)
271+ if ( view !== undefined ) return view . subType ;
272+ if ( field . subType === FIELD_SUBTYPE_ENUM ) return VIEW_SUBTYPE_DROPDOWN ;
273+ return null ;
274+ }
275+
259276export function renderFormFile ( entity : MetaObject , ctx : RenderContext ) : string {
260277 const entityName = entity . name ;
261278 // Import the entity's own file. Same target → relative "./Entity"; cross
@@ -295,6 +312,67 @@ export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
295312 )}
296313 </div>` ;
297314
315+ // Shared label + control + error wrapper used by the view-kind dispatch
316+ // branches below (select / textarea / checkbox / radio). `scalarBlock`
317+ // stays untouched as the plain-<input> fallback (bound via `form.input.*`).
318+ const labelAndError = ( f : string , control : string ) =>
319+ ` <div className="metaobjects-field" key=${ JSON . stringify ( f ) } >
320+ <label className="metaobjects-field-label" htmlFor={${ entityName } .${ f } .name}>
321+ {${ entityName } .${ f } .label}
322+ </label>
323+ ${ control }
324+ {form.formState.errors.${ f } !== undefined && (
325+ <span className="metaobjects-field-error" role="alert">
326+ {String(form.formState.errors.${ f } ?.message ?? '')}
327+ </span>
328+ )}
329+ </div>` ;
330+
331+ // Dispatch a visible scalar field to its control by declared view kind: an
332+ // explicit view.textarea/checkbox/radio child, or an enum defaulting to a
333+ // dropdown when it carries no explicit view. Everything else falls back to
334+ // scalarBlock's typed <input> bound via `form.input.<field>`.
335+ const fieldControlFor = ( field : MetaField ) : string => {
336+ const name = field . name ;
337+ const kind = viewKindFor ( field ) ;
338+ // Enum member symbols are validated to /^[A-Za-z_][A-Za-z0-9_]*$/, so raw
339+ // interpolation into JSX attribute/text positions is safe (no escaping).
340+ if ( kind === VIEW_SUBTYPE_DROPDOWN ) {
341+ const values = ( field . attr ( FIELD_ATTR_VALUES ) as string [ ] | undefined ) ?? [ ] ;
342+ const required = field . attr ( FIELD_ATTR_REQUIRED ) === true ;
343+ const empty = required ? "" : ` <option value="">Select…</option>\n` ;
344+ const options = values . map ( ( v ) => ` <option value="${ v } ">${ v } </option>` ) . join ( "\n" ) ;
345+ return labelAndError (
346+ name ,
347+ ` <select className="metaobjects-field-input" {...form.register("${ name } ")}>\n${ empty } ${ options } \n </select>` ,
348+ ) ;
349+ }
350+ if ( kind === VIEW_SUBTYPE_TEXTAREA ) {
351+ // Configurable @rows is deferred (see the spec's Deferred work); default to 4.
352+ return labelAndError (
353+ name ,
354+ ` <textarea className="metaobjects-field-input" rows={4} {...form.register("${ name } ")} />` ,
355+ ) ;
356+ }
357+ if ( kind === VIEW_SUBTYPE_CHECKBOX ) {
358+ return labelAndError (
359+ name ,
360+ ` <input type="checkbox" className="metaobjects-field-checkbox" {...form.register("${ name } ")} />` ,
361+ ) ;
362+ }
363+ if ( kind === VIEW_SUBTYPE_RADIO ) {
364+ const values = ( field . attr ( FIELD_ATTR_VALUES ) as string [ ] | undefined ) ?? [ ] ;
365+ const radios = values
366+ . map (
367+ ( v ) =>
368+ ` <label className="metaobjects-field-radio"><input type="radio" value="${ v } " {...form.register("${ name } ")} /> ${ v } </label>` ,
369+ )
370+ . join ( "\n" ) ;
371+ return labelAndError ( name , ` <fieldset className="metaobjects-field-radios">\n${ radios } \n </fieldset>` ) ;
372+ }
373+ return scalarBlock ( name ) ;
374+ } ;
375+
298376 // For each visible field: scalars keep the flat `form.input.<field>` block; a
299377 // `field.object` with a resolvable `@objectRef` recurses into the referenced
300378 // value object as a nested <fieldset> sub-form (issue #95). useFieldArray
@@ -303,7 +381,7 @@ export function renderFormFile(entity: MetaObject, ctx: RenderContext): string {
303381 const fieldArrayHooks : string [ ] = [ ] ;
304382 for ( const f of fields ) {
305383 if ( resolveValueObject ( f , ctx ) === undefined ) {
306- blocks . push ( scalarBlock ( f . name ) ) ;
384+ blocks . push ( fieldControlFor ( f ) ) ;
307385 continue ;
308386 }
309387 const r = renderNestedField ( f , f . name , false , ctx , new Set < string > ( ) , 0 ) ;
@@ -357,9 +435,11 @@ export function ${entityName}Form(props: ${entityName}FormProps): ${ReactElement
357435 onSubmit={form.handleSubmit(props.onSubmit as never)}
358436 >
359437${ fieldBlocks }
360- <button type="submit" disabled={form.formState.isSubmitting}>
361- {props.submitLabel ?? 'Submit'}
362- </button>
438+ <div className="metaobjects-form-actions">
439+ <button className="metaobjects-form-submit" type="submit" disabled={form.formState.isSubmitting}>
440+ {props.submitLabel ?? 'Submit'}
441+ </button>
442+ </div>
363443 </form>
364444 );
365445}
0 commit comments