Skip to content

Task Forms Developer Guide

Ed Mozley edited this page Oct 19, 2026 · 4 revisions

πŸ› οΈ Forms on tasks and the Work view - Developer Guide

How a task occurrence needs forms, how a submission is linked to it, how two people saving at once are kept apart, and how it all reaches the #181 record. Built for #190 (phase 1). User guide: Forms on tasks. Design and decisions: Task work view - design.

The one-line version: TaskForms (includes/services/task_forms.php) owns every rule; the submission is an ordinary Forms submission made by FormsService::submitForm(), linked to its occurrence inside that method's transaction; completion goes through the one task gate; the answers follow one read rule everywhere.


1. The files

πŸ—„οΈ schema Β· βš™οΈ service Β· πŸ”Œ endpoint Β· πŸ–₯️ UI Β· πŸ”— cross-module Β· πŸ§ͺ tests

File 🎨 What happens there
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php (generated) πŸ—„οΈ task_forms; form_submissions.task_id, tenant_id, client_reference, superseded_by_id; uq_form_submissions_task_ref (task_id, client_reference)
includes/services/task_forms.php βš™οΈ TaskForms: mayReadAnswers(), mayFill(), forTask(), answers(), forFill(), add/update/remove(), saveDraft(), submit(), outstanding(), copyForOccurrence(), deleteForTasks(), snapshot(), submissionScopeSql(), newestVersion(); TaskFormConflict
includes/services/forms.php πŸ”— submitForm(..., array $link = []) - the task columns and the in-transaction within callback; assertNotTaskEvidence() in deleteSubmission() / deleteForm(); collectionSubmissions(..., $companyScope)
includes/services/checklists.php πŸ”— taskCompletionCheck() adds outstanding forms; recordTaskCompletionOverride() records them
includes/services/task_recurrence.php, tasks.php, task_history.php πŸ”— The repeat copies requirements; deleteTask() removes them; snapshot() gains forms
includes/task_evidence.php, api/tasks/evidence_export.php, tasks/evidence/report.php πŸ”— formsOf() per occurrence; the form_answers CSV; form_submission rows in the evidence CSV
includes/capabilities.php, tasks/settings/manifest.php πŸ”— Cap::TASKS_FORM_ANSWERS, an everyday action with restrict => null (always restricted)
api/tasks/forms.php πŸ”Œ GET the task's forms / the fill data; POST add, update, remove, draft, submit
api/forms/get_submissions.php, get_collection_submissions.php, api/v1/resources/forms.php πŸ”Œ Company scope on every list and single read of submissions
api/tasks/get_settings.php, save_settings.php, tasks/settings/index.php πŸ”ŒπŸ–₯️ tasks_forms_enabled
assets/js/tasks.js (v58), assets/css/tasks.css (v49) πŸ–₯️ #taskFormsSection: loadTaskForms(), renderTaskForms(), postTaskForm(), openTaskForm(); history labels; the BroadcastChannel listener
forms/fill.php πŸ–₯️ ?task_form=N: the shared draft, showTaskConflict(), the client reference, the banner
tasks/help.php, lang/en/tasks.php (forms.*, help.forms_*, history.form_*, evidence.form_*) πŸ–₯️ Help and strings
tests/task-forms.php πŸ§ͺ 57 assertions, below

2. The data

task_forms (id, task_id, form_id, title, is_required, requirement_mode ENUM('warn','block'),
            submission_id, draft_json, draft_by_id, draft_by_name, draft_datetime,
            revision, sort_order, added_by_id, added_datetime, is_demo)

form_submissions + task_id, tenant_id, client_reference, superseded_by_id
  • One row per form per occurrence. form_id is the exact form version the occurrence is filled on: add() and copyForOccurrence() resolve the newest version in the chain (newestVersion()), because submitForm() accepts any active version and an older one can still be active.
  • submission_id is the submission that currently meets it. A correction is a new submission; the old one gets superseded_by_id and keeps its task_id, so the record shows both.
  • πŸ”΄ No foreign keys on task_forms, and the new submission columns have none either - the same rule as the #181 tables: a cascade would delete the evidence with the thing it is evidence about.
  • tenant_id on the submission is the task's company when it was submitted. Forms are install-wide, so without it the Forms module could not scope the submission (section 6).
  • The draft lives on the requirement, not in form_drafts: that table's key is one draft per person per form version, and decision 3 is one draft per occurrence, shared.

3. Two people at once: revision

Every write to a requirement's draft or submission is conditional on the revision the writer last read:

// TaskForms::saveDraft()
$up = $conn->prepare("UPDATE task_forms SET draft_json = ?, ..., revision = revision + 1
                       WHERE id = ? AND revision = ?");
$up->execute([..., $taskFormId, $revision]);
if ($up->rowCount() !== 1) throw self::conflict($conn, $taskFormId);

πŸ”΄ TRAP: the conditional UPDATE is the check. Reading the revision, comparing, then writing would let two saves both pass the comparison. Never split it.

TaskFormConflict (a ServiceError, code task_form_conflict) carries detail: the current revision, who saved, when, whether it was a draft or a submission, and their draft answers. api/tasks/forms.php returns it as conflict; fill.php's showTaskConflict() keeps the analyst's answers on screen and offers Theirs (reload) or Mine (repeat the last action from the revision just shown).

4. Submitting: one transaction, and the retry key

TaskForms::submit():

  1. Retry first. If a submission with this task and client_reference exists, return it (replayed: true) - before any other check, so a retry after a success never fails on, say, the revision having moved.
  2. Guards: Tasks write, the task visible, the #181 completed-checklist lock, and Forms write access (mayFill()).
  3. FormsService::submitForm($conn, $ctx, $formId, $data, null, $link) with $link = ['task_id', 'tenant_id', 'client_reference', 'within']. submitForm() writes the three columns, and calls within($conn, $submissionId) before its own commit. within does the conditional UPDATE task_forms ... WHERE revision = ? and marks the previous submission superseded. If the revision moved, it throws TaskFormConflict - and submitForm()'s own catch rolls the submission back with it.
  4. History: form_submitted or form_replaced.

πŸ”΄ TRAP: submitForm() opens its own transaction. Wrapping it in an outer one throws ("already in a transaction"). That is why the link is a callback run inside it, not code around it.

Validation is untouched: required fields, conditional visibility, formats, options, lookups and grids are all submitForm()'s. Workflow events and the form's own when submitted actions run as they always do, with task.id added to the payload.

5. The one completion gate

ChecklistsService::taskCompletionCheck() now returns outstanding required forms beside outstanding mandatory steps (grouped under Forms not submitted), each warn or block through effectiveClosureMode() - so the company's block all floor applies. Nothing else changed: assertTaskCompletionAllowed() is still called before the status is written by updateTask, moveTask, api/tasks/reorder.php and api/tasks/toggle_subtask.php, and so by the REST API. recordTaskCompletionOverride() records forms completed without in the same task_audit row, which the #181 report reads as an exception.

⚠️ The window's confirmChecklistCompletionById() used to return early when data.enabled was false. enabled is the checklist switch, so with checklists off and forms on, the window skipped the warning entirely - the server still refused, but with no list first. It now only needs success.

Skipping an occurrence is still not gated (#181).

6. Who may read and fill

TaskForms::mayReadAnswers($conn, $analystId)
    = analystModuleLevel(..., 'forms') !== null        // any Forms access
   || analystMayAct(..., Cap::TASKS_FORM_ANSWERS);     // or the narrow permission

Callers must have checked the task is visible first. One function, used by forTask() (the window), TaskEvidence::report() (screen and print) and so the CSV. The history shows form names only, never answers, so it needs no check.

Cap::TASKS_FORM_ANSWERS is an everyday action with restrict => null - always restricted: it bites from the moment it exists, so nobody gains it on upgrade. Administrators have it as they have every capability.

Filling needs analystCanWriteModule('forms') (mayFill()), checked in the service and by fill.php itself.

The Forms module's lists use TaskForms::submissionScopeSql($conn, $companyScope, 's'): (s.task_id IS NULL OR COALESCE(s.tenant_id, <default>) IN (<scope>)), or 1=1 for an all-companies scope or before Database Verification. Applied in get_submissions.php, collectionSubmissions(), and the REST API's list and single read.

7. Repeats, deleting, the frozen record

  • TaskRecurrence::createOccurrence() calls TaskForms::copyForOccurrence() after the checklist copy: requirement, mode and order - never the submission or draft - on the newest form version at that moment.
  • TasksService::deleteTask() calls TaskForms::deleteForTasks(). The submissions stay, still pointing at the task id.
  • TaskHistory::snapshot() adds forms: requirements and every submission for the task (superseded included) with answers and labels, so a removed occurrence reads after its form is gone.
  • FormsService::assertNotTaskEvidence() refuses deleteSubmission() for a task-linked submission and deleteForm() for a form with task-linked submissions or still required by a task (code task_evidence).

8. Before Database Verification

TaskForms::columnsReady() (cached) checks the new columns and table exist. Every reader that names them asks it first - a list that fails reads as "no submissions". outstanding() and copyForOccurrence() also swallow errors, so completing a task never starts failing on a part-upgraded install.

9. Traps

  • The conditional UPDATE is the check (section 3).
  • submitForm() owns its transaction (section 4).
  • enabled in the checklist check is the checklist switch (section 5).
  • A new reader of form_submissions must apply submissionScopeSql(), or a task's answers leak across companies through the Forms module.
  • A new place that shows answers must call mayReadAnswers() - the rule is the same everywhere by being one function.
  • Tests must delete what they made directly: deleteSubmission() and deleteForm() now refuse task evidence, so tests/task-forms.php tidies with SQL - history by task id and recurrence id included.

10. The Work view (phase 2)

One block, three layouts. renderDetailPanel() draws #taskWorkSection holding #taskWorkHead, #taskWorkInstructions, #taskProcedureSection, #taskChecklistSection and #taskFormsSection. The layouts only move it, as they move every other block: TASK_MODAL_TABS files it as the work tab, applyModalLayout()'s WIDE list puts it in the wide column, the side panel leaves it in line. No second template.

The head waits for all three loaders. loadTaskChecklists(), loadTaskForms() and loadTaskProcedures() each call workLoaded(taskId, part, enabled, count); renderWorkHead() shows the block (and its tab button) only if one is switched on, computes progress from checklistState and formState (every step, every required form), and - once all three have reported, the task has material, the layout is tabs and window.TASK_WORK_OPEN is not false - opens the Work tab, once per opening (workState.autoOpened). tasks_work_open (on / off) is the per-analyst preference behind the Open here box, read by tasks/index.php.

Complete (completeFromWork()) goes through saveField('status', <first closed status>), so the people-involved question and the completion check are the same as the status box, then redraws the window.

Instructions are the task description through safeHtmlFragment() (assets/js/safe-html.js, now loaded by tasks/index.php), in the tabs layout only - in the others the editable description is already on screen.

⚠️ status_is_closed and is_closed can arrive as the string "0", which JavaScript reads as true. Compare with Number(...) === 1.

Filling a form in place

Fill puts forms/fill.php?embed=1&task_form=N in an <iframe> inside the form's card. It is the same page - same rendering, conditions, validation, shared draft, revision check and retry key - with body.fill-embed hiding the header, logo, title and banner. The frame never scrolls on its own: the page posts {type: 'tf-height', task_form_id, height} to its parent from a ResizeObserver, and on save or submit posts {type: 'tf-saved', ..., submitted} (as well as the BroadcastChannel message a separate tab uses). The task window checks e.origin === location.origin, reloads the forms and history, and closes the frame after a submit.

Why a frame rather than the FormFiller extraction the design described. Extracting fill.php's filler into a shared component would have meant moving about 600 lines of rendering, grid, draft and submit code that the Forms page, the portal catalogue and the builder preview depend on, for a result that behaves identically. The frame gives the same guarantee the design was after - one filler, no second editor to drift - with none of that risk.

Procedures

TaskProcedures (includes/services/task_procedures.php), api/tasks/procedures.php, and two tables with no foreign keys:

Table Holds
task_procedures task, Knowledge article, title when added, order; once completed: snapshot_id, article_version, frozen_datetime
task_procedure_snapshots each distinct text once: content_hash (sha256 of article id + title + body, UNIQUE), title, body
  • Reading goes through knowledgeVisibilitySql() with KnowledgeViewer::forAnalyst(), after mayReadKnowledge() (access to Knowledge, Tickets or LMS - the modules knowledge_article.php accepts). A row the viewer may not read comes back with no title and no body: a title can say what a restricted article is about.
  • Adding needs the same: nobody references an article they cannot read.
  • Freezing: TaskHistory::afterStatusChange() calls TaskProcedures::freeze() whenever a task closes (completed or skipped). It reads the article directly - this is the record, not a display - INSERT IGNOREs the snapshot by hash and points the reference at it. A reopen leaves it; the next completion re-freezes.
  • Why freeze at all: an ordinary Knowledge save (KnowledgeService) edits in place and only save as version writes knowledge_article_versions, so version alone cannot promise the words.
  • Repeats copy references (unfrozen) in TaskRecurrence::createOccurrence(); deleteTask() removes references (snapshots stay); TaskHistory::snapshot() adds procedures.
  • The report: each occurrence's procedures; procedure_texts (each snapshot once) only when the viewer mayReadKnowledge(), else procedure_texts_hidden. report.php prints the text as plain text (tags stripped, line breaks kept) - it is the record of the words, and a report page is no place to execute article HTML. The evidence CSV gains procedure rows with the snapshot's sha256.

Step descriptions

task_checklist_items.description: copyTemplateToTask() copies it from checklist_template_items, copyTaskChecklists() from the previous occurrence, taskChecklists() returns it - each guarded by hasStepDescription() for a database not yet verified.

11. Follow-ups (phase 3)

TaskFollowUps (includes/services/task_follow_ups.php), api/tasks/follow_ups.php, task_follow_ups (task, kind ticket|task, record_id, created_here, source_kind step|form, source_id, note, client_reference UNIQUE with the task, who, when). No foreign keys.

  • Tickets go through TicketsService::createTicket() exactly as ProjectTools::issueToTicket() does: the analyst is requester (needs an email) and assignee, in the occurrence's company, so numbering, routing, notifications and ticket.created all happen as for any ticket. Needs Tickets module access.
  • Tasks go through TasksService::saveTask() with the occurrence's tenant_id.
  • Retries: replay() returns the follow-up the same client reference made, before any check.
  • Visibility: describe() gives every follow-up its number and open/closed state, but a title and status only where analystCanAccessTicket() / analystCanAccessTask() says the viewer may see it. link() only accepts a record the analyst can see, and never the task itself.
  • The gate: TaskFollowUps::outstanding() feeds taskCompletionCheck() under Open follow-ups, only when tasks_evidence_open_followups is warn or block (default allow, decision 5).
  • History follow_up_created / follow_up_linked / follow_up_removed; deleteTask() removes the links (the records stay); TaskHistory::snapshot() adds follow_ups; the report and evidence CSV list them.

12. Periods, due offsets and exception answers (phase 4)

Repeats. task_recurrences.period_mode (none | scheduled_day | day_before | since_previous), due_offset_mode (same_day | days | next_working_day), due_offset_days (0-60), normalised in ruleFromInput() and left out of the save before Database Verification (hasPeriodColumns()). In createOccurrence() the $due argument stays the scheduled day - what occurrenceExistsFor() and next_due_date match on - and dueFor() computes the actual due_date; periodFor() the period. Both, with original_due_date, are written in their own statement after the insert.

πŸ”΄ TRAP: never pass the offset due date as $due. The series identifies an occurrence by the day it is made for. Feeding it the offset date would make the job think Saturday's occurrence was never created and create it again.

"Next working day" is Monday to Friday. FreeITSM has no install-wide working week to read, so it does not pretend to.

The report. TaskEvidence::judge() compares against $originalDue = tasks.original_due_date, falling back to the scheduled day: on time up to it, on time after rescheduling if the due date was moved later and met, late past the due date in force. The rescheduled flag compares against it too. Rows carry original_due, period_start, period_end; the occurrences CSV appends original_due_date, period_start, period_end at the end, so existing column positions hold.

Exception answers. The form designer's Exception tick per option writes config.exception_values (by option text, kept in step on rename and remove). FormsService::validateFieldConfig() keeps only values that are the field's own options, on choice fields only - and it belongs to the form version, like the options. TaskForms::isException() marks each answer; TaskEvidence::addFormExceptions() adds a form_answer exception per flagged answer - with the question and answer only for a viewer who mayReadAnswers(), so the count is the same for everybody.

12a. The REST API (phase 5)

Two routes in api/v1/lib/routes.php, handlers apiTaskFormsList() / apiTaskFormSubmit() in api/v1/resources/tasks.php, permission resource task_forms (read, create) in api/v1/lib/permissions.php, catalogued in api/v1/spec.json (so openapi_check.php passes), user page REST API: Tasks.

  • Both go through apiLoadTask() first - the key's company scope - then TaskForms. The list calls forTask(..., $actor, true), so answers follows mayReadAnswers() for the key's analyst and is absent rather than empty when not allowed.
  • The submit is TaskForms::submit() with ActorContext::fromApiKey(): the same retry key, the same revision check, the same Forms validation, the same in-transaction link. revision is optional: without it the requirement's current revision is used - an integration that is the only writer need not track it, and one that is not can send it and get 409.
  • Deviation from the design, which proposed task_id and client_reference on POST /forms/{id}/submissions. A task-scoped route needs no form id the caller could get wrong, goes through the task's company gate by construction, and keeps the Forms endpoint unchanged for every existing integration.

⚠️ spec.json has one older entry that escapes its Unicode (\u2192) where the rest of the file does not, so re-encoding the whole file is never byte-identical. Add entries as text, or with api/v1/dev/openapi_stub.php, and never run openapi_fix.php in place.

13. Tests

php tests/task-forms.php [base-url] - 68 assertions. Phase 2 adds: a procedure listed for a reader and hidden (no title, no text) from a viewer without Knowledge; not frozen while open; frozen on completion through saveTask(), equal to the article as it stood; two occurrences sharing one snapshot; the repeat copying it unfrozen; the #181 snapshot carrying it; the report listing each text once, and hiding it from a viewer without Knowledge; a template step's description on the task and copied by the repeat. Phase 1's 57, on a ZZ-TF form and tasks: the switch; newest version on add; block / warn / optional through the gate; the draft revision race and the conflict's detail; a stale submit leaving no submission; validation; linking; the retry key; correction and supersession; delete refusals with an ordinary-submission control; the repeat copy; the snapshot; delete; company scope with a control; the read rule (an analyst without Forms or the permission, when one exists); the #181 lock; the evidence report for both kinds of viewer; and over HTTP the endpoint, the conflict, and the form answers CSV. Also run: task-evidence, task-read-only, module-access-coverage, task-recurrence-*, form-drafts, form-audiences, document-permissions, web-exposure-guard.


Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally