-
Notifications
You must be signed in to change notification settings - Fork 15
Form Sections and Conditional Logic Developer Guide
How section headings, conditional visibility and date fields work, why field identity had to be fixed before any of them could be built, and which decisions are load-bearing.
Shipped as #940 (sections), #941 (conditions), #942 (field identity), #943 (the submissions access gate) and #944 (date / time fields).
The user-facing page is Forms.
Colour key: ποΈ schema Β· βοΈ engine Β· π API Β· π₯οΈ UI Β· π¨ CSS Β· π i18n Β· π§ͺ tests Β· π docs
| π¨ | File | What it does |
|---|---|---|
| ποΈ | database/freeitsm.sql |
form_fields.config (LONGTEXT NULL) + form_fields.is_deleted (TINYINT DEFAULT 0) |
| ποΈ | includes/db_verify_schema.php |
the same two columns, so an upgrade picks them up |
| βοΈ | includes/form_logic.php |
the source of truth. formLogicVisibility(), formLogicTestRule(), formLogicNormaliseValue()
|
| βοΈ | includes/services/forms.php |
FIELD_TYPES (+section, +datetime), ANSWERABLE_TYPES, CONDITION_OPS, DATE_MODES, DATE_MODE_PATTERNS, dateModeOf(); validateFields(), validateFieldConfig(), syncFields() (now id-based), submitForm() visibility + date-format gate, createVersion() id remap |
| βοΈ | assets/js/form-logic.js |
the browser mirror β FormLogic.visibility/testRule/parseOptions/hasOptions/dateMode/dateInputType/formatDateValue
|
| π | api/forms/get_form.php |
returns config; excludes is_deleted = 1
|
| π | api/forms/get_submissions.php |
includes retired fields (with is_deleted), excludes sections; gained requireModuleAccessJson('forms') (#943) |
| π | api/forms/ai_generate.php |
section + datetime in the allowed types, the date_mode sanitiser, and the system prompt that documents both |
| π | api/self-service/get_catalogue_form.php |
returns config; excludes retired fields |
| π | api/v1/resources/forms.php |
serialises config; excludes retired fields; docblock records the sync change |
| π₯οΈ | forms/edit/index.php |
the builder β _key identity, the Only show this whenβ¦ editor, section rows, buildRulesForSave(), pruneInvalidConditions(), AI-apply id carry-over |
| π₯οΈ | forms/fill.php |
section rendering, readField() / collectValues() / applyVisibility(), visible-only validation |
| π₯οΈ | forms/submissions.php |
the removed marker on retired columns; date display + CSV via FormLogic.formatDateValue()
|
| π₯οΈ | self-service/catalogue.php |
the portal's renderer β sections, applyVisibility(), hidden answers excluded from submit |
| π₯οΈ | self-service/includes/footer.php |
$needsFormLogic loads assets/js/form-logic.js for the portal |
| π¨ | assets/css/forms.css |
.field-item-section, .field-conditions, .cond-row, .preview-section, .preview-cond, .col-retired
|
| π |
lang/en/forms.php + lang/pt-BR/forms.php
|
the cond block, fieldtypes.section, typename.section, field.section_ph, preview.conditional*, subs.retired* β same commit, 446 keys each |
| π§ͺ | tests/forms-logic/run.php |
56 assertions; runs one case table through both evaluators |
| π |
CHANGELOG.local.md, this wiki |
#940β#943 |
There is no new table. A section is a form_fields row and a condition is JSON on that row, mirroring how is_portal_visible was added to forms.
Before #942, syncFields() matched a form's fields by position:
// the OLD code
foreach ($fields as $i => $f) {
if ($i < count($existingIds)) {
$upd->execute([...$f..., $existingIds[$i]]); // "the i-th existing row"
}
}form_submission_data.field_id points at a form_fields.id. Updating those rows in payload order means dragging a question rewrites the labels while the stored answers stay put. A form answered Alice Brown / 14 Elm Road / 52000 against Employee name / Home address / Starting salary reported, after moving Starting salary to the top, that the person's salary was "Alice Brown".
It was silent, and forms/submissions.php builds its columns from the current field list β so the table stayed perfectly well-formed and merely showed the wrong answers under each heading. Removing a middle field was worse: it shifted everything up and hard-deleted the trailing row's form_submission_data.
This also blocked conditions outright. A rule saying "show X when Y is Yes" has to name Y by something that survives a drag; "the third question" does not.
The fix: every payload row carries the id it is editing, a row with no id is genuinely new, and a field that disappears from the payload is soft-deleted rather than dropped.
$id = $f['id'] ?? null;
if ($id !== null && isset($ownIds[$id])) {
$upd->execute([...$f..., $i, $id, $formId]); // update THAT field
} else {
$ins->execute([$formId, ...$f..., $i]);
$id = (int)$conn->lastInsertId();
}$ownIds is the set of ids that actually belong to this form. A payload id outside it is treated as new rather than trusted β otherwise a crafted save could re-point another form's field, and with it another form's answers.
Field identity is expressed three different ways, and mixing them up is the easiest mistake to make here:
| Where | Looks like | Why |
|---|---|---|
| Database + all read paths | form_fields.id |
the only stable, persistent identity |
| Save payload | an integer id, or "idx:N"
|
a rule may point at a field being created by this same save, which has no id yet |
Builder (forms/edit/index.php) |
_key, a session counter |
a new field has no id, and an index changes the moment anything is dragged |
validateFields() accepts both payload forms; syncFields() resolves "idx:N" to a real id in a second pass, once pass 1 has minted every id. On the builder side rehydrateRuleRefs() converts ids β _key after a load, and buildRulesForSave() converts _key β id-or-idx:N on save.
A section is a form_fields row with field_type = 'section'.
That looks impure β it is presentational, and it lives in a table of questions β but it buys two things that a separate form_sections table would have cost:
-
One flat
sort_orderstill describes the whole form. No "section 2, position 3" composite ordering, no ambiguity about where a sectionless field sits. -
The builder's existing drag-and-drop keeps working untouched.
onFieldDrop()splices one array; cross-container HTML5 drag between section boxes would have been a rewrite of the most fiddly code in the module.
Membership is positional: a section owns every field after it until the next section. FormsService::ANSWERABLE_TYPES is FIELD_TYPES minus section, and that constant is what keeps a heading out of validation, submission and the workflow payload.
A date question is field_type = 'datetime' with config.date_mode of date, time or datetime.
π One type with a mode, not three types (date / time / datetime) β and the reason is Β§2, not aesthetics. field_type cannot be changed once a field exists: there is no type control in the builder, only a read-only badge. Three separate types would therefore force an irreversible choice at add-time, and discovering a month later that you also needed the time would mean deleting the field β which now retires it and strands every answer already given to it under a separate column. A mode is a setting; the field keeps its id.
| Mode | Input | Stored |
|---|---|---|
date (default) |
<input type="date"> |
2026-08-14 |
time |
<input type="time"> |
09:30 |
datetime |
<input type="datetime-local"> |
2026-08-14T09:30 |
Stored values are exactly what the browser's own picker produces, validated server-side against DATE_MODE_PATTERNS. FormsService::dateModeOf() supplies the default, so a field saved by an adapter that never sent a mode still behaves.
date_mode is stripped from any non-datetime field in validateFieldConfig(), so it can never sit on a text field looking as though it means something.
This is the trap the feature exists around. Most datetimes in FreeITSM are instants: stored UTC, rendered in each viewer's timezone (see Timezones and Time Handling). A form answer is not an instant. "I need this by the 14th" means the 14th to whoever typed it and to whoever reads it. Convert it and an analyst in another zone opens the submission and sees the 13th.
So: stored verbatim, exported verbatim, displayed verbatim. FormLogic.formatDateValue() is the entire display transform β it swaps the ISO T for a space and stops. It deliberately does not call new Date(), because that would apply a timezone, and deliberately keeps ISO ordering rather than reformatting to dd/mm/yyyy, which is ambiguous across the 21 locales this product ships in.
forms/submissions.php loads assets/js/form-logic.js purely for that function β note it does not route these values through Tz, unlike every other date on the page.
Added to CONDITION_OPS alongside greater_than / less_than, which they mirror. Both engines implement them as a plain string comparison, which is correct rather than lazy: ISO-8601 is designed so lexical order is chronological order. Parsing to a timestamp would reintroduce the timezone these values deliberately do not carry.
They exist for readability. greater_than would compute the right answer on a date, but "is more than 2026-08-14" reads like nonsense in a rule the user is composing.
The builder narrows which operators it offers per trigger type (opsFor()) β dates get is_after/is_before, numbers get greater_than/less_than, lists get equality only. The engine still accepts the full set, so this constrains the menu, not the API. An operator already stored on a rule is kept in the list even if it wouldn't be offered now, so changing a field's mode never silently rewrites a rule someone wrote on purpose.
form_fields.config is JSON, carrying the visibility rule and (on a date field) the mode:
{"date_mode": "date",
"visible_if": {
"match": "all",
"rules": [{"field": 94, "op": "equals", "value": "Yes"}]
}}config must merge, not replace. The builder's buildRulesForSave() starts from the field's other settings and adds visible_if β an earlier version of it returned a fresh {visible_if: β¦} object, which silently dropped date_mode from any date field that also carried a condition.
NULL config β which is every field that existed before this change β means always visible. That is why an upgraded form renders byte-for-byte as it did.
Operators: equals Β· not_equals Β· contains Β· is_empty Β· is_not_empty Β· greater_than Β· less_than Β· is_after Β· is_before. match is all or any.
validateFieldConfig() rejects a rule whose target sits at the same index or later:
if ($refIndex >= $i) {
throw new ServiceError('validation', 'invalid_field',
"fields[{$i}]: a condition can only depend on an earlier question.");
}π This is the single most load-bearing decision in the feature. Because a rule can only look backwards, a cycle is impossible to construct β so neither evaluator ever needs cycle detection, and evaluating in sort_order always has the answers it depends on already computed. It is enforced on the server, so the REST API and a hand-crafted request are bound by it too, not just the builder.
The builder's pruneInvalidConditions() runs after every drag: a reorder is the only way to invert a rule, and dropping it (with a toast) is more honest than letting the save 422 about a question the user wasn't thinking about.
The rule engine exists twice, deliberately:
| Decides | Why it exists | |
|---|---|---|
includes/form_logic.php |
yes β on submit | a browser can simply not run our JS |
assets/js/form-logic.js |
no | so the form reacts as someone types |
Two copies that disagree is the entire risk of this design, so tests/forms-logic/run.php runs one case table through both and compares. The JS half is executed in headless Chrome with the library inlined into the harness (a file:// page cannot reliably <script src> a sibling, and a silent load failure would look exactly like a pass). No Chrome β that section SKIPs loudly rather than quietly passing.
Both normalise an answer into {scalar, list} before testing, so one operator handles a text answer and a checkboxes answer (stored as a JSON array string) without branching per type.
$visible = formLogicVisibility($fields, $normalised);
foreach (array_keys($normalised) as $fieldId) {
if (empty($visible[$fieldId])) unset($normalised[$fieldId]); // never asked β not stored
}
// ...then: skip required + format checks for anything not visibleπ Required means required if shown. Without this, hiding a required field client-side would still 422 on submit β and a crafted post could skip a genuinely required question by claiming it was hidden. Re-deriving from the submitted answers closes both.
is_deleted = 1 hides a field from the builder, forms/fill.php, the portal and the REST API β everywhere the form is asked.
api/forms/get_submissions.php is the deliberate exception: it returns retired fields (ordered is_deleted, sort_order, id) so forms/submissions.php can keep their column, marked removed. Sections are excluded there instead, since they never held an answer.
The rule: a question stops being asked; the answers it already collected do not stop being real.
createVersion() copies fields row by row, not with INSERT..SELECT:
$idMap[(int)$f['id']] = (int)$conn->lastInsertId();
// ...second pass rewrites each rule's `field` through $idMapA bulk copy would leave the new version's rules pointing at the frozen original's field ids β so editing the copy would change what the frozen snapshot showed. Retired fields are not carried forward: they exist to keep old answers readable on the version they belong to.
A form is drawn in three places, and they were left as three:
| Renderer | CSS vocabulary |
|---|---|
forms/edit/index.php (preview) |
.preview-field |
forms/fill.php |
.form-field |
self-service/catalogue.php |
.cat-field |
Adding datetime in #944 was the first real test of that call: it meant three small markup cases and three CSS selector lists, but only one type list, one dateInputType() and one formatDateValue(). The duplication that remains is markup; the duplication that would actually hurt β what a type means β stays in one place.
Collapsing them into one renderer was the original plan and was dropped on purpose: the three genuinely look different, so unifying the markup would have changed how existing forms render β a visible regression in exchange for an internal tidy-up.
Instead they share one brain (assets/js/form-logic.js: which types exist, which carry options, what is visible) and keep their own skins. Adding a field type still means touching three small markup templates, but only one type list and one visibility rule.
If you do unify them later, write the characterisation test first: snapshot the rendered HTML of a fixture form covering all nine types through all three renderers, then assert it is unchanged.
applyGeneratedForm() rebuilds the whole fields array from the generator's response. With id-based sync that would read as "retire every question and create new ones" β no data loss, but the form silently detaches from every answer already given to it.
It therefore carries ids across by label, the same way the proposal diff decides what counts as unchanged, and re-points any carried-over condition through the old field's label to the new field's _key. Any rule whose trigger the AI removed is dropped.
mergeGeneratedConfig() decides which config wins per key: the author's existing settings survive, except date_mode, which only the generator has an opinion about on a field it just proposed.
$allowedTypes does nothing on its own β the model only produces what the prompt describes. section was whitelisted in #940 but not documented in the prompt, so the generator never once emitted one until #944 fixed it. Worse, the prompt actively instructed the model to fake dates (a date question becomes "text" with a label like "Start date (DD/MM/YYYY)"). When you add a field type, change three things: the whitelist, the sanitiser, and the FIELD TYPES section of the prompt.
php tests/forms-logic/run.php
85 assertions. Every negative is paired with a positive control β "the hidden required field did not block submission" is equally true of a build that validates nothing, so the control proves the same question is enforced once shown.
Confirm the suite can fail before trusting a green run: break formLogicTestRule() (e.g. return true at the top) and it should report ~14 failures, including the JS-vs-PHP comparisons.
Two date assertions are worth keeping whatever else changes: a date is stored exactly as typed, and a 23:30 datetime does not roll into the next day. Both would pass trivially today and fail the moment somebody "helpfully" adds a UTC conversion.
FormsService opens its own transactions and MySQL has no nested ones. It creates ZZ_TEST_-prefixed forms and removes them in a finally, driven by the title rather than by a tracked id list (a fatal mid-createVersion() can commit a row before its id is ever recorded, and the version-chain FK then blocks the whole cleanup).
For the builder itself, remember that grepping rendered HTML proves nothing about whether the JS parses. Extract each inline <script> block and feed it to new Function() in headless Chrome, with a deliberately-invalid block included as a control.
form_fields.config and form_fields.is_deleted are both in includes/db_verify_schema.php, so System β Database Verification adds them. Until it is run, saving a form fails on the missing columns β this feature has no soft-degrade path, unlike ticket snooze.
REST API clients need one behaviour change flagged: PATCH /forms/{id} now syncs by id. Send each field's id back as GET returns it and it is updated in place; a field with no id is new, and an omitted field is retired. A client that rebuilds the list from scratch replaces the questions rather than renaming them β which is deliberate, and the opposite of the old behaviour that quietly re-pointed historical answers.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
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
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- 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: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- 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
- β³ 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)