Skip to content

Task Board and Subtasks Developer Guide

Ed Mozley edited this page Oct 1, 2026 · 1 revision

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.


πŸ“ Files

πŸ—„οΈ 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.


1. Columns by analyst

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.

⚠️ A task can point at an analyst who no longer exists β€” deleted, or left behind by old demo data. Ed's real install had 33 such tasks across five ids, and the first build headed their column "#3". The column now reads Former analyst #id (tasks.board.former_analyst), so the work is visible and the cause is named rather than disguised.


2. Subtasks under their card

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 ::before is the trunk (a left border, cut to half height on :last-child so it ends at the last branch, like β””) and ::after the branch.
  • The preference tasks_hide_done_subtasks ('on' / '') is a personal setting on Preferences β†’ Subtasks on the task board, read by tasks/index.php into window.TASK_HIDE_DONE_SUBTASKS. The count on the card is unaffected, so a tidied-away list never looks like no subtasks.

3. Dragging subtasks into order

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.


4. Start and Due as columns

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.


5. The tabs

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.


The traps

  • Do not change the shape of subtasks in list.php. It is counts, read by the card bar and the table. Rows are subtask_items.
  • board_position means 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 the parent_task_id IS NULL / = ? split (moveTask() re-packs only parent_task_id IS NULL rows; reorder.php is 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_id directly 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.

How it was verified

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

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally