Skip to content

Form Designer Developer Guide

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

The Form Designer β€” Developer Guide

How a form knows where its questions go, as opposed to what they are.

This is the architecture behind the planned full-screen form designer. The designer itself is not built; everything described in Parts 1–4 is, and shipped in 2.2.x. Read this before touching any of the three places a form is drawn.

Companion pages. Form Layout and the Grid covers field widths and the table question. Form Sections and Conditional Logic covers visibility. This page is about the layout model and the renderer contract.

Where this has got to

Phase State
1a One shared walk over a form's fields βœ… #1813–#1815
1b Layout becomes its own object βœ… #1816–#1817
2 (groundwork) "Is this a question?" is one decision Β· an info colour βœ… #1818–#1819
2 The note block, with named styles βœ… #1820–#1821
2 Label position β€” above or beside βœ… #1822–#1823
2 The image block βœ… #1824–#1825
3 The table question's builder, filler and exports βœ… #1826–#1831
4 The full-screen designer ⬜
5 Split and merge cells ⬜ only if 4 proves it is needed

Part 0 β€” two different things are called "table"

This caused real confusion while designing and the vocabulary is now fixed.

What it is Who makes the rows
A table question one question whose answer is many rows β€” ITEM / DESCRIPCIΓ“N / UNIDAD / CANTIDAD the person filling in the form, unbounded
The layout how the questions sit on the page the author, at design time

πŸ”‘ The word "grid" is retired as a user-facing term. The field type added in #1808 is a table question. The layout 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.)


Part 1 β€” layout is its own object

Before #1816, a question and its position were the same thing: sort_order and config.width lived on the field. So there was nothing to rearrange without editing the questions themselves.

Now:

  • form_fields is a pool of questions. Id, type, label, options, required, conditional rule, soft delete. It no longer decides where.
  • forms.layout (LONGTEXT NULL) says where they go, referencing questions by id.

Everything downstream β€” stored answers, conditional logic, the PDF, the CSV, the REST output, the portal, workflow payloads β€” addresses questions by id and never reads the layout at all.

The format

{
  "type": "flow",
  "rows": [
    { "cells": [ {"field": 12, "width": 8, "rowspan": 1},
                 {"field": 13, "width": 4, "rowspan": 1} ] },
    { "cells": [ {"field": 14, "width": 12, "rowspan": 1} ] }
  ]
}

πŸ”‘ flow and grid are the same format

A grid cell may span rows (rowspan > 1) and may hold no question at all (field: null, a spacer). A flow layout is a grid where every rowspan is 1.

This is deliberate and load-bearing. It is what makes a future split-and-merge designer a user interface rather than a rewrite: the model does not change, only the editor that writes it and the renderer's willingness to emit real rows. tests/form-layout.php asserts it, so it cannot quietly stop being true.

πŸ”‘ NULL means "derive", and NULL is every existing form

layout IS NULL is the normal case and is not a missing value β€” it means never laid out. FormsService::layoutFor() then derives a flow layout from sort_order plus each field's width, packing into rows of twelve.

That packing is the same arithmetic the CSS grid already does when it wraps, so the derived rows describe exactly what is already on the screen. That is why this shipped with no data migration and no visible change, proved by rendering every field type through all three surfaces before and after and comparing character by character.

⚠️ A flow layout emits no row elements

Its rows are structural, not markup. FormRender flattens them back to a stream of cells, because the CSS grid already wraps at twelve columns and drawing real rows would change how every existing form renders.

Real row elements arrive only when rowspan does β€” which is the one thing a wrapping grid cannot express.

Reconciliation: the layout meets the pool

layoutFor() never trusts a stored layout blindly. reconcileLayout():

  • drops a cell pointing at a question that no longer exists;
  • drops a duplicate placement, so no question is drawn (or submitted) twice;
  • appends any question the layout does not mention, as its own rows;
  • forces a bad width back to the field's own width, never to 12 β€” otherwise a half-width field placed deliberately would silently fill the row;
  • forces rowspan < 1 to 1, and an unknown type to flow.

πŸ”΄ The append is the important one. A question added after the layout was saved would otherwise never render β€” the same silent-drop failure as an untaught field type, arriving by a different road. A required question that nobody can see is a form that cannot be submitted and gives no reason why.

FormRender appends unplaced fields again, client-side. That is belt and braces on purpose: the server always reconciles, so the client should never find anything, which is exactly why it is cheap to leave in.

πŸ”΄ Two sources of order β€” the rule, in one sentence

If a layout exists it wins; if it does not, sort_order does.

sort_order remains the pool's own ordering, which is what the simple builder edits. Do not add a second place that decides order.

πŸ”΄ A new version renumbers every field

FormsService::createVersion() copies each question to a new id, deliberately, so conditional rules do not point back at the frozen original. The layout references questions by id too.

Two things are therefore required, and the second is easy to miss:

  1. layout must be named in the INSERT's column list. A per-form setting left out of that list is a setting that pressing Save deletes β€” that is how approval gating was lost before #95, and why collection_id had to be added in 2.2.0.
  2. The ids must be remapped through the same $idMap the conditional rules use.

Carried verbatim, every cell would have pointed at the previous version's questions, each would have been dropped as unreadable, and every question appended in pool order. The form would have come back looking laid out while silently being in default order β€” indistinguishable from never having been designed.

⭐ The test for this first passed for the wrong reason: its sample layout agreed with what the derivation produces anyway, so losing it was invisible. A layout that merely agrees with the derivation cannot show whether it survived. It now uses an order the derivation could never produce.

⚠️ It must work before Database Verification

FormsService::layoutAvailable() feature-detects the column, and every read is guarded by it. Every install is in that state between pulling an update and remembering to verify, and a new column that only works afterwards means a form that will not open.


Part 2 β€” one walk, three sets of markup

A form is drawn in three places:

File Looks like
Analyst filler forms/fill.php .form-field, error divs, real inputs
Self-service portal self-service/catalogue.php .cat-field, id="fN"
Builder preview forms/edit/index.php .preview-field, disabled inputs with placeholders

Before #1813 each owned its own loop, its own width handling, and its own answer to "what if I have never heard of this field type".

πŸ”‘ The split: the walk is shared, the markup is not

assets/js/form-render.js owns what is drawn, in what order, and at what width. Each surface supplies the markup for one field.

FormRender.render(fields, {
    name: 'analyst filler',
    field: (f, ctx) => {
        switch (f.field_type) {
            case 'text': return `<div class="form-field" ${ctx.wrapAttrs}>…</div>`;
            …
            default: return null;          // "I cannot draw this"
        }
    }
}, form.layout);

form-logic.js already argued against merging the markup and it is right: the three genuinely look different, and one function emitting all three would change every existing form for no benefit.

  • ctx.wrapAttrs β€” data-wrap-id plus data-width, ready to interpolate.
  • ctx.width β€” the number alone, for a surface that does not need data-wrap-id (the preview has no conditional visibility to drive).

πŸ”‘ The width comes from the CELL, not the field. For a derived layout they are identical, because the derivation reads the field's own width. Once a form has been laid out deliberately, where a question sits is a property of the layout and must win.

πŸ”‘ One edit, not eleven

Everything that applies to every field type goes into ctx, which is built once. That is how field widths covered all eleven types in a single line, and it is how label position will. Editing each case is eleven chances to miss one, and a missed case is a field that silently ignores the setting.

πŸ”΄ Returning null is how a surface says "I cannot draw this"

Before this existed, an unhandled type failed three different silent ways:

Before
Filler no default: branch at all β€” the question vanished from the form
Preview default: return '' β€” vanished
Portal default: drew a text box β€” a control that looks like it works, accepts whatever is typed, and stores it as that question's answer

The portal's was much the worst: the other two lost a question, that one took a wrong answer from a customer with nothing to tell them.

A surface now returns null and FormRender draws a visible, non-submittable notice and logs to the console. If you add a field type, you still have to teach all three surfaces β€” but forgetting is now loud.


Part 3 β€” "is this a question?" is one decision

FormsService::isAnswerable() and FormLogic.isAnswerable(), backed by PRESENTATIONAL_TYPES on each side. A test asserts the two lists agree.

This replaced field_type === 'section' written out in thirteen places across PHP, SQL and three JavaScript files. That is harmless while a heading is the only thing on a form that collects nothing, and a silent bug the moment there are two: every un-updated site treats standing text as a question β€” collected on submit, demanded if required was ticked, and a column in every export.

⚠️ The sites that are genuinely about a heading were left alone

A section owns the fields beneath it until the next section, so hiding the heading hides the group. That is about what a section is, not about whether it collects anything, and a note or an image block must not inherit it. Those checks are still === 'section' on purpose:

  • includes/form_logic.php β€” section visibility owning its block
  • assets/js/form-logic.js visibility() β€” the same rule client-side
  • the three renderers' case 'section': β€” drawing an <h2>

Deciding which of the thirteen were which was the actual work. When you add a presentational type, ask the same question of each site rather than replacing by pattern.

πŸ”‘ Written as an exclusion

presentationalSqlExclusion() produces field_type NOT IN ('section'), not a list of question types. A new question type is then included automatically and only a new presentational type has to be declared β€” the direction that fails safe. The opposite shape is what left grid out of three lists.

⚠️ ANSWERABLE_TYPES is narrower than its name

It means "what a conditional rule may depend on", not "everything that collects an answer". grid stores a real answer and is deliberately absent, because a table cannot be a condition trigger.

Reaching for it to mean "is this a question" would drop every table answer out of the submissions view. Use isAnswerable().


Part 3a β€” blocks, and why their styles are names

A block is an item on a form that is read rather than answered. section was the only one for years; note is the second, and the pattern it establishes is the one an image block and a heading block will follow.

field_type note
label the message itself, one line
config.note_body an optional longer paragraph, absent when empty
config.note_style one of plain, info, warning, danger, success

⚠️ A note is not a section. It collects nothing, like a section, but it does not own the fields below it β€” it is simply there to be read. That distinction is why the heading-specific checks in Part 3 had to stay as === 'section'.

πŸ”΄ The style is a name, never a colour

The theme defines each semantic pair twice, once per mode, and dark is not light darkened β€” the background sits darker than the page while the text goes lighter, so the pair inverts:

--info-bg  light #e0f2fe   dark #12293a
--info-text light #075985  dark #7dd3fc

A form author cannot be asked to get that right twice, and a form built by someone who was not asked is a form that looks broken at night. Named styles also keep every form looking like FreeITSM and carry all of them through a re-skin.

The names describe what the note is ("Warning"), not what it looks like ("an amber box"), so a re-skin does not turn every label into a lie.

⚠️ An unrecognised style must fall back, not pass through. FormLogic.noteStyle() returns the default for anything off the list. A value that reaches the DOM as data-note-style="amber" matches no rule and renders an unstyled panel that looks like a fault β€” and the AI generator will happily propose exactly that, so it is whitelisted server-side too.

πŸ”‘ Shared presentation is one stylesheet

assets/css/form-shared.css, loaded by all three surfaces.

The three places a form is drawn load three different stylesheets and share only theme.css, which is a token registry with no component classes in it and should stay that way.

What belongs in the shared file is presentation with no reason to differ: blocks (a notice panel is a notice panel everywhere) and label position (one authored decision that must produce the same shape on all three). What does not belong there is an input's own styling β€” those look deliberately different, and merging them would change every existing form for no gain.

It was briefly called form-blocks.css. Label position is a property of a question, not a block, so the name was already lying one commit in and was changed rather than stretched.


The image block, and the two questions it forces

field_type image
label what the picture shows β€” it becomes the alt text
config.image_path <formId>/<32 hex>.<ext>, written only by the upload endpoint
config.image_name the author's filename, display only
config.image_max 100 / 75 / 50 / 25, a percentage of the column

Files live in forms/images/<formId>/, gitignored except the .htaccess and web.config guards, which ship rather than waiting for the first upload to write them.

πŸ”΄ Serve by field id, never by path

api/forms/image.php?field=<id> reads the stored filename out of that field's own config, server-side. There is no caller-supplied string anywhere near the filesystem, so ?path=../../config.php has nothing to attach itself to β€” traversal is removed as a category rather than filtered for.

The stored value is still re-checked against the one shape the upload ever writes, in two places: validateFieldConfig() on the way in, and the serve endpoint on the way out. One check is a single point of failure, and the stored row is exactly what an attacker who reached the database would edit.

πŸ”΄ Writing and reading are different questions

May write May read
Analyst with Forms access yes any form's image
Signed-in portal customer no only a form that is in the catalogue, active and current

Conflating them is how an image on an unpublished form leaks. The portal gate is in the SQL, the same shape get_catalogue_form.php uses, so a form that is not in the catalogue is indistinguishable from one that does not exist.

Every refusal is a 404, never a 403 β€” a refusal that distinguishes "not yours" from "does not exist" tells an unauthenticated person which ids are real.

⚠️ UPLOAD_TYPES_IMAGE, not the default whitelist

The default is the ceiling for a ticket attachment and includes documents and archives. An image block can only ever be an image, so it passes the narrower constant β€” which also keeps SVG refused: it is XML, it can carry <script>, and a browser will run it if ever served inline. Branding accepts one because an administrator uploads the logo; nothing a form shows a customer should.

⚠️ Not offered to the AI generator

A model cannot upload a picture, so it would only ever propose an empty block. This is the second legitimate exclusion from tests/forms-lookup/run.php's "every type appears in all four lists" rule, after grid β€” two deliberate exceptions is a sign the rule wants teaching about them rather than the code bending to satisfy it.


Part 3b β€” label position

config.label_position, one of above (the default) or beside. Absent means above, so nothing existing changed and the default lives in one place.

beside is what produces a row reading | First name | [input] | Surname | [input] | out of two half-width questions β€” with no cell editor, which is the point. It is offered only on answerable types: a block has no control for its text to sit beside, and a picker that does nothing is worse than no picker.

πŸ”‘ One edit, three surfaces

The decision is made once β€” FormLogic.labelPosition() β€” and applied once, in FormRender's ctx.wrapAttrs. That single line put data-label-pos on every field type on all three surfaces.

Anything that applies to every field type belongs in ctx for exactly this reason. Width went there first; putting either inside a surface's switch would be eleven edits per surface and a silently unstyled twelfth type.

The CSS then needs one selector per surface, because the three nest differently β€” the filler and portal put the attribute on the field wrapper, while the preview puts it on the slot with the field one level further in. The decision is shared; only the markup it lands on differs, which is the same split the renderers follow.

πŸ”΄ Beside collapses to above on a phone

Non-negotiable, and the same rule and the same reason as every width going full below 768px: a label beside an input at 360px leaves roughly 200px for the control, and a label long enough to be worth reading wraps to three lines first.

⚠️ A single checkbox is excluded β€” it already draws its label beside the box, so beside would make it a two-column grid containing a two-column row.

⭐ Nothing had to change in the save path

buildRulesForSave() preserves every config key it does not itself rebuild, since #1806 inverted it from "copy the keys I know" to "keep everything, take over what I manage". So note_style and note_body survived a save with no change to that function at all.

That is the property to protect when adding any per-field setting: if you find yourself adding a key to a list of things to keep, the list is the wrong shape.


Part 3c β€” the table question

One question whose answer is many rows. The author defines the columns; whoever fills the form in adds the rows.

config.columns        [ {id, label, type, required, options?, deleted?} ]
config.next_column_id int
field_value           [ {"<columnId>": "value", …}, … ]   JSON, in the ONE value

πŸ”‘ Columns have a stable id, and the id is never reused

Not a position, not a label. An id survives reorder, rename and retire β€” the same rule form_fields already follows for questions, one level down.

Every mutation in the builder finds its column by id. Looking one up by index works perfectly until the first reorder and then quietly edits the wrong column, and the damage only surfaces when an old submission is read back under a heading that has moved.

next_column_id only ever goes forward, enforced in the builder and again in the service. Reusing a retired column's id would make its old answers reappear under a new heading β€” the one failure the soft delete exists to prevent.

The test fixture uses column ids 7, 3, 9, 5, 2 β€” deliberately neither sequential nor in display order. Anything addressing a cell by position fails immediately instead of passing by coincidence.

πŸ”΄ Filling in uses the LIVE columns. Reading out uses ALL of them.

Function Why
Filler, portal, preview gridLiveColumns() nobody is asked a retired question again
Detail panel, collection, PDF, CSV, REST gridColumns() a value stored against a withdrawn column must still say what it was answering

A reader that used the live list would silently lose part of an older record, with the submission still looking complete. Retired headings are marked as removed, exactly as a removed question already is.

The restricted cell palette

text, number, dropdown, choose-one, tick box, date. A file upload or a signature pad in a 200px column is unusable and per-cell conditional logic is combinatorial. Cognito Forms restricts its Table the same way and offers a separate repeating section for the rich case. Widening later is easy; narrowing after people have built forms is not.

Other decisions worth not re-deriving

  • A row nobody typed into is not an answer and is dropped on the way out. That is what lets a table start with one blank row β€” so there is somewhere to type β€” without it becoming an empty record on every submission, or making a required table look answered.
  • Each row's radios get their own group name, and a new row is built fresh rather than cloned. Cloning duplicates the name and silently joins two rows into one group.
  • The CSV gets one column per TABLE column, values joined down the rows β€” not one per row, which cannot survive a header that must be identical for every submission in the file.
  • The submissions list shows a count, not the contents: that list is one row per submission with a single value everywhere else.
  • grid is not in ANSWERABLE_TYPES β€” see Part 3. It stores a real answer but cannot be a condition trigger.
  • The table scrolls sideways on a narrow screen rather than squeezing. Six columns in 360px is four characters each.

⚠️ The Add-menu button went in LAST

Through #1808 and #1826–#1828 the type existed, validated, rendered and filled in β€” and no form could contain one, because no button created it. That was deliberate: a table you can define and fill but cannot read back on a submission, a PDF or an export takes real answers from real people and then loses them. The button is what makes a feature exist, so it goes on when the feature is whole.


Part 4 β€” the readers that have to be taught

Adding a field type means teaching every one of these. This list is the checklist, and it has grown every time something was missed.

Where What it does
FormsService::FIELD_TYPES what may be saved
FormsService::PRESENTATIONAL_TYPES whether it collects an answer
FormsService::ANSWERABLE_TYPES whether a condition may depend on it
FormLogic.TYPES / PRESENTATIONAL the client-side mirrors
forms/fill.php the analyst filler
self-service/catalogue.php the portal
forms/edit/index.php the preview, the Add menu (hardcoded buttons), typeName()'s known list, and the settings panel
assets/css/form-shared.css anything that looks the SAME on all three surfaces
forms/submissions.php the list CELL and the detail panel are two different renderings
forms/collection.php the cross-form detail panel, which is a SECOND copy of that decision
api/forms/image.php a new block type that serves a FILE needs its own authorised endpoint
lang/en/forms.php fieldtypes.*, typename.* and any per-type settings labels
assets/js/form-pdf.js the PDF β€” reads answers, not inputs
forms/submissions.php the submissions table and detail panel
CSV export one column per question
api/v1/resources/forms.php the REST shape
api/forms/ai_generate.php the generator whitelist and the prompt text
FormsService::submitForm() validation and storage of the answer
workflow payload flattening implode() is wrong for anything non-scalar

πŸ”΄ tests/forms-lookup/run.php asserts four of these agree and has been red since #1808, because grid is deliberately service-only until its screens exist. That is a known, accepted state β€” not drift. It is not in CI (CI runs only tests/db-verify-indexes/run.php).


Part 5 β€” how this gets verified

Drive the real pages; do not read the code. Every round of this work found a fault that reading had missed.

The markup comparison

The claim for a markup-preserving refactor is exact: for every field type, every surface emits what it emitted before. So render the same synthetic form through the working copy and through git show HEAD:<file>, and compare the strings.

The real functions are lifted out of the PHP page by brace-matching rather than retyped β€” a retyped copy agrees with itself while disagreeing with the page, which is the failure the harness exists to catch. Lift the SHOUTY_CASE constants by pattern too; naming them one at a time is whack-a-mole.

⚠️ Two traps that made a check lie

  • A check whose answer cannot come back negative is not a check. A JS parse checker reported "ALL OK" for a deliberately broken file, because its final "all clear" line was its own <script> and always ran. Every block must own a marker that starts at FAIL. Always run a deliberately broken control.
  • <script\b[^>]*> matches too early on <script src="<?php echo BASE_URL; ?>…">, because the PHP close tag contains >. Strip PHP tags from the whole file before splitting on <script>. The self-service pages carry their JS in a $pageScripts nowdoc and have no <script> tag at all.

πŸ”΄πŸ”΄ data-theme and data-theme-mode are different attributes

Both sit on <html> and both say "dark", and they are not interchangeable:

Attribute What reads it
data-theme="dark" theme.css β€” every token override
data-theme-mode="dark" forms.css and others β€” component rules

A probe that set only data-theme-mode reported that every note style was identical in light and dark, which looked exactly like the tokens being missing. Set both, as the real pages do.

More generally: when checking a theme-aware component, read getComputedStyle in both modes and assert three things β€” each style differs from the plain base (or its tokens are missing and it has silently fallen back), each differs from the others (or two styles are indistinguishable), and dark differs from light (or nothing is theme-aware at all).

⚠️ Check that every translation key exists

i18n.js surfaces the key on a miss, deliberately, so a gap is visible rather than blank. That means a key nobody added appears to a user as the literal text forms.field.note_style. It shipped that way on the portal once: a lookup field's placeholder read forms.fill.lookup_placeholder to customers in every language, because catalogue.php called into the forms namespace that was never exported to it (#1815).

Scrape the keys out of the source rather than listing them by hand, and remember that a key built by concatenation β€” t('forms.field.note_style_' + s) β€” cannot be scraped and has to be expanded from whatever list decides the suffixes.

πŸ”΄ Measure in an iframe, never a browser window

--window-size is not the layout viewport, so media queries ignore it. An iframe's width is. Put the same document in a 1200px and a 400px iframe and measure both; that is how the one-column rule gets proved. Serve over http://localhost so frames are same-origin β€” file:// blocks contentDocument.

Kill transitions before measuring: getBoundingClientRect includes transforms.

Accounts

The analyst account used by older form probes no longer has Forms access and lands on ?denied=forms, so any probe built on it asserts against a refusal page. The portal login is a JSON POST to api/self-service/login.php with {identifier, password} β€” not a form-encoded email field.


Part 6 β€” what comes next, and the one rule for it

Phase 4 adds a second, full-screen builder alongside the existing one. The existing builder stays: the AI-assisted path to a simple form is worth protecting.

πŸ”΄ Both edit ONE model, and the simple builder must never save away what it cannot edit. Either it preserves what it does not manage verbatim, or it declines to open the form and links to the designer. Silently dropping is the one outcome that must be impossible β€” and it is the failure this codebase has already had twice, most recently in #1806 where the builder was deleting every config key it did not recognise.

The builder's preview deliberately does not take the stored layout: it renders what is being edited right now, and the pool is the layout while the simple builder is open. When forms start carrying explicit layouts, that is the point at which the builder must refuse rather than paper over it.

Decisions still open before phase 4 can start

Three questions, none of them blocked by code β€” the model already stores everything the designer will write, so forms.layout needs no change. They are behaviour choices, and they are written here rather than left in somebody's head because the answer shapes the first screen that gets built.

Question Proposed
1 Does a question that has not been placed in the layout still appear on the form? No β€” and warn on save, rather than silently dropping it or silently appending it.
2 Deleting a slot β€” does the question return to the pool, or is it deleted outright? Back to the pool. Deleting a slot is a layout action; deleting a question should take a separate, deliberate one.
3 Where does AI generation write, now that layout is a separate object? The questions, plus a plain flow layout β€” so a generated form is immediately editable in either builder.

πŸ”΄ Whatever is decided for (1), the answer has to hold on the READ side too. FormRender.render() already appends any field the layout does not place, as belt and braces against a layout that has drifted from its questions. If the product's answer becomes "an unplaced question does not appear", that fallback becomes a silent contradiction of the rule and has to be revisited at the same time β€” not left as a surprise for whoever next reads a form that renders a question the designer said was not on it.

What is deliberately NOT built

  • Merged cells (phase 5). Held back until phase 4 proves it is wanted. The model already permits it: a cell grid is this layout plus rowspan, so it is an editor, not a migration.
  • Banded rows on a table question (None / Subtle / Stronger). Agreed in the design conversation, never implemented β€” the word appears nowhere in the codebase. Worth saying out loud, because the design notes read as though it exists.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally