-
-
Notifications
You must be signed in to change notification settings - Fork 29
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.
| 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 |
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.)
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_fieldsis 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.
{
"type": "flow",
"rows": [
{ "cells": [ {"field": 12, "width": 8, "rowspan": 1},
{"field": 13, "width": 4, "rowspan": 1} ] },
{ "cells": [ {"field": 14, "width": 12, "rowspan": 1} ] }
]
}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.
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.
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.
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
widthback to the field's own width, never to 12 β otherwise a half-width field placed deliberately would silently fill the row; - forces
rowspan < 1to 1, and an unknowntypetoflow.
π΄ 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.
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.
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:
-
layoutmust 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 whycollection_idhad to be added in 2.2.0. -
The ids must be remapped through the same
$idMapthe 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.
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.
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".
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-idplusdata-width, ready to interpolate. -
ctx.widthβ the number alone, for a surface that does not needdata-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.
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.
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.
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.
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.jsvisibility()β 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.
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.
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().
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
|
=== 'section'.
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.
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.
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.
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.
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.
| 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.
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.
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.
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.
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.
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.
beside would make it a two-column grid containing a two-column row.
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.
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
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.
| 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.
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.
- 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.
-
gridis not inANSWERABLE_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.
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.
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).
Drive the real pages; do not read the code. Every round of this work found a fault that reading had missed.
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.
-
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$pageScriptsnowdoc and have no<script>tag at all.
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).
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.
--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.
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.
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.
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.
-
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 β 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