Forms - edmozley/freeitsm GitHub Wiki

Forms

Folder: forms/

πŸ›’ Forms can be offered to customers. Flip a form into the self-service request catalogue and requesters can order it themselves. Off by default for every form.

Colour: Teal #00897b

Dynamic form builder and submission system. Ten question types (including validated email, numeric and date / time pickers) plus section headings, conditional questions that only appear when they are relevant, AI-assisted form generation and modification, real version history with rollback, A4-style fillable rendering, and CSV-exportable submissions.

Dashboard (index.php)

Full-width table of every form (one row per form chain β€” see Versioning below). Sortable columns and instant search.

  • Columns: Title (description shown muted underneath) Β· Version pill Β· Status (Active / Inactive) Β· Fields count Β· Submissions count Β· Last modified (friendly 5 min ago with tooltip showing the exact timestamp) Β· Modified by Β· Actions.
  • Sort: click any column header to sort; click again to flip direction. Default sort is Last modified ↓.
  • Search: client-side filter as you type, matching against both title and description.
  • Click any row to open the editor.
  • Per-row actions: Fill / Submissions / Delete icons. Delete prompts a confirmation that warns it will wipe the whole version chain and all submissions across all versions.

Editor (edit/index.php)

Pretty URL /forms/edit/?id=X (or /forms/edit/ for a new form). Real bookmarkable, refreshable, shareable URLs.

Layout

  • Top toolbar β€” inspection tools: AI Assist Β· Versions dropdown Β· Save as new version Β· Properties. Also shows the current v-pill next to the title.
  • Editor body β€” title + description card, then a tabbed Fields / Preview pane. Fields drag to reorder.
  • Sticky footer β€” completion actions: Cancel Β· Save. The scrollbar in the editor body stops at the footer's top edge so Save is always one click away on long forms.
  • Properties drawer β€” right-side slide-out (open via the Properties button or by clicking the v-pill). Shows the version pill, author, created date, last modified date, modified by. Closes via X / backdrop click / ESC.

Field Types

Ten question types plus a section heading, each with its own colour-coded badge in the add-field menu (with a fixed-width badge column so the labels line up):

Type Use for
text Single-line free text β€” names, short answers, reference numbers, phone numbers
textarea Multi-line text β€” descriptions, comments, notes, justifications
email Email addresses β€” <input type="email"> with browser format validation + server-side FILTER_VALIDATE_EMAIL
number Numeric input β€” quantities, ages, counts. Rejects letters in the browser + is_numeric server-side
checkbox A SINGLE yes/no toggle β€” consent flags, agreements, single boolean answer
checkboxes MULTI-SELECT from a list β€” "tick everything that applies". 2–12 options
radio Single-select from a small visible list β€” for 2–5 mutually-exclusive options
dropdown Single-select from a collapsed list β€” for 6+ mutually-exclusive options or tight space
datetime A real date and/or time picker. One type with a mode β€” see Date and time fields
lookup Searches FreeITSM's own records β€” which laptop, which server, which person. See Lookup fields
section Not a question β€” a heading that groups everything below it. See Sections

Lookup fields

Every other field is a box someone types into. Ask "which laptop is broken?" with a text field and the answer comes back as "the Dell one", and somebody has to work out which.

A Lookup field searches FreeITSM itself as the person types, and the answer is the actual record. Choose what it searches when you add the field:

Searches What it finds On the portal?
Equipment Assets β€” laptops, desktops, phones, monitors. Matches on name, asset tag or service tag, so it works whether someone knows the machine name or is reading a sticker Optional
Infrastructure CMDB objects β€” servers, services, applications, databases Optional
People Your staff directory Never

Like the date field, it is deliberately one field type with a setting rather than one type per source, and for the same reason β€” a field's type cannot be changed once it exists, so three types would force an irreversible guess at add-time.

Both the name and the link are kept. Rename a machine six months later and an old submission still reads the way it read at the time, while the answer still points at the right record. Conditional questions compare against the name, so "show 'Is it still under warranty?' when 'Which machine' is LT-001" works as you would expect.

Customers only ever see their own

A lookup is not offered on the self-service portal unless you tick it for that field β€” off unless you turn it on, so nothing is ever exposed by accident. When you do, a customer only ever sees records belonging to their own company.

The staff directory is never offered to customers, whatever you tick.

⚠️ Two things are not available as lookup sources yet: contracts (the contracts table has no company column, so a lookup over it could not be restricted properly) and software. Both are wanted; neither is a one-line addition.

Date and time fields

Add a Date / time field and then choose, on the field itself, what it should ask for:

Mode Shows Stored as
A date (default) a calendar picker 2026-08-14
A time a clock picker 09:30
Date and time both together 2026-08-14T09:30

It is deliberately one field type with a setting, not three separate types. A field's type cannot be changed once it exists, so three types would force an irreversible guess at the moment you add the question: realising a month later that you needed the time as well would mean deleting the field and starting its column again, leaving every answer already given stranded under a removed heading. The mode is just a setting β€” flip it, and the field keeps its identity and its history.

The badge on the field row shows the mode (Date, Time, Date + time), so a glance down a long form tells you what each one asks for without opening it.

Dates work with conditional questions using is before and is after β€” "show 'Why the short notice?' when 'First day off' is before 1 September".

⚠️ A date on a form is not converted between timezones. Most dates in FreeITSM are moments in time and are shown in each viewer's own timezone. A form answer is not: "I need this by the 14th" means the 14th to whoever typed it and to whoever reads it, so it is stored, exported and displayed exactly as entered. An analyst in another country reading it as the 13th would be a bug, not a feature.

Field widths β€” several questions on one line

Every field has a Width, chosen from the dropdown next to the Required tick on the field's header row in the editor:

Full width the whole row (the default)
Three-quarters / Two-thirds pairs with a quarter / a third
Half two on a line
Third three on a line
Quarter four on a line

Anything narrower than the full row shares its line with whatever follows, until the line is full. Two halves make a row; a two-thirds and a third make a row; four quarters make a row.

The asymmetric pairs are usually what a real document wants β€” on a purchase requisition, Área deserves more room than Fecha, so two-thirds and a third reads better than two halves.

⚠️ A narrow field's label wraps before its input does. At quarter width you have roughly 200px, which is fine for a quantity or a postcode but turns a label like "Cantidad solicitada" into three lines and makes the whole row tall. Quarters suit short labels.

On a phone every field is full width, whatever you choose. Two controls side by side on a small screen is harder to use than one underneath the other, and this is the same form your customers fill in from the self-service portal.

Widths apply to headings too β€” a heading spanning half a row beside another is a real layout.

Nothing changes on a form you have already built: a field that has never been given a width is full width, which is where they all started.

Sections

A Section heading is added from the same menu as any question, and everything below it belongs to that section until the next heading β€” Your details, Equipment needed, Manager sign-off. It turns one long intimidating form into what it really is: a few short ones.

  • A heading collects no answer. It never appears in submissions, in the CSV export, or in the answers passed to a workflow, and it cannot be marked Required.
  • It renders as a heading with a rule beneath it on the analyst form, in the self-service portal and in the builder preview.
  • Because a section owns the questions beneath it, putting a condition on the heading shows or hides the whole group at once β€” rather than setting the same rule on eight questions individually.
  • Existing forms have no sections, so they carry on rendering as one continuous form exactly as before.
  • The AI Assist generator can propose sections too, so asking it for a joiners form gets you something already grouped. It is told to use them only on a form long enough to benefit (roughly 8+ questions).

Conditional questions

Any question β€” or a whole section β€” can be told to appear only when it is relevant. Open a field in the builder and use Only show this when….

A condition reads as a sentence: only show this when "Do you need a laptop?" is "Yes". Answer No and the whole laptop block simply isn't there.

Test Means
is / is not The earlier answer matches (or doesn't) exactly. On a multi-select, is means "this option was ticked"
contains The earlier answer contains this text (case-insensitive), or β€” on a multi-select β€” includes this option
is empty / is not empty The question was left blank (or, for a tickbox, left unticked)
is more than / is less than Numeric comparison. Ignored on an answer that isn't a number
is after / is before Date comparison, offered on a date field. Not inclusive β€” is after 14 August is false on the 14th

The tests you are offered depend on the question being tested: a date field offers is before / is after rather than is more than, a dropdown offers only is / is not / empty checks, and free text adds contains. The engine accepts them all β€” the menu just doesn't invite combinations that read as nonsense.

A question can carry several conditions at once, matching all of them or any of them β€” the all/any selector appears once you add a second one. The value control matches the question it is testing: a dropdown of that question's own options for a list, Ticked / Not ticked for a tickbox, a free-text box otherwise.

Conditions apply live as the person types or ticks, both on the analyst form and in the self-service portal.

Three behaviours that are deliberate rather than accidental:

  • A hidden question is not a required question. Otherwise a form refuses to submit because of something the person cannot see β€” the classic way this feature goes wrong. This is enforced on the server, not only in the browser.
  • An answer typed into a question that later became hidden is not recorded. What you read back is what the person was actually asked.
  • A condition can only depend on an earlier question. That is what makes a circular rule ("A shows when B is set, B shows when A is set") impossible to build in the first place. If you drag a question above the one it depends on, the now-impossible condition is dropped and you are told.

The Preview tab shows conditional questions with a small Conditional badge rather than evaluating them. Nothing has been answered in a preview, so every condition would be false and the form would look empty β€” the point of the preview is to see everything you have built.

Removing a question

Deleting a question takes it off the form, but the answers people already gave it are kept. The question stops being asked, and its column stays in the submissions view marked removed β€” because those answers are still real data somebody typed in.

AI Assist

Top-toolbar button opens a modal where the analyst describes the form (or, when editing an existing form, the change they want) in plain English. The AI generates a complete form definition; with Anthropic this streams live token-by-token (claude.ai-style SSE), with OpenAI / OpenRouter it arrives as a single result. Provider / model / key are configured per the shared AI Providers panel.

Context-aware β€” detects whether you're building a new form or modifying an existing one and adjusts its behaviour:

  • New form mode: "What's the form for?" β€” AI builds from scratch.
  • Edit mode: "What would you like to change?" β€” AI sees the current form as a Current form JSON block and applies your request to it. System prompt's MODIFICATION MODE section enforces "every field the user didn't ask you to change MUST come back identical".

Preview-and-apply β€” after the AI stream completes a coloured proposal panel appears inside the modal listing every proposed field with a per-row badge (added Β· changed Β· unchanged Β· removed vs the current form). The Generate button is swapped for an Apply button. Nothing is written to the editor until you click Apply. Cancel discards the proposal; your form stays exactly as it was.

Versioning

A form's history is a chain of rows in the forms table β€” each pointing back at its predecessor via parent_form_id. The leaf (no children) is the current editable version; older rows are frozen snapshots.

  • Save (in the footer) β€” in-place update of the current version. Refreshes modified_by/date. Does NOT bump version_number.
  • Save as new version (in the toolbar) β€” clones the current form forward into a new chain entry with version_number = source + 1. The current version becomes a frozen historical snapshot; the new one is the editable current.
  • Versions dropdown β€” lists every version in the chain with the current row badged green. Click any to navigate. Latest at the top.
  • Read-only mode β€” when viewing a frozen version, an orange banner appears, the Save button is disabled, and the Save as new version button is hidden. The banner links to the current version. Server-side guard rejects in-place saves of non-leaf rows so a stale tab can't mutate history.

What happens next β€” the form's own actions (#95)

(3.3.0) Create a project proposal turns a submission into a proposed project waiting for approval - see Projects β†’ Intake and approval. Arguments with fixed choices (How it is run, Priority) show as drop-downs.

A third tab beside Fields and Preview. Three sections β€” when submitted, when approved, when rejected β€” each an ordered list of actions. Stored on the form in forms.submission_actions; run by the workflow engine. See the Catalogue Request Approvals developer guide for the storage and execution contract, and Workflows for what each action does.

The catalogue is not described twice. forms/edit/index.php exports WorkflowEngine::availableActions() as FA_ACTION_DEFS and resolves every lookup argument into FA_ACTION_LOOKUPS, exactly as workflow/editor.php does. What actions exist, what arguments each takes and which widget each argument gets all come from the engine β€” a second description here would drift the first time an action gained an option, and the two editors would then disagree about what the same feature can do.

The list renderer is deliberately not shared with workflow-editor.js: that one is bound to the canvas node model. Sharing the data is what matters; sharing the drawing code would have meant loading a canvas to render a list.

The three touches that make the gate honest

  • The submitted heading becomes "When submitted (before approval)" when the form is gated. "When submitted" and "when submitted, before anyone has approved it" are different promises, and the name does most of the work.
  • A warning appears only when a ticket-raising action actually sits in the submitted list of a gated form β€” the one configuration that defeats the gate. Warning on every action would train people to ignore it.
  • On a gated form with nothing configured, the approved section says a ticket gets raised anyway. Otherwise an empty section reads as "nothing happens", when in fact the hard-coded raise is about to fire.

⚠️ null and [] are different, and the editor must preserve the difference. A section nobody has opened stays null and is left out of the save payload entirely, so the service keeps its "never configured" meaning. Removing the last action leaves [] β€” a deliberate "do nothing" β€” which on the approved section is the difference between no ticket and the default ticket. Rendering an unconfigured section as an empty list would silently convert one into the other on the next unrelated save.

⚠️ Approved/rejected are dimmed, not hidden, when there is no gate. Hiding them leaves no clue the capability exists, and configuration you cannot see is configuration you cannot fix.

⚠️ The editor's state lives in top-level let bindings, which are not window properties. Drive the page through its exported functions (faAddAction, faSetArg, faPayload) and the rendered DOM β€” the surface a person actually uses. Same trap as PM in the Process Mapper.

Filler (fill.php)

A4-style form rendering with company logo (alignment configurable in settings β€” left / centre / right).

Per-type validation: required-empty for text/textarea/email/number/checkbox/dropdown/radio (must pick one); for checkboxes requires at least one tick; for email validates address format; for number rejects non-numeric.

Submissions (submissions.php)

  • Table view of all submissions for the form.
  • Multi-select (checkboxes) values display as comma-joined in the table, as a bullet list in the detail modal, and semicolon-joined in CSV export.
  • Click any row for a detail modal with all field values.
  • Date range filtering.
  • CSV export with UTF-8 BOM so Excel handles non-ASCII correctly.
  • Questions you have since removed still get a column, marked removed. This is the one view that looks backwards: dropping the heading would make answers people genuinely gave look as though they had never been given. Section headings are not shown β€” they never held an answer.
  • Reading submissions requires Forms module access, like every other action in the module.

Keeping a submission as a PDF (#1775-#1781)

Requested by a user who needed an audit trail: a form you can file, not just a row you can read.

  • A single record β€” the Export as PDF button on the open submission, or the download icon in the row's actions column. Both run the same code: exportSubmissionPdf(idx) takes an index and falls back to whatever the detail panel has open, rather than the list growing a copy of the export that would drift from the panel's.
  • Several at once β€” tick rows and choose Bundle (one PDF, one page per record) or Separate (one PDF each). Neither needs a zip library: Separate is a run of doc.save() calls with a 150 ms beat between them, because a browser drops all but the first few when they fire back to back, and it confirms first above ten files.
  • What a record contains: the operator's own logo, the form title, submission number, who submitted it and when, the approval trail, and every answer including those to questions since removed. The approval block is the point of the document β€” a list of answers with no decision on it is a form, not a record.

Generated client-side with jsPDF, vendored at assets/js/vendor/jspdf.umd.min.js. Nothing is uploaded and no server-side PDF tooling is needed.

Things that are easy to get wrong here

  • πŸ”‘ drawSubmission() is the only place that decides what a record looks like. The single export, the row icon and the bundle all call it. A separate drawing routine for bundles drifts the first time a field type changes.
  • πŸ”‘ The logo is embedded once per document, under a jsPDF alias (doc.addImage(..., 'brandlogo', 'FAST')), and loadBrandLogo() caches the image for the page. Without the alias jsPDF re-embeds the same PNG per record: measured at 59 KB for one submission and 68 KB for four, about 3 KB per extra record instead of 56 KB. Without 'FAST' a single page reached 1.45 MB.
  • ⚠️ A date template is not a filename. Files are named Form title - Person - date.pdf using the reader's own date preference, so a US install writes 09.19.2026. Three of the nine shipped templates contain a slash, which no filesystem accepts β€” separators are converted to dots rather than stripped, trailing dots and spaces are trimmed for Windows, and the whole name is capped at 180 characters to leave room for the extension and the (1) a browser adds on a collision.
  • ⚠️ Use fmtDate, never fmtNaiveDate. A submission date is a real instant stamped by the server and must convert into the viewer's zone. See Timezones and Time Handling.
  • ⚠️ The selection is cleared at the top of renderTable(), above the empty-list early return. It was at the foot to begin with, so filtering down to no results left the bar reading "2 selected" over an empty table and still offering to export rows that had gone.
  • ⚠️ The selection bar is shown with a class, not the hidden attribute β€” an element carrying its own display rule ignores hidden entirely.
  • πŸ”΄ Every colour on the bar is a real token from theme.css. It first shipped asking for var(--surface-alt, …), a token defined in neither theme β€” a var() with a fallback fails silently to that fallback, so the bar came out bright white on a dark page and nothing reported it. See Theming and Dark Mode.
  • ⚠️ Measuring this in a headless browser: .btn carries transition: background-color 0.15s, and getComputedStyle during a transition returns the interpolated value β€” so the background appears to ignore the theme while the (untransitioned) text colour flips instantly. Disable transitions before sampling, and set both data-theme and data-theme-mode.

Collections

A collection groups the submissions of several forms under one name β€” Staff Survey 2026, an audit round, an onboarding wave. Entirely optional: most forms, a laptop request say, belong to none. Several forms may share one collection; a form belongs to at most one.

  • Created in Forms β†’ Settings β†’ Collections (its own capability, forms.collections), where they can also be renamed, closed, reopened and deleted.
  • Paired from the folder icon on the forms list, beside portal visibility and approval β€” because that is where every other per-form setting already lives, and a dropdown in Settings would be the only form property you configure on a screen that does not list forms.
  • The list shows a form's collection as a pill, marked when the collection is closed, so nobody has to open the form to find out.

πŸ”‘ Two collection_id columns, and they are ALLOWED to disagree

Column Means
forms.collection_id where new submissions of this form go
form_submissions.collection_id which collection this submission was part of

The second is stamped at submit time and never read live again. Reading it through the form instead would let re-pairing rewrite history: unpair a form and last year's 340 responses stop being part of anything; move it and they defect to the new collection. For a feature whose job is an audit trail that is the one failure that makes it worthless. form_submissions.approver_id is snapshotted for exactly the same reason.

Carried forward by createVersion() β€” a form is a chain of rows and the catalogue lists leaves, so a new version that dropped collection_id would unpair the form the moment somebody pressed Save. That is how approval gating was lost before #95.

πŸ”΄ Closing never writes to a form

What closing does is an operator setting, forms_collection_close_effect, because organisations mean different things by "closed":

Value Effect
reporting_only nothing stops; the label says the exercise is over
stop_submissions (default) submitForm() refuses, naming the collection
stop_and_hide as above, and the portal catalogue does not offer the forms

Whichever is chosen, setCollectionClosed() writes to form_collections and nothing else. If closing stamped is_portal_visible = 0 on the forms, reopening would switch all of them back on β€” including one deliberately kept off the portal. The question is asked at the moment it matters, by submissionsBlockedBy() and portalCatalogueFilter(), so reopening restores exactly what was there.

A form's own is_active stays independent: two separate reasons a form may be shut, and neither overwrites the other. The submission guard sits after the is_active check, so a form that is both inactive and in a closed collection reports being inactive β€” its own state, and the one an analyst can act on.

Deleting

A collection holding submissions is closable but not deletable β€” the service says so in a sentence and the FK has no delete rule behind it. Deleting one with no submissions simply unpairs any forms pointing at it (ON DELETE SET NULL). The Delete button is disabled with the explanation as its tooltip, so nobody has to discover the rule by pressing it.

Before Database Verification

Every read and write is behind FormsService::collectionsAvailable(), which checks the table and both columns β€” all three, because two of three is a half-migrated database where the feature would write stamps nothing can read. With it false the module is exactly the one that shipped before: get_forms.php and the portal catalogue build their queries without naming the column, submissions still save, and the settings tab says to run Database Verification rather than showing an empty list that looks like a working feature nobody has used.

Laying a form out

Every question has a Width β€” full, three-quarters, two-thirds, half, third or quarter β€” and anything narrower than the full line shares that line with what follows. Twelfths rather than a fixed set of halves and thirds, so the asymmetric pairs a real document wants come free: Área | Fecha is two-thirds plus a third, not half and half.

Each question also has a Label position β€” Above (the default) or Beside. Two half-width questions set to Beside give a row reading First name [____] Surname [____], which is how a printed form normally reads, with no table involved.

πŸ”΄ Everything goes to one question per line on a phone, and every label goes back above its box. Not a setting, and not negotiable per form.

Notes, warnings and images

Two things on a form that are read rather than answered:

  • Note β€” a one-line message with an optional paragraph beneath it, and a style: Plain, Information, Warning, Important or Success. The author picks a name, never a colour, so it reads correctly in light mode and in dark. A note can be conditional, so a warning appears only when it becomes true β€” which is worth roughly ten that sit on screen permanently.
  • Image β€” a logo, a floor plan, a diagram. Sized as a share of its column (full / three-quarters / half / quarter) rather than in pixels, and full width again on a phone. The label becomes the alt text. ⚠️ SVG is not accepted: it is a document that can carry a script, and these are shown to customers.

⚠️ A note is not a section heading. A heading owns the questions beneath it until the next one; a note owns nothing.

Table questions

One question whose answer is many rows, with the rows added by whoever fills the form in β€” the CNSS purchase requisition's ITEM / DESCRIPCIΓ“N / UNIDAD / CANTIDAD.

The author defines the columns once; each is text, number, dropdown, choose-one, tick box or date. The palette is deliberately restricted: a file upload or a signature pad in a 200px column is unusable, and per-cell conditional logic is combinatorial.

πŸ”‘ Columns carry a stable id, so they can be renamed, reordered and retired without orphaning an answer. A retired column still labels its old answers on the submissions that used it, marked as removed, and its id is never reused β€” which is what stops last year's answers reappearing under this year's heading.

Answers appear as a real table on the submission, in the PDF and in a collection. The submissions list shows a count ("3 rows"), because that list is one line per submission. The CSV gets one column per table column, values joined down the rows β€” a spreadsheet needs the same headings for every row in the file, and rows are unbounded.

Drafts

Every form has a Save draft button beside Submit, on the analyst side and in the portal. It keeps what has been typed and leaves the form unsent; opening the form again picks it back up and says when it was saved.

  • Nothing is validated. Required fields may be empty and a number box may say "tbc". That is the point; validation belongs to Submit.
  • Hidden answers are kept β€” a draft is a snapshot of the typing, not of the answer. Submit does the opposite deliberately.
  • Only the person who saved it can see it. A draft is not a submission: it appears in no list, no export, no PDF, no approval queue and no API response, and it raises nothing.
  • πŸ”΄ If the form gets a new version, the draft cannot be filled back in and says so. A new version renumbers every question, so old answers would land in the wrong boxes.

Who can request a form

By default any customer who can reach the request catalogue can request any form in it. Who can request this form, on the Forms list, restricts it to one or more groups from Tickets β†’ Users β†’ Groups β€” the one group kind in FreeITSM that holds customers as well as members of staff.

  • No groups means everyone, so no existing form narrowed when this arrived.
  • An expired membership and a deactivated group both fail closed.
  • A new version inherits the restriction, so editing a form never republishes it.
  • A customer outside the groups is told the form does not exist, not that they are not allowed β€” "you cannot see this one" tells somebody which forms are worth asking about.
  • Enforced on the catalogue list, opening by id, drafts, images and submitting. A hidden card has never been a check.
  • Analysts are unaffected: this answers who may request, not who may administer.

Settings (settings/)

Settings page tabs:

  • Layout β€” company-logo alignment for the filler view (left / centre / right). The same alignment is applied to the editor's Preview tab, so what you preview is what fills.
  • Access (3.8.0, forms.access) β€” Who can design forms: Everyone with Forms (default) or Only people with "Design forms". Designing includes choosing a form's approver and what happens after a request, so an approver role can be kept out of it. The tab lists who would keep designing before you switch. When restricted, the design endpoints, FormsService and the REST API all refuse; the list hides New form and the per-form design buttons, and the designer sends people back.
  • Collections β€” see above.
  • AI β€” per-module AI billing. Provider (Anthropic / OpenAI) Β· model (text input + datalist of suggested IDs per provider) Β· API key (encrypted at rest, masked when displayed) Β· Verify SSL toggle. Test connection button fires a tiny "say ok" prompt and reports provider Β· model Β· latency Β· tokens in / out. Saved settings drive the form builder's AI Assist via forms_ai_* keys in system_settings, so the Forms feature's spend lands separately on the provider's console from other modules' AI usage.

Read only (3.8.0): Forms can be granted Read only in System β†’ Modules - read every form, version and submission and export them, but not design, fill in, delete, or decide unless named as the approver. See Module Access Control.

Help

Full-page guide at forms/help.php with left-pane navigation and scroll-spy, covering the builder, field types, filling forms in, submissions, exports and settings.

Since 2.3.0 it also links to five in-depth topic pages under forms/help/, each with its own contents rail β€” laying a form out, notes and images, table questions, drafts, and who can request a form. The guide was already the longest in the product and six new areas would have doubled it; the topics are driven by one registry (forms/help/_registry.php) so the guide cannot link to a page that does not exist.

On a phone

All seven pages are mobile-friendly as of #1289 (LAYER 27) β€” the list and submissions as card feeds, the approvals sidebar as a chip row, and the builder readable and editable with its drag-to-reorder intact. See Mobile: Forms.

Key Tables

Table Purpose
forms Form definitions. parent_form_id chains versions; version_number is set on create/clone, never bumped by an in-place save; created_by + modified_by track authorship per version
form_fields Per-form field definitions (type, label, options JSON, required, sort order, config for the visibility rule, is_deleted for a retired question). Cloned forward on Save as new version. See the developer guide
form_submissions One row per submission
form_submission_data Per-field values per submission. Multi-select values stored as JSON-encoded arrays
form_drafts A form somebody started and has not submitted. Its OWN table, deliberately, so a draft is invisible to every reader of form_submissions
form_audiences Which people-groups may request a form from the catalogue. No rows = everyone
system_settings Houses forms_ai_provider, forms_ai_model, forms_ai_api_key (encrypted), forms_ai_verify_ssl for the per-module AI billing