Form Layout Guide
Use Form Layout when a schema-driven resource form needs a deliberate structure: field order, Grid or flow sections, groups, or tabs.
This guide covers the structural layout contract authored in DTO metadata and rendered through basic-form:blockFormLayout. It does not replace Form Guide, which explains ZForm, field rendering, validation, and manual or mixed forms.
TIP
Cabloy Basic implements the renderer described here with DaisyUI and Tailwind CSS. The formLayout contract and its resolver are shared Zova surfaces, but group, Grid, flow, and tab presentation are Basic-specific. Do not assume Cabloy Start uses identical markup or styling.
Choose the layout from the business form
Start with the user's task, information hierarchy, and audience—not with the available layout node types or the number of fields. The generated CRUD layout is a valid baseline to review and refine, not a requirement to preserve unchanged.
Choose the smallest structure that communicates the business form:
| Business shape | Recommended structure |
|---|---|
| A short, conventional form whose schema order is already clear | No explicit formLayout, or a flat field list when placement must be explicit |
| One meaningful business subsection | A group with a title that explains the boundary |
| Related fields users complete or compare together and that need responsive density or spans | A Grid section, normally inside the relevant group |
| Compact scan-and-act controls, especially a list filter with Search/Reset | A section with layout: 'flow' and one embedded filter-actions block |
| Genuinely separate business domains or workflows | tabs with one tab per independent area |
| A nested detail collection | One normal field; its field renderer owns the detail UI |
Do not select tabs merely because a form has many fields, and do not add a structural tree merely because the API supports one. A group communicates a semantic boundary; a section solves Grid or flow placement. Field renderer and wrapper requirements remain separate concerns, as explained in the next section.
Create, update, and view can reuse one layout when they present the same information hierarchy. When their tasks differ—for example, a focused creation workflow versus a review-oriented view—author scene-specific DTO layouts instead of forcing a shared tree. Likewise, choose entry-form and filter layouts independently: a compact filter is not a smaller entry form.
When the business grouping is unknown, keep the simpler layout or clarify the grouping before introducing groups or tabs. See the complete entry-form example for a justified tabbed business form and the complete filter-form example for the maintained compact filter convention.
The layout layers are different
Several APIs contain the word “layout,” but they own different concerns:
| Surface | Owns | Does not own |
|---|---|---|
formLayout | Field and renderable-block placement, Grid/flow sections, groups, responsive spans, and tabs | Field renderer selection, validation rules, submit policy |
basic-form:blockFormLayout | Resolving and rendering a structural formLayout tree in Cabloy Basic, including embedded renderable blocks | Page-entry or filter action semantics |
layout, formFieldLayout, FormFieldLayout | One field's label and wrapper presentation: inline/block mode, icons, borders, header/footer, class, and style | Sections, Grid/flow placement, groups, or tabs |
For example, formFieldLayout: { inline: false } makes each field use a block-style wrapper. It does not create a grid. Pair it with basic-form:blockFormLayout when the fields also need structural placement.
Read Form Guide for field wrapper and provider customization, and Behavior Guide for the FormFieldLayout behavior pipeline.
Compose the layout through resource blocks
For resource forms, author structural layout in backend DTO metadata with ZovaRender.block(...). The helper creates contract metadata; it does not render the form itself. Zova later consumes the generated schema metadata and renders the registered blocks.
Entry form composition
A resource entry DTO normally nests the layout block inside the page-entry form block:
@Dto({
blocks: [
ZovaRender.block('basic-pageentry:blockPageEntry', {
blocks: [
ZovaRender.block('basic-pageentry:blockForm', {
blocks: [
ZovaRender.block('basic-form:blockFormLayout', {
formLayout: {
children: [/* fields, groups, sections, or tabs */],
},
}),
],
}),
ZovaRender.block('basic-pageentry:blockToolbarRow', {
actions: [/* Submit, Back, and other page-entry actions */],
}),
],
}),
],
})The responsibilities stay separate:
- the DTO supplies the structural contract metadata
basic-pageentry:blockFormbridges page-entry form state, schema, and scene intoZFormbasic-form:blockFormLayoutplaces schema fieldsbasic-pageentry:blockToolbarRowowns page-entry actions and their scene/permission rules
Form Layout changes neither readonly behavior nor actions. Create, update, and view scenes can reuse the same structure while the normal form and page-entry pipelines decide field state and available actions. See Resource Entry Page Deep Dive for the wider entry-page runtime.
Filter form composition
A filter uses the filter block as its host. Place Search/Reset inside the structural layout when they should participate in the same Grid or flow as filter fields:
ZovaRender.block('basic-page:blockFilter', {
formFieldLayout: { inline: false },
blocks: [
ZovaRender.block('basic-form:blockFormLayout', {
formLayout: {
children: [
{
type: 'section',
layout: 'flow',
children: [
/* filter fields */
{
type: 'block',
block: ZovaRender.block('basic-page:blockFilterActions'),
},
],
},
],
},
}),
],
});The renderable block node controls only structural placement. basic-page:blockFilterActions still owns Search/Reset and obtains the filter command surface from the inherited form scope, preserving normalization and page-query handoff. A nonempty blocks list replaces ZForm's automatic body and footer.
The legacy sibling composition remains supported when actions do not need to share a structural section:
blocks: [
ZovaRender.block('basic-form:blockFormLayout', { formLayout }),
ZovaRender.block('basic-page:blockFilterActions'),
];Use either the embedded layout block or the legacy sibling action block, never both; otherwise Search and Reset are rendered twice. Maintained Cabloy Basic list DTOs and current Basic CRUD output prefer the embedded flow, while a bare filter remains the minimal/default form when structural placement is unnecessary. See Table + Resource CRUD Cookbook for the filter ownership model.
Layout node grammar
formLayout has a root children array. The node types deliberately have limited nesting rather than arbitrary recursion:
formLayout
├─ field
├─ block
├─ section
│ ├─ field
│ └─ block
├─ group
│ ├─ field
│ ├─ block
│ ├─ group
│ └─ section
└─ tabs
└─ tab
├─ field
├─ block
├─ group
└─ section| Node | Key properties | Allowed children | Use it for |
|---|---|---|---|
field | required name; optional span | none | Place one resolved schema field |
block | required block; optional span | none | Place an existing renderable resource block |
section | optional id, title, description, layout, columns | fields and blocks | A Grid or wrapping flow layout |
group | optional id, title, description | fields, blocks, groups, sections | A semantic, bordered fieldset-style group |
tabs | optional id | tabs only | One tab container |
tab | optional id; required title | fields, blocks, groups, sections | One tab panel |
A section is a layout boundary. It uses the Grid strategy by default; set layout: 'flow' for compact, left-packed fields and blocks that wrap at their intrinsic widths. Use a group when the fields need a semantic or visual boundary, and place a section inside that group when it also needs Grid columns or flow placement. There is no separate row node: Grid and flow placement create rows automatically.
A block node is not a schema field and does not add a request, response, validation, or query value. It wraps an existing ZovaRender.block(...) descriptor and renders it with the current form JSX/CEL context. This lets a filter action block participate in a flow section without transferring filter-action behavior into Form Layout.
Nested tabs are not part of the current contract. Likewise, a section cannot contain a group or another section.
Grid sections and field spans
Grid is the default section strategy: omit layout or set layout: 'grid'. Both section.columns and field.span use the same responsive shape:
{
default?: 1 | 2 | 3 | 4,
md?: 1 | 2 | 3 | 4,
lg?: 1 | 2 | 3 | 4,
}columnschooses how many columns a section has at each breakpoint.spanchooses how many of those columns a field occupies.- In Cabloy Basic, these become Tailwind classes such as
grid-cols-2,md:grid-cols-2,col-span-2, andmd:col-span-2. - A section with no
columns.defaultrenders as one column. - A field with no
spantakes its normal grid cell.
The following compact filter structure becomes one column by default, two columns at md, and makes createdAt use both medium columns:
formLayout: {
children: [
{
type: 'section',
columns: { default: 1, md: 2 },
children: [
{ type: 'field', name: 'name' },
{ type: 'field', name: 'level' },
{ type: 'field', name: 'createdAt', span: { default: 1, md: 2 } },
],
},
],
},Flow sections
Set layout: 'flow' when compact fields should appear from left to right without being distributed across equal Grid columns. Cabloy Basic renders a flow section as a wrapping flex layout, so each field keeps its renderer-defined width and moves to the next line only when space runs out.
{
type: 'section',
layout: 'flow',
children: [
{ type: 'field', name: 'name' },
{ type: 'field', name: 'level' },
{ type: 'field', name: 'createdAt' },
],
}columns and span are Grid-only settings. Omit them from flow sections; they do not determine flex widths or offsets. Use formFieldLayout.inline independently when the fields themselves should use compact inline wrappers.
How the resolver handles the declared tree
Before rendering, resolveFormLayout(...) reconciles formLayout with the current scene's resolved schema properties. This makes the declaration a placement overlay, not an allow-list. The preceding OpenAPI normalization step is explained in OpenAPI Runtime Under the Hood.
Eligible and omitted fields
Only schema properties with rest.visible !== false are eligible. When an eligible visible field is absent from formLayout, the resolver appends it as a root-level field after the declared nodes, in schema-property order.
A field that uses fieldSource is represented at runtime by its nested canonical source key. The loader stores that source path in key, retains the first differing original schema name in schemaKey, and retains further coalesced names in schemaKeys. A property already named by its canonical key retains that identity in key. These preserved names are aliases for declaration matching, not separate fields or bindings.
Form Layout accepts three declaration forms in precedence order:
- the exact eligible canonical key;
- any uniquely mapped preserved schema alias from
schemaKeyorschemaKeys; - a unique relation-prefix shorthand.
For example, a real relation declaration studentContentForm can resolve to the sole studentContentForm.descriptionMarkdown source, while an intentional fieldSource schema alias such as _descriptionMarkdown can resolve to content.descriptionMarkdown even though it is not that source path's prefix. The resolved plan always renders and binds the canonical nested key. fieldSource(...) is a binding and canonical-key mapping mechanism; it neither infers nor declares the backend DTO field type. A projected field already has a schema, while a true virtual DTO key absent from the inferred projection must be defined with $makeSchema(...) and a concrete schema such as z.string(); see Virtual fields in the DTO fields map.
An alias or relation prefix must resolve to exactly one eligible canonical source. If multiple visible source keys match, it is unresolved and receives unknownField; declare the exact canonical source key instead. Exact canonical matches win over colliding aliases. Invisible properties contribute neither eligible fields nor usable aliases.
Duplicate declarations are detected by canonical key, so an alias and content.descriptionMarkdown cannot render the same field twice. Resolved field names, duplicate identity, and tab paths all use the canonical key. If a declaration is removed because it is unknown, invisible, ambiguous, or duplicate, otherwise unplaced eligible canonical fields are still appended at the root.
If a field must not render, make it invisible in schema metadata. Leaving it out of formLayout.children is not enough.
Invalid declarations and diagnostics
The resolver keeps the first occurrence of each field and removes later duplicates. It also removes field names that are not eligible in the current schema scene.
| Situation | Resolver result | Diagnostic |
|---|---|---|
| Unknown or invisible field name | Field is removed | unknownField |
| Repeated field name | Later field is removed | duplicateField |
| Repeated structural ID | Later group, section, tabs, or tab is removed | duplicateId |
| Structural node has no surviving children | Node is removed | none |
Groups, sections, tab containers, and tabs receive an ID even when the DTO omits one. The resolver derives it from the node type and index path, for example tabs-0 or section-0-1-0. Omit IDs for simple static layouts; provide explicit IDs when external state, diagnostics, tests, or future extensions need a stable structural reference.
WARNING
Diagnostics are returned in the resolved layout plan, but the current basic-form:blockFormLayout renderer does not display or log them. Treat field names and explicit IDs as metadata that must be reviewed and tested, rather than expecting a visible authoring error at runtime.
Tabs and validation feedback
The Basic renderer keeps active-tab state locally for each tabs node. If no saved active tab remains valid, it falls back to the first surviving tab.
It renders native tab buttons and panels with role="tablist", role="tab", role="tabpanel", aria-selected, aria-controls, and aria-labelledby relationships. A tab receives an error badge when fields below it have validation errors. The badge counts fields with errors, not individual error messages.
Field error messages remain the responsibility of the field-layout behavior. The structural layout only summarizes their presence on tabs.
Current behavior boundaries:
- an error badge does not automatically activate or focus the tab containing the invalid field
- tab state belongs to the
basic-form:blockFormLayoutcomponent, not the form globally - the contract does not support nested tabs
- Form Layout does not select field renderers, change field visibility, or alter validation and submit policies
Complete entry-form example
The Student create DTO is the canonical complete example. It uses optional structural IDs, two tabs, separate semantic groups for profile and companion content, a responsive profile section, and a nested-details field:
ZovaRender.block('basic-pageentry:blockForm', {
blocks: [
ZovaRender.block('basic-form:blockFormLayout', {
formLayout: {
children: [
{
type: 'tabs',
children: [
{
type: 'tab',
title: $locale('BasicInformation'),
children: [
{
type: 'group',
title: $locale('StudentProfile'),
children: [
{
type: 'section',
columns: { default: 1, md: 2 },
children: [
{ type: 'field', name: 'name' },
{ type: 'field', name: 'mobile' },
{ type: 'field', name: 'imageId' },
],
},
],
},
{
type: 'group',
title: $locale('StudentContent'),
children: [
{
type: 'section',
children: [{ type: 'field', name: 'studentContentForm' }],
},
],
},
],
},
{
type: 'tab',
title: $locale('TrainingRecords'),
children: [
{ type: 'field', name: 'level' },
{
type: 'section',
children: [{ type: 'field', name: 'trainingRecords' }],
},
],
},
],
},
],
},
}),
],
});studentContentForm is one projected, fieldSource-mapped nested-relation field in the structural tree. It remains the DTO-facing declaration name, while the resolved plan rewrites it to studentContentForm.descriptionMarkdown before field-state lookup and rendering. Its Markdown renderer owns the nested source-field UI, while the separate StudentContent group expresses that it is a distinct content area rather than part of the responsive profile Grid.
trainingRecords is one field in the structural tree. Its basic-details:formFieldDetails renderer owns the nested details UI; Form Layout does not recursively arrange the properties inside each detail record.
Student update and view DTOs use the same structural shape. The normal form scene controls readonly behavior, while the page-entry toolbar decides whether Submit, Back, or other actions are available.
Complete filter-form example
The Student list filter combines field-wrapper and structural layout concerns:
ZovaRender.block('basic-page:blockFilter', {
formFieldLayout: { inline: true },
blocks: [
ZovaRender.block('basic-form:blockFormLayout', {
formLayout: {
children: [
{
type: 'section',
layout: 'flow',
children: [
{ type: 'field', name: 'name' },
{ type: 'field', name: 'level' },
{ type: 'field', name: 'createdAt' },
{
type: 'block',
block: ZovaRender.block('basic-page:blockFilterActions'),
},
],
},
],
},
}),
],
});Here formFieldLayout.inline: true controls how each field wrapper is presented. The flow section keeps fields and the action block left-packed and wraps them together when necessary. basic-page:blockFilterActions remains required because the custom blocks replace automatic filter body/footer content, but it is now placed through the structural layout rather than as a sibling block.
Authoring checklist
- Identify the audience, task, and meaningful field groupings for the operation-specific DTO before choosing a structural tree.
- Start with DTO or resource metadata; do not hand-patch generated
.zova-restartifacts. - Use no structural layout when schema order is sufficient. Otherwise use the smallest layout that communicates the business structure; reserve tabs for genuinely separate business domains or workflows.
- Use
formLayoutwhen the requirement is field placement, Grid or flow structure, groups, or tabs. - Use
layout,formFieldLayout,options, or provider behaviors when the requirement is one field's wrapper or renderer. - Keep entry actions in page-entry toolbar blocks. Keep filter action semantics in
basic-page:blockFilterActions; place that block inside Form Layout when the actions must share structural Grid or flow placement with fields. - For maintained Cabloy Basic list filters, prefer one inline flow section that explicitly lists every real filter-schema field in schema order and ends with one embedded
basic-page:blockFilterActionsblock. Do not add synthetic request-only filter fields, alter filter transforms, or combine it with a sibling action block. - Review field names against the scene-specific schema. Unlisted visible fields are appended; unknown and duplicate declarations are silently pruned from the rendered plan.
- Prefer an exact canonical source path when an alias or relation prefix could match multiple visible fields. Do not list both an alias and its canonical key; canonical duplicate detection keeps only the first declaration.
Source-reading and verification path
For source-level investigation, follow this order:
vona/src/suite/a-training/modules/training-student/src/dto/studentCreate.tsxorstudentSelectResItem.tsxzova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/formLayout.tszova/src/suite-vendor/a-zova/modules/a-openapi/src/lib/schema.tszova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.tszova/src/suite-vendor/a-zova/modules/a-form/src/lib/formLayout.tszova/src/suite/cabloy-basic/modules/basic-form/src/component/blockFormLayout/controller.tsxzova/src/suite-vendor/a-zova/modules/a-openapi/test/lib/schema.test.tszova/src/suite-vendor/a-zova/modules/a-form/test/lib/formLayout.test.tsvona/src/suite/a-training/modules/training-student/test/student.test.ts
The OpenAPI loader test verifies canonical fieldSource keys, preserved aliases, scene overlays, coalescing, and declaration-order independence. The Form Layout test verifies canonical rewriting, alias and prefix resolution, ambiguity, duplicate detection, visibility, and tab paths. The Student test verifies that entry and filter DTO metadata preserves the current block nesting, optional IDs, Grid columns/spans, flow layout selection, and field order through OpenAPI generation. It is a contract-metadata test, not a browser end-to-end assertion for tabs, layout behavior, or error badges.
For the broader form runtime, continue with Zova Form Under the Hood and Zova Form Source Reading Map.