Tasks on Tickets Developer Guide - edmozley/freeitsm GitHub Wiki
Tasks on a ticket β Developer Guide
How follow-up tasks are created from and attached to a ticket: why there is no new button, why creating and linking are one control, and the two invariants that are not expressible as scope checks.
Requested in discussion #83 by dschipfel. Shipped as #1176 (company scoping) and #1177 (the feature), commits 5de974cf, 5161b5ff, e4de579c.
The user-facing pages are Tickets β Follow-up tasks and Tasks β Linking.
Related: Linking equipment to tickets Β· Multi-Tenancy Developer Guide Β· Tasks
1. Half of it already existed, backwards
Worth knowing before you touch anything: tasks.ticket_id had always been a column, and api/tasks/search_links.php plus assets/js/tasks.js already let you open a task and attach a ticket to it.
So the relationship, the storage and one direction of the UI were all built. What was missing was the ticket's side: no way to get from a ticket to its tasks, and no way to see them without leaving the ticket. That is what #83 actually asked for, and it made the job far smaller than the request reads.
The general lesson: before scoping any "link A to B" request, look for the reverse direction. A feature that sounds absent is sometimes present and pointing the other way.
2. π The files involved
π’ The endpoints
Named to match the equipment linking work, because they do the same job for a different thing.
| File | Role |
|---|---|
api/tickets/get_ticket_tasks.php |
The strip's pills: title, status, assignee, subtask progress, plus open_count for the close warning |
api/tickets/search_linkable_tasks.php |
The picker's results. Excludes tasks already on this ticket, and subtasks |
api/tickets/save_ticket_task.php |
Both verbs β link an existing task, or create one via TasksService |
api/tickets/delete_ticket_task.php |
Unlink. Never deletes |
π΅ The client
| File | Role |
|---|---|
assets/js/inbox.js |
loadTicketTasks / renderTicketTasks (pills), openLinkTaskPicker (the one picker), openContextLinkTask (right-click), and the close warning inside assignStatus |
tickets/index.php |
The Link to task⦠context-menu item |
assets/css/inbox.css |
.task-badge / .task-done |
βͺ Groundwork
| File | Role |
|---|---|
includes/services/tasks.php |
Company scoping and the creation rules β see Multi-Tenancy Progress |
3. No new button, on purpose
The ticket screen is busy. Rather than add a control, tasks became the seventh entry in the Link to⦠menu that already existed, and task pills joined the strip that already held problems, changes, tickets, equipment, CMDB objects and tracker issues.
That strip is where an analyst already looks to answer "what else is this connected to", and the codebase had already made this argument once β the comment introducing tracker pills reads:
a Jira issue IS a link, so it belongs in this strip rather than in a panel of its own
A task is the same kind of thing. The cost of the whole feature, in screen furniture, is one menu row.
4. One picker, two verbs
Creating a task and linking an existing one are the same gesture. Typing searches; if nothing matches, the first row creates a task with exactly what was typed.
This is not a space saving. Two separate actions β Create task and Link task β make the user decide which they are doing before they know whether the task already exists, which is precisely the decision they cannot make yet. One box shows them the near matches as they type, so the duplicate is prevented rather than tidied up afterwards.
Structurally the create row is just entry 0 of the same flat list:
current = [{ kind: 'create', title: typed }]
.concat(rows.map(r => Object.assign({ kind: 'task' }, r)));
which keeps one render path and one click handler:
const pick = (r) => {
if (r.kind === 'create') createTaskForTicket(ticketId, r.title);
else linkTaskToTicket(ticketId, r.id);
};
The right-click entry deliberately has no modal
Every other link item on the context menu opens its own modal. openContextLinkTask() does not: it loads the ticket and opens the real picker on the strip.
A second copy of the picker in a modal would be two implementations of "find me a task", and issue #77 is what happens when the same question gets answered in two places. The ticket you right-clicked is the one you are about to work on, so opening it costs nothing.
5. β οΈ Two rules that a scope check cannot express
Both endpoints gate the ticket (analystCanAccessTicket) and the task (analystCanAccessTask). Both gates passing is not sufficient.
A task and its ticket must belong to the same company. An analyst with access to two companies passes both checks and could still attach one client's task to another client's ticket, creating exactly the straddle the scoping work exists to prevent. So the companies are compared explicitly:
if (isMultiTenant($conn)) {
$default = getDefaultTenantId($conn);
$taskCo = $task['tenant_id'] === null ? $default : (int)$task['tenant_id'];
$ticketCo = $ticketTenant === null ? $default : (int)$ticketTenant;
if ($taskCo !== $ticketCo) {
throw new Exception('That task belongs to a different company from this ticket.');
}
}
Note both sides resolve NULL to Default before comparing. Skip that and a Default-company ticket never matches a NULL-company task β which is every task predating the scoping work. CMDB draws the same line for the same reason, and the Multi-Tenancy Developer Guide explains why NULL means Default here rather than "shared".
A subtask cannot be linked to a ticket on its own. It belongs to its parent's work, and a pill whose parent is nowhere in sight is a dead end. search_linkable_tasks.php excludes them and save_ticket_task.php refuses them.
Scope both ends of a read, too
get_ticket_tasks.php filters the tasks it hydrates even though the ticket is already gated:
[$tSql, $tArgs] = activeTenantFilter($conn, $analystId, 'tk');
A link made before a company was split, or by an all-access analyst, can straddle. The gate on the parent is not evidence about the children β the same reasoning get_ticket_assets.php documents.
6. What is copied, and what deliberately is not
| Title | What was typed |
| Company | Inherited from the ticket |
| Assignee | Whoever created it |
| Link back | tasks.ticket_id |
| No | |
| No | |
| No |
Copying priority or dates makes a second copy of the truth that drifts the moment either side changes, and a task showing a stale priority is worse than one showing none. The link is the connection; the ticket is one click away.
The assignee changed after review. It started as unassigned, on the argument that handing work to a person should be deliberate. That is right in the Tasks module and wrong here: raising a task while working a ticket almost always means "I am going to do this", and an unassigned task appears on nobody's list. Reassigning takes one click; noticing an unowned task can take days. The Tasks module keeps its own behaviour β this applies to the ticket path only.
7. The close warning
Closing a ticket with unfinished tasks warns and never blocks, matching collision detection.
It costs no request: the strip has already loaded the tasks, and ticketStatuses already carries is_closed, so assignStatus() can answer from what is in memory.
const closing = ticketStatuses.some(s => s.name === status && s.is_closed);
const openTasks = (tasksForTicket || []).filter(tk => !tk.status_is_closed).length;
Two details that matter more than they look:
tasksForTicketis cleared at the start of every load, not just on success. Otherwise a failed fetch leaves the previous ticket's tasks in place and the warning reports a count belonging to a ticket the analyst is no longer looking at.- If the load failed, the array is empty and the warning simply does not appear. That is the right way round: a warning that cannot be shown must never become a block that cannot be cleared.
8. π The bug this shipped with, and why it was invisible
The first cut of the picker looked completely dead β type, and nothing happened.
.strip-picker-results is display: none until an active class is added:
.strip-picker-results { display: none; }
.strip-picker-results.active { display: block; }
The equipment picker adds it. Mine wrote innerHTML and never did, so the results were fetched, rendered and painted into a hidden container. Everything worked except being visible.
A second bug sat behind it: the rows used .link-search-result, which is a Tasks-module class with no rule in inbox.css, so they would have been unstyled even once shown.
Two lessons. Copy the working example's behaviour, not just its shape β the class that reveals the container is as load-bearing as the markup. And an end-to-end test that stops at "the endpoint returned the right JSON" would have passed this bug twice: the API was correct throughout, and so was the rendering. Only driving the real page caught it.
The rewrite also removed a genuine escaping bug: the create row's click handler was built by concatenating the typed text into an onclick attribute, so a task title containing an apostrophe would have broken it. Handlers are now bound with data-idx.
9. Testing notes
Driving the real page beats asserting on JSON, and this feature is the proof. A same-origin harness that iframes tickets/index.php, seeds the session cookie from PHP, then:
loadTicketById(...)so the strip and its picker host existopenLinkTaskPicker(...)- sets
input.valueand dispatches a bubblinginputevent - waits out the debounce and the fetch before asserting
Step 4 is not optional β an early check reported one row where there were two, which looks exactly like a bug and was not one.
Assert on getComputedStyle(results).display, not just on innerHTML. The whole defect above was content that existed and could not be seen.
Server-side, the differential that matters is the cross-company one: attempt the link as an all-access analyst. A test using a company-restricted analyst passes whether or not the explicit company comparison exists, because the scope gate already refuses it β so it proves nothing about the invariant that needed proving.
10. The task editor inside a ticket's workspace (PR #212)
Since PR #212 (by @Dr4inder), a task pill on the workspace's Work tab opens the task's own editor inside the ticket. This section is what that embedded page relies on - the design asked for it, because it means every change to the task detail panel now also runs inside a ticket. The workspace side is in the Ticket workspace developer guide.
How it is built
| File | Role |
|---|---|
tasks/embedded.php |
Requires both Tickets and Tasks, checks the context with taskEmbedCanOpen(), sets $taskEmbedContext, then requires tasks/index.php. A wrong context is a 404, never a 403 |
tasks/index.php |
With $taskEmbedContext set: no board, header, context menus or assets/js/mobile.js; adds assets/css/task-embed.css and assets/js/task-embed-bridge.js; TASK_HOST = 'ticket', TASK_EMBED_CONTEXT = {taskId, ticketId}, and the detail view forced to the large window |
tasks/includes/detail-shell.php |
The detail panel's markup, shared by both - one copy, not two that drift |
includes/task_embed.php |
taskEmbedCanOpen($conn, $analystId, $taskId, $rootId, $ticketId) |
api/tasks/get.php |
Given embed_root_id and ticket_id, re-runs taskEmbedCanOpen() on every read, so an unlink or a lost permission shows on the next click |
assets/js/tasks.js |
On TASK_HOST === 'ticket': opens the one task instead of building the board, asks the bridge before switching task, sends the embed parameters on reads, and loadTasks() reports a change instead of redrawing a board |
assets/js/task-embed-bridge.js |
The frame's side of the conversation with the ticket |
The checks
taskEmbedCanOpen() needs all of:
- the analyst can access the ticket, the linked task (the root) and the task asked for, each by its own module's rules;
- the root really is linked to this ticket (
tasks.ticket_id), and the ticket is not in the trash; - walking up
parent_task_idfrom the task asked for reaches the root, through tasks the analyst can each see - a subtask is reachable only through parents they can open, as on the board. A seen-list and a 100-step limit stop a corrupt parent chain from looping.
Writes need nothing new. The frame saves through the ordinary Tasks endpoints, with their own module, Read only and company checks and the CSRF token. A Read only Tasks analyst sees a read-only editor in the ticket exactly as on the board.
Rules for anyone changing the task detail panel
- Keep new controls on the existing writers. A field that saves through
saveField()/postTaskChange()needs only adata-task-field="<payload key>"attribute, so the bridge can move its saved baseline when the save succeeds. Without it, a value that saved still looks unsaved, and leaving asks to discard it. - A compound control with its own endpoint (moving company, the work-view preference) calls
TaskEmbedBridge?.saved({...})after its own success - seemoveToCompanyFromPanel(). - A new nested editor (another iframe, another TinyMCE) must take part in the unsaved check - the bridge watches inputs in
.tf-framedocuments and the onetinyEditor, and nothing else. - No
window.top, noparentassumptions, no full-page navigation from the panel. Open other records in a new tab, as Open separately does. - Size from the panel. The bridge measures
#detailPanel; the page body is as tall as the frame, so it can report growing but never shrinking (the same lesson as PR #198).
The conversation (postMessage only, same origin only)
| Direction | Message | Meaning |
|---|---|---|
| frame β ticket | ready (hasActions) |
Drawn; whether the header has its own Open separately / Close |
| frame β ticket | resize (height) |
Size the frame to this |
| frame β ticket | dirty (dirty) |
Something is, or is no longer, unsaved |
| frame β ticket | changed |
A save succeeded - reload the task pills |
| frame β ticket | error |
The task can no longer be edited from this ticket |
| frame β ticket | closed |
The editor's own Close |
| ticket β frame | freeitsm:task-embed:leave (requestId) |
May I close you? |
| frame β ticket | freeitsm:task-embed:leave-result (requestId, canLeave) |
The answer, after flushing the description, waiting up to 10 s for writes, and asking to Discard |
| ticket β frame | freeitsm:task-embed:context-lost |
The task was unlinked |
Every message carries {ticketId, taskId}, is posted to location.origin only, and is checked on arrival for origin, sending window and context. A leave answer for a frame that has since been replaced is ignored (frameGeneration). No answer within 45 seconds counts as no - keeping the editor is the safe way to be wrong.
π΄ TRAP: the bridge wraps
window.fetch- but only inside the frame's own document. It counts writes in flight and marks a failed one (a non-JSON orsuccess: falseresponse) as unsaved, without changing any Tasks writer. It sits on top ofassets/js/csrf.js. Do not "tidy" it intoassets/js/tasks.js: there it would run on the board too.
Testing
tests/task-embedded-workspace.php (forged roots, unrelated tasks, another company, missing module access, read-only writes, unlinking), tests/task-embed-bridge.php (sizing, messages, drafts, nested forms, editor baselines - Chrome, no database) and tests/ticket-task-live.php (the real editor in the real ticket page: scheduled work, checklists, forms, subtasks). The last two drive Chrome from PHP; see Developer tests - Tickets.
Related pages
- Tickets β Follow-up tasks Β· Tasks β Linking β the user-facing side
- Linking equipment to tickets β the strip pattern this follows
- Multi-Tenancy Developer Guide β why
NULLmeans Default for a task - Mail could only ever be collected from Inbox β the same-question-answered-twice failure the right-click entry avoids