Repository navigation
Form Layout and 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_typeis still the stringgridin the database, because a field type cannot be renamed once rows exist.
| 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 |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 β
CANTIDADtoCANTIDAD SOLICITADAis 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.
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.
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."
β¬ 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.
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
numbercell β 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.
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 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.
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 |
- 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
sectionpattern twice over: presentational, excluded fromANSWERABLE_TYPES, no submission row. Images go throughincludes/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
buildRulesForSavebug above, which is that exact failure found in the only builder there is.
- 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.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96