Task Forms Developer Guide - edmozley/freeitsm GitHub Wiki
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.
๐๏ธ 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 |
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_idis the exact form version the occurrence is filled on:add()andcopyForOccurrence()resolve the newest version in the chain (newestVersion()), becausesubmitForm()accepts any active version and an older one can still be active. -
submission_idis the submission that currently meets it. A correction is a new submission; the old one getssuperseded_by_idand keeps itstask_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_idon 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.
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).
TaskForms::submit():
-
Retry first. If a submission with this task and
client_referenceexists, return it (replayed: true) - before any other check, so a retry after a success never fails on, say, the revision having moved. - Guards: Tasks write, the task visible, the #181 completed-checklist lock, and Forms write access (
mayFill()). -
FormsService::submitForm($conn, $ctx, $formId, $data, null, $link)with$link = ['task_id', 'tenant_id', 'client_reference', 'within'].submitForm()writes the three columns, and callswithin($conn, $submissionId)before its own commit.withindoes the conditionalUPDATE task_forms ... WHERE revision = ?and marks the previous submission superseded. If the revision moved, it throwsTaskFormConflict- andsubmitForm()'s own catch rolls the submission back with it. - History:
form_submittedorform_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.
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'sconfirmChecklistCompletionById()used to return early whendata.enabledwas false.enabledis 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 needssuccess.
Skipping an occurrence is still not gated (#181).
TaskForms::mayReadAnswers($conn, $analystId)
= analystModuleLevel(..., 'forms') !== null // any Forms access
|| analystMayAct(..., Cap::TASKS_FORM_ANSWERS); // or the narrow permissionCallers 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.
-
TaskRecurrence::createOccurrence()callsTaskForms::copyForOccurrence()after the checklist copy: requirement, mode and order - never the submission or draft - on the newest form version at that moment. -
TasksService::deleteTask()callsTaskForms::deleteForTasks(). The submissions stay, still pointing at the task id. -
TaskHistory::snapshot()addsforms: 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()refusesdeleteSubmission()for a task-linked submission anddeleteForm()for a form with task-linked submissions or still required by a task (codetask_evidence).
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.
- The conditional UPDATE is the check (section 3).
-
submitForm()owns its transaction (section 4). -
enabledin the checklist check is the checklist switch (section 5). -
A new reader of
form_submissionsmust applysubmissionScopeSql(), 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()anddeleteForm()now refuse task evidence, sotests/task-forms.phptidies with SQL - history by task id and recurrence id included.
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_closedandis_closedcan arrive as the string"0", which JavaScript reads as true. Compare withNumber(...) === 1.
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
FormFillerextraction the design described. Extractingfill.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.
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()withKnowledgeViewer::forAnalyst(), aftermayReadKnowledge()(access to Knowledge, Tickets or LMS - the modulesknowledge_article.phpaccepts). 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()callsTaskProcedures::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 writesknowledge_article_versions, soversionalone cannot promise the words. -
Repeats copy references (unfrozen) in
TaskRecurrence::createOccurrence();deleteTask()removes references (snapshots stay);TaskHistory::snapshot()addsprocedures. -
The report: each occurrence's
procedures;procedure_texts(each snapshot once) only when the viewermayReadKnowledge(), elseprocedure_texts_hidden.report.phpprints 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 gainsprocedurerows with the snapshot's sha256.
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.
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 asProjectTools::issueToTicket()does: the analyst is requester (needs an email) and assignee, in the occurrence's company, so numbering, routing, notifications andticket.createdall happen as for any ticket. Needs Tickets module access. -
Tasks go through
TasksService::saveTask()with the occurrence'stenant_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 whereanalystCanAccessTicket()/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()feedstaskCompletionCheck()under Open follow-ups, only whentasks_evidence_open_followupsiswarnorblock(defaultallow, decision 5). - History
follow_up_created/follow_up_linked/follow_up_removed;deleteTask()removes the links (the records stay);TaskHistory::snapshot()addsfollow_ups; the report and evidence CSV list them.
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.
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 - thenTaskForms. The list callsforTask(..., $actor, true), soanswersfollowsmayReadAnswers()for the key's analyst and is absent rather than empty when not allowed. - The submit is
TaskForms::submit()withActorContext::fromApiKey(): the same retry key, the same revision check, the same Forms validation, the same in-transaction link.revisionis 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 get409. -
Deviation from the design, which proposed
task_idandclient_referenceonPOST /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.jsonhas 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 withapi/v1/dev/openapi_stub.php, and never runopenapi_fix.phpin place.
The same engine serves tickets: every host-specific piece of TaskForms is a constant (TABLE, FK, SUB_COL, SETTING, NOUN) or a protected static hook, every method uses static::, and TicketForms extends TaskForms overrides only those. Change a method here, for both hosts - never copy it into TicketForms. The full guide, with the schema, the hooks, the close gate, the endpoints, the screens and the REST API: Forms on tickets - Developer Guide (two pages).
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.