-
-
Notifications
You must be signed in to change notification settings - Fork 29
Task Board and Subtasks Developer Guide
Shipped in 2.10.0 Β· Asked for by Ed Β· User-facing page: Tasks (Β§ The Board, Β§ Subtasks, Β§ The large window)
Six changes to the Tasks module that arrived together at the end of 2.10.0: board columns by analyst, subtasks listed under their card, subtasks dragged into order, Start and Due as columns in the task window, tabs that look like tabs with a Documents count, and a personal preference that hides finished subtasks on the board. Checklist re-ordering shipped at the same time and is written up with the rest of that feature: Checklists on tasks β Developer Guide Β§ Re-ordering.
Only one of them touched the schema, and not with a new column: subtask order reuses tasks.board_position.
ποΈ data Β· π endpoint Β· βοΈ service Β· π₯οΈ UI Β· π¨ style Β· βοΈπ€ preference Β· π strings Β· β help Β· π§ͺ Feature Bingo
| π¨ | File | What it does | Section |
|---|---|---|---|
| π | api/tasks/list.php |
Adds subtask_items (the subtask rows) beside the existing subtasks counts |
2 |
| π | api/tasks/get.php |
Subtask query now returns start_date too |
4 |
| π | api/tasks/reorder_subtasks.php |
New. Saves a parent's subtask order | 3 |
| βοΈ | includes/services/tasks.php |
createTask(): a new subtask goes to the end of its parent's list |
3 |
| π₯οΈ |
assets/js/tasks.js (v46) |
renderBoardByAnalyst(), dropOnAnalystColumn(), boardSubtasksHtml(), subtaskRowHtml(), subtaskHeadHtml(), setSubtaskDate(), subtaskDrag*(), setModalTabCount()
|
all |
| π₯οΈ |
assets/js/documents.js (v7), includes/documents_panel.php
|
Dispatches fd:count when the documents list has loaded |
5 |
| π¨ |
assets/css/tasks.css (v37) |
Analyst columns, the tree under a card, the subtask grid, the folder tabs | all |
| βοΈπ€ | tasks/index.php |
Reads tasks_board_group and tasks_hide_done_subtasks; prints window.TASK_BOARD_GROUP, window.TASK_HIDE_DONE_SUBTASKS; the Columns toggle |
1, 2 |
| βοΈπ€ | system/preferences/index.php |
Subtasks on the task board switch | 2 |
| π |
lang/en/tasks.php, lang/en/system.php
|
New strings (owed to the other locales) | |
| β |
tasks/help.php, system/help/preferences.php
|
Board, task panel and Preferences help | |
| π§ͺ | includes/feature_bingo/cards/tasks.php |
tasks.board_by_analyst, tasks.hide_done_subtasks
|
What is not touched: api/tasks/reorder.php (the status board's drag), the table, timeline and calendar views, and the REST API. The calendar has its own subtasks switch (tasks_calendar_subtasks) and is unaffected.
A Columns toggle (Status | Analyst) under View in the left panel, written with exactly the same .sidebar-section / .view-toggle / .view-btn markup as the board/list toggle so it looks and behaves like it. The choice is a user_preferences row, tasks_board_group, whitelisted when read:
// tasks/index.php
if ($__k === 'tasks_board_group' && $__v === 'analyst') { $taskBoardGroup = 'analyst'; }No columns are stored. By analyst, the columns are worked out from the tasks in view every time the board draws, because who has work changes as the list is filtered, searched and reassigned:
function renderBoard() {
if (boardGroup === 'analyst') { renderBoardByAnalyst(); return; }
// ... the status board, unchanged
}renderBoardByAnalyst() groups the visible tasks by assigned_analyst_id, puts your column first (always present, so you can drag work to yourself when you have none), the rest by name, and Unassigned last. Within a column tasks sort by the status board's column order, then board_position. Each card gets a status chip in the status's own colour, because the column no longer says what state it is in.
Dropping a card on another column reassigns it, and deliberately through the ordinary save:
const ok = await postTaskChange({ id: taskId, assigned_analyst_id: to || null }, 'tasks.board.reassign_failed');postTaskChange() posts to save.php β TasksService::updateTask(), so the analyst-exists check, task.assigned, notifications (including the task email) and workflow events all apply exactly as when the assignee is changed in the panel. A drag-only shortcut that wrote the column directly would have skipped every one of them. The branch sits in the active endDrag(); dragging columns and the quick-add + stay status-only.
tasks.board.former_analyst), so the work is visible and the cause is named rather than disguised.
list.php already returned subtasks β but as counts ({total, done}), feeding the 3/5 bar. Changing that shape would break every caller, so the rows ride alongside in a new key, one query for the whole board:
$stmt = $conn->prepare(
"SELECT t.id, t.parent_task_id, t.title, t.start_date, t.due_date,
ts.is_closed AS status_is_closed,
tp.name AS priority, tp.colour AS priority_colour,
a.full_name AS analyst_name
FROM tasks t
LEFT JOIN task_statuses ts ON ts.id = t.status_id
LEFT JOIN task_priorities tp ON tp.id = t.priority_id
LEFT JOIN analysts a ON a.id = t.assigned_analyst_id
WHERE t.parent_task_id IN ({$placeholders})
ORDER BY t.board_position ASC, t.created_datetime ASC"
);
// ...
$task['subtask_items'] = $subtaskRows[$task['id']] ?? [];$taskIds are the parents already in the (company-scoped) list, and a subtask always lives in its parent's company, so this needs no scope predicate of its own.
On the card, boardSubtasksHtml(t) draws them:
function boardSubtasksHtml(t) {
const items = t.subtask_items || [];
if (!cardFields.subtasks || !items.length) return '';
if (window.TASK_HIDE_DONE_SUBTASKS && items.every(s => s.status_is_closed)) return '';
// ... one .task-card-subtask per item, onclick="event.stopPropagation(); openDetailPanel(id)"
}- It rides on the existing subtasks card setting. Somebody who turned subtasks off the card does not want them back as a list.
- The click stops at the row and opens the subtask, not the parent.
-
The tree is pure CSS: each row's
::beforeis the trunk (a left border, cut to half height on:last-childso it ends at the last branch, likeβ) and::afterthe branch. -
The preference
tasks_hide_done_subtasks('on'/'') is a personal setting on Preferences β Subtasks on the task board, read bytasks/index.phpintowindow.TASK_HIDE_DONE_SUBTASKS. The count on the card is unaffected, so a tidied-away list never looks like no subtasks.
Where the order lives. get.php already sorted subtasks ORDER BY board_position ASC, created_datetime ASC, and a subtask never appears on the board as a card of its own, so its board_position meant nothing. It now means "position among its siblings". No new column.
The endpoint, api/tasks/reorder_subtasks.php, takes the parent and the full list:
if (!analystCanAccessTask($conn, (int)$_SESSION['analyst_id'], $parentId)) { /* Task not found */ }
$stmt = $conn->prepare("UPDATE tasks SET board_position = ? WHERE id = ? AND parent_task_id = ?");
foreach ($ids as $pos => $id) {
$stmt->execute([$pos, $id, $parentId]);
}π The parent is the gate, and AND parent_task_id = ? carries it to every row. The ids come from the browser; an id belonging to another parent β or another company β matches nothing and changes nothing, which is the same pattern reorder.php uses with its scope predicate.
New subtasks go to the end of their parent's list. createTask() used to give every new task the next position in its status column β right for a card, arbitrary for a subtask, and after a re-order it could land a new subtask in the middle. Now:
if ($links['parent_task_id']) {
$posStmt = $conn->prepare("SELECT COALESCE(MAX(board_position), -1) + 1 FROM tasks WHERE parent_task_id = ?");
$posStmt->execute([(int) $links['parent_task_id']]);
$boardPosition = (int)$posStmt->fetchColumn();
}The drag uses the HTML5 drag events on the row (draggable="true"), with one rule that matters: only the handle starts it. The handle's mousedown sets data-grab; subtaskDragStart() cancels any drag without it. Otherwise a click with a slight wobble on a title would start dragging the row. A document-level mouseup clears a data-grab that never became a drag. subtaskDragOver() moves the row live under the pointer; subtaskDragEnd() posts the order and refreshes the board in the background (it lists subtasks in the same order). A refused save re-opens the task to put the list back as stored.
The subtask row was a flex line with a due badge or an "add a due date" picker. It is now a grid, shared with a heading row so the headings sit exactly over their dates:
.subtask-item,
.subtask-head {
display: grid;
grid-template-columns: 14px 16px minmax(0, 1fr) minmax(0, 110px) 128px 128px;
/* handle | tick | name | who | start | due */
}Both dates are always <input type="date">, so a date can be changed as easily as set. setSubtaskDate() posts just that field (null when cleared) and, deliberately, does not call openDetailPanel():
async function setSubtaskDate(subtaskId, field, input) {
input.classList.toggle('is-empty', !input.value);
const ok = await postTaskChange({ id: subtaskId, [field]: input.value || null }, 'tasks.toast.save_failed');
if (ok) loadTasks();
}Rebuilding the window resets its scroll to the top and puts tabs back on Details β the same reason addSubtask() appends a row instead of refetching. An empty date is faded (.is-empty) so a list of undated subtasks does not look like a form. Below 700px the who column is dropped.
subtaskRowHtml() is now the one place a row is built: the window's render and appendSubtaskRow() (after adding) both use it, so the two can no longer drift β they had, slightly, before.
The tabs layout already existed (applyModalTabs(), per-analyst tasks_modal_layout). Two changes:
They look like tabs. Each .tdm-tab is flex: 1 1 0 (equal widths whatever the label), raised on --surface-2 with a border on three sides and margin-bottom: -1px over the strip's rule; the active one takes --surface and a matching bottom border so it joins the page, with an inset accent line on top. The border width never changes between states, so switching tabs cannot nudge the content by a pixel.
Documents gets a count. The other counts are taken when the tabs are built, by counting rows. Documents cannot be: the panel is mounted after the tabs exist and loads asynchronously, and it pages (so its row count is not the total). So the documents panel announces its total when it paints:
// assets/js/documents.js, Panel.prototype.paint
this.el.dispatchEvent(new CustomEvent('fd:count', { bubbles: true, detail: { total: this.total } }));and the tab listens on its own panel:
if (t.key === 'documents') {
panel.addEventListener('fd:count', e => setModalTabCount(btn, e.detail.total));
}setModalTabCount() creates, updates or removes the badge, and appendSubtaskRow() uses it too, so adding a subtask bumps Subtasks (n) without a rebuild. The event is harmless everywhere else the documents panel is used β nobody listens.
-
Do not change the shape of
subtasksinlist.php. It is counts, read by the card bar and the table. Rows aresubtask_items. -
board_positionmeans two things. For a top-level task, its place in a status column; for a subtask, its place among siblings. Any code that re-packs positions must keep theparent_task_id IS NULL/= ?split (moveTask()re-packs onlyparent_task_id IS NULLrows;reorder.phpis only ever sent the cards in a status column). -
Reassigning from the board must go through the save. It is tempting to write
assigned_analyst_iddirectly in the drop handler; that would silently skip notifications, the task email and workflows. -
Never rebuild the task window for a small edit. Dates, ticks and new subtasks update in place;
openDetailPanel()is for opening a task. - Any new count that arrives late needs an event, not a count at build time β see Documents.
On a throwaway database and worktree (never Ed's real data), through the real endpoints and a headless-Chrome harness on the real page:
| Check | Result |
|---|---|
| Three subtasks created under one parent | positions 0, 1, 2 among siblings |
reorder_subtasks.php with [4,2,3,5] where 5 is another task's |
4, 2, 3 re-ordered; task 5 untouched |
list.php subtask_items
|
in the stored order; counts unchanged |
| Board | three rows under the card, 12px, tree lines drawn; clicking the first opened that subtask |
| Task window | heading row Start Date / Due Date; start column aligned across rows |
| Drag by handle (first row to the end) | stored order One, Two, Three
|
| Drag not started on the handle |
dragstart cancelled |
| Due date changed in place | saved; still on the Subtasks tab |
| Tabs | six tabs, all 207px wide; Subtasks 3, Documents 2 (two links attached) |
| Preference on, all subtasks done | list hidden, count still 3/3; a parent with an open subtask still lists it |
| Preferences page | switch renders, ticked from the stored value |
- Tasks β the user-facing page
- Checklists on tasks β Developer Guide β including re-ordering checklists and steps
- Notifications β the task-assigned email a board reassignment can trigger
- People on a task β Developer Guide β the owner/involved model a board column is grouped by
- Attached documents β Developer Guide β the documents panel that now reports its count
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
- β³ π’ 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