Skip to content

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 shapeRecommended structure
A short, conventional form whose schema order is already clearNo explicit formLayout, or a flat field list when placement must be explicit
One meaningful business subsectionA group with a title that explains the boundary
Related fields users complete or compare together and that need responsive density or spansA Grid section, normally inside the relevant group
Compact scan-and-act controls, especially a list filter with Search/ResetA section with layout: 'flow' and one embedded filter-actions block
Genuinely separate business domains or workflowstabs with one tab per independent area
A nested detail collectionOne 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:

SurfaceOwnsDoes not own
formLayoutField and renderable-block placement, Grid/flow sections, groups, responsive spans, and tabsField renderer selection, validation rules, submit policy
basic-form:blockFormLayoutResolving and rendering a structural formLayout tree in Cabloy Basic, including embedded renderable blocksPage-entry or filter action semantics
layout, formFieldLayout, FormFieldLayoutOne field's label and wrapper presentation: inline/block mode, icons, borders, header/footer, class, and styleSections, 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:

tsx
@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:blockForm bridges page-entry form state, schema, and scene into ZForm
  • basic-form:blockFormLayout places schema fields
  • basic-pageentry:blockToolbarRow owns 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:

tsx
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:

tsx
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:

text
formLayout
├─ field
├─ block
├─ section
│  ├─ field
│  └─ block
├─ group
│  ├─ field
│  ├─ block
│  ├─ group
│  └─ section
└─ tabs
   └─ tab
      ├─ field
      ├─ block
      ├─ group
      └─ section
NodeKey propertiesAllowed childrenUse it for
fieldrequired name; optional spannonePlace one resolved schema field
blockrequired block; optional spannonePlace an existing renderable resource block
sectionoptional id, title, description, layout, columnsfields and blocksA Grid or wrapping flow layout
groupoptional id, title, descriptionfields, blocks, groups, sectionsA semantic, bordered fieldset-style group
tabsoptional idtabs onlyOne tab container
taboptional id; required titlefields, blocks, groups, sectionsOne 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:

ts
{
  default?: 1 | 2 | 3 | 4,
  md?: 1 | 2 | 3 | 4,
  lg?: 1 | 2 | 3 | 4,
}
  • columns chooses how many columns a section has at each breakpoint.
  • span chooses 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, and md:col-span-2.
  • A section with no columns.default renders as one column.
  • A field with no span takes 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:

tsx
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.

tsx
{
  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:

  1. the exact eligible canonical key;
  2. any uniquely mapped preserved schema alias from schemaKey or schemaKeys;
  3. 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.

SituationResolver resultDiagnostic
Unknown or invisible field nameField is removedunknownField
Repeated field nameLater field is removedduplicateField
Repeated structural IDLater group, section, tabs, or tab is removedduplicateId
Structural node has no surviving childrenNode is removednone

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:blockFormLayout component, 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:

tsx
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:

tsx
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

  1. Identify the audience, task, and meaningful field groupings for the operation-specific DTO before choosing a structural tree.
  2. Start with DTO or resource metadata; do not hand-patch generated .zova-rest artifacts.
  3. 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.
  4. Use formLayout when the requirement is field placement, Grid or flow structure, groups, or tabs.
  5. Use layout, formFieldLayout, options, or provider behaviors when the requirement is one field's wrapper or renderer.
  6. 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.
  7. 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:blockFilterActions block. Do not add synthetic request-only filter fields, alter filter transforms, or combine it with a sibling action block.
  8. Review field names against the scene-specific schema. Unlisted visible fields are appended; unknown and duplicate declarations are silently pruned from the rendered plan.
  9. 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:

  1. vona/src/suite/a-training/modules/training-student/src/dto/studentCreate.tsx or studentSelectResItem.tsx
  2. zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/formLayout.ts
  3. zova/src/suite-vendor/a-zova/modules/a-openapi/src/lib/schema.ts
  4. zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts
  5. zova/src/suite-vendor/a-zova/modules/a-form/src/lib/formLayout.ts
  6. zova/src/suite/cabloy-basic/modules/basic-form/src/component/blockFormLayout/controller.tsx
  7. zova/src/suite-vendor/a-zova/modules/a-openapi/test/lib/schema.test.ts
  8. zova/src/suite-vendor/a-zova/modules/a-form/test/lib/formLayout.test.ts
  9. vona/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.

Released under the MIT License.