-
Notifications
You must be signed in to change notification settings - Fork 15
Form Sections and Conditional Logic Developer Guide
How section headings and conditional visibility work, why field identity had to be fixed before either could be built, and which decisions are load-bearing.
Shipped as #940 (sections), #941 (conditions), #942 (field identity) and #943 (the submissions access gate).
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), ANSWERABLE_TYPES, CONDITION_OPS; validateFields(), validateFieldConfig(), syncFields() (now id-based), submitForm() visibility gate, createVersion() id remap |
| βοΈ | assets/js/form-logic.js |
the browser mirror β FormLogic.visibility/testRule/parseOptions/hasOptions
|
| π | 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 added to the generator's allowed types |
| π | 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 |
| π₯οΈ | 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.
form_fields.config is JSON, currently carrying one key:
{"visible_if": {
"match": "all",
"rules": [{"field": 94, "op": "equals", "value": "Yes"}]
}}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. 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 |
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.
php tests/forms-logic/run.php
56 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.
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)