Skip to content

Form Layout and Grid Developer Guide

Ed Mozley edited this page Sep 20, 2026 · 2 revisions

Form Layout and the Grid β€” Developer Guide

Two related requests from the CNSS (Dominican Republic) users:

Could the forms support multiple columns per row β€” specifically, having two or three columns so that several controls can be placed on the same line.

They would like to see a grid component added to the forms to make them more dynamic and adaptable to the institution's specific processes.

The source document is a purchase requisition: a header of paired fields (ÁREA | FECHA), a five-row table of ITEM / DESCRIPCIΓ“N / UNIDAD DE MEDIDA / CANTIDAD SOLICITADA, and three signature blocks side by side. Every element on it sits in a rectangular grid β€” nothing is freely positioned, which is the observation the whole design rests on.

πŸ”‘ The architecture moved on after this page was written. A form's layout is now its own object, separate from its questions, and the three renderers share one walk. Read The Form Designer β€” Developer Guide first; it is the current picture. Everything below about widths and the table question still holds β€” it is the detail that page does not repeat.

⚠️ Vocabulary: what this page calls "the grid" is a table question β€” one question whose answer is many rows. The layout is a separate thing and is never called a table. field_type is still the string grid in the database, because a field type cannot be renamed once rows exist.

Where this has got to

State
Field widths β€” several controls on one line βœ… shipped, #1805-#1807
Table question β€” service layer βœ… shipped, #1808-#1809
One shared walk over the fields βœ… shipped, #1813-#1815 β€” see the designer guide
Layout as its own object βœ… shipped, #1816-#1817 β€” see the designer guide
Table question β€” builder, filler, reading out ⬜ not started
Text and image blocks ⬜ not started
Full-screen designer ⬜ not started

Part 1 β€” field widths (shipped)

A field's width lives in form_fields.config.width as a number of twelfths.

12  full        9  three-quarters    8  two-thirds
 6  half        4  third             3  quarter

Why twelfths rather than a fixed set of halves and thirds. 12 divides by 1, 2, 3, 4 and 6, so every useful fraction is a whole number of columns β€” and so are the asymmetric pairs a real document wants. ÁREA | FECHA is 8 + 4, not 6 + 6.

πŸ”‘ Absent means full, and absent is every existing field

There is no migration and no schema change. A field with no width key renders full width, which is every field that predates this β€” 60 of the 66 on the development install have config = NULL entirely. Choosing Full width in the builder deletes the key rather than writing 12, so a form that never asked for a width keeps an empty config and the default stays in one place.

One edit, not eleven

forms/fill.php builds its eleven field types in a switch, and every case interpolates the same wrap variable. The width goes there, so one line covers every field type that exists and every one added later. Editing each case would have been eleven chances to miss one, and a missed case is a field that silently ignores its width.

⚠️ The portal has its own renderer (self-service/catalogue.php) with two separate return points. That is the "a reader nobody taught" risk in miniature, and it turned up in the very first step of this work.

A data attribute, not an inline style

Wrappers carry data-width="6", and CSS does the spanning:

.fill-grid > [data-width="6"] { grid-column: span 6; }

An inline style could only be overridden with !important, and the mobile layer would then have needed it on every rule. .fill-grid > [data-width="6"] and the media query's .fill-grid > [data-width] have equal specificity, so the phone wins on cascade order alone.

⚠️ minmax(0, 1fr), not 1fr: a grid track's default minimum is auto, so one long unbroken word in a narrow column would push its track past its share and break the row.

πŸ”΄ One column on a phone, always

Below 768px everything is full width. Two controls side by side on a 360px screen is worse than one, and at quarter width a label wraps to three lines before its input is even narrow. Standard practice, and non-negotiable given the work invested in the portal on a phone.

Three lists, one meaning

The permitted widths are written down three times β€” the service (what may be saved), form-logic.js (what the fillers render), and the builder (what the picker offers). tests/field-widths-agree.php asserts they agree, because three hand-maintained lists is the shape that produced the index-list drift three times over, and the failure here would be quiet: a width the picker offers but the service refuses, or one that saves but renders as full.

That test also checks every width has a partner that completes the row β€” 9 with 3, 8 with 4, 6 with 6. It does not check that each divides 12, because 9 and 8 do not and they are on the list precisely because the asymmetric pairs are the point.

πŸ”΄ Two bugs this work exposed, both pre-existing in shape

The builder was silently deleting any field setting it did not recognise. buildRulesForSave() started from {} and copied across only the four config keys it knew about, so anything else was dropped the next time somebody pressed Save. Its own comment stated the right intention but it enumerated what to keep instead of preserving what it does not manage. Width was the fifth key. Now inverted: everything survives by default and only the keys it rebuilds are taken over.

A helper added to a shared script is a cache-buster change. FormLogic.fieldWidth was added to assets/js/form-logic.js without bumping its ?v=, so browsers kept serving the copy from before it existed and every form got stuck on a blank page. The markup was fine and the script died mid-render, which is why it read as "stuck" rather than as an error.


Part 2 β€” the grid (service layer only)

A question whose answer is a table: named columns, and as many rows as the person needs.

Nothing in any screen creates, fills or displays one yet. The type is not in the builder's Add menu. That is deliberate β€” a half-visible feature is worse than an invisible one.

πŸ”‘ Columns are identified by a stable id

Not a position, and not a label. An id survives three things neither of those does:

  • Reorder β€” drag a column and it is still the same column
  • Rename β€” CANTIDAD to CANTIDAD SOLICITADA is a rename, not an orphaning of every value ever stored
  • Retire β€” mark column id 3 deleted and keep showing its answers; you cannot meaningfully retire "the third column"

This is the rule form_fields already follows one level up: conditional rules point at a field's id so a reorder cannot break them, and deleting a question sets is_deleted rather than removing the row.

Storage

Column definitions live in the field's config:

{
  "columns": [
    { "id": 1, "label": "Item",     "type": "text",     "required": true },
    { "id": 3, "label": "Unidad",   "type": "dropdown", "options": ["ea","box"] },
    { "id": 4, "label": "Cantidad", "type": "number",   "required": true, "deleted": true }
  ],
  "next_column_id": 5
}

Answers are JSON in the single form_submission_data.field_value β€” a list of rows, each a map of column id to value:

[ { "1": "Laptop", "3": "ea", "4": "2" } ]

Why JSON rather than a new table: it ships without a migration, and it composes cleanly with versioning β€” a fork copies config as-is, so v2's column ids equal v1's, which is harmless because stored values are scoped to (submission, field) and the field id differs per version.

What JSON costs: you cannot query inside it. "Every submission where any row's CANTIDAD exceeds 100" is not answerable and reporting cannot reach in. If that comes to matter, a table can be built later and populated from the JSON β€” the stable ids make that migration mechanical.

⚠️ next_column_id never goes backwards, or a retired column's id gets reused and its old answers reappear under a new heading.

The cell palette is restricted

text, number, dropdown, radio, checkbox, datetime. A textarea or a file upload is refused.

Not laziness: a file upload or a signature pad in a 200px column is unusable, and per-cell conditional logic is combinatorial. Cognito Forms, the closest comparable product, restricts its table the same way and offers a separate repeating section for the rich case β€” their own guidance is "if you need complex conditional logic, file uploads, or addresses, use repeating sections instead."

⚠️ Widening a palette later is easy. Narrowing it after people have built forms is not.

⬜ lookup is deliberately absent for now β€” a scoped search over the install's own records, behaving inside a repeating row, is its own piece of work.

What the service enforces

All of it in FormsService, not in a page, so a crafted post cannot walk around it:

  • a value for a column that does not exist β†’ refused
  • a retired column cannot be answered
  • a missing required cell β†’ refused, naming the row and column
  • a non-numeric number cell β†’ refused
  • a choice outside a dropdown's list β†’ refused (a narrowed dropdown has never been a check)
  • more than GRID_MAX_ROWS (500) β†’ refused; an abuse ceiling, not a feature limit
  • a wholly blank row is dropped, not refused β€” that is somebody pressing add and changing their mind. Dropped before the required check, or a trailing empty row would fail a required column

gridColumns() returns every column including retired ones (so a reader can label old answers); gridLiveColumns() returns only those a new row may use.

πŸ”‘ grid is NOT in ANSWERABLE_TYPES

That list decides only what a condition may depend on, and "show this when the table equals…" has no meaning. A grid does collect an answer, and everything that stores or reads one handles it.

The first reader that had to be taught

The workflow payload flattens every answer with implode() β€” right for a multi-select, wrong for rows that are objects. A grid produced "Array, Array" and a PHP warning. gridToText() now renders one labelled line per row:

Item: Laptop, Descripcion: Dell XPS, Unidad: ea, Cantidad solicitada: 2

Labels come from the full column list including retired ones, so a value stored against a withdrawn column still says what it was.


⬜ Still to come

The readers that must be taught

This is where the remaining work is. forms/fill.php has no default: branch β€” the field types are an if/else chain β€” so a type nobody taught it about does not error, it falls through to whichever branch is last. Silently. A grid rendering as a lonely text box in the portal, or arriving in the CSV as raw JSON, is the realistic failure.

File What it must do with a grid
forms/edit/index.php define columns; reorder; retire; the Add-menu entry
forms/fill.php render the table, add/remove rows
api/self-service/get_catalogue_form.php + self-service/catalogue.php the same, in the portal
forms/submissions.php the list column, the detail panel, and the CSV
forms/collection.php the same, across forms
assets/js/form-pdf.js draw a real table β€” autoTable is already vendored
api/v1/resources/forms.php, openapi_schemas.php the REST shape
includes/catalogue_approvals.php an approver reading a submission
api/forms/ai_generate.php must not invent a type it cannot configure

Decided but not built

  • CSV shape: one column per grid column, rows joined inside each cell. A stable header that does not depend on how many rows anybody typed. Two distinct separators (one between cells, one between rows), in Settings rather than hard-coded, because a multi-select cell is already a list and they must not collide.
  • A warning, not a gate, when an edit would retire a column on a form that already has submissions β€” offering "make a new version instead" as a one-click alternative.
  • Text and image blocks β€” the section pattern twice over: presentational, excluded from ANSWERABLE_TYPES, no submission row. Images go through includes/uploads.php; SVG is not allowed and must not become so for this.
  • A second, full-screen designer modelled on the Network Mapper's shell, kept alongside the existing builder rather than replacing it, so the AI-assisted quick path survives. πŸ”΄ Both must edit one model, and the simple builder must never save away what it cannot edit β€” see the buildRulesForSave bug above, which is that exact failure found in the only builder there is.

Deliberately not built

  • Totals. Adding them later is free: cells already carry a type, and a total should be computed when shown rather than stored β€” a stored total that disagrees with its rows is worse than none.
  • Free positioning. Incompatible with an add-as-you-go grid (a table whose height is unknown until it is filled in cannot sit above anything pinned), and it cannot survive a phone. No mainstream form builder offers it.
  • Forcing a new version when a form has submissions. Versioning already isolates data, but forcing a fork on every edit fragments submissions across versions, taxes every typo fix, and gives each fork another chance to drop a per-form setting.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally