Skip to content

Issue 88 Subtasks Could Not Be Completed

Ed Mozley edited this page Aug 25, 2026 · 1 revision

Subtasks could not be ticked off (issue #88)

Ticking a subtask's checkbox did not tick it. On some installations it could not be completed at all, and on every installation the click opened the subtask instead of ticking it.

Reported in issue #88 by dschipfel.

Fixed in fe32e1d6, released as update #1199.

There were four separate faults stacked here, and each one hid the one beneath it.


1. What you saw

Open a task, add a subtask, click its checkbox. The panel changes to show the subtask itself, and the box is still empty. Go back to the parent and the subtask is still outstanding. The progress on the parent card never moves.

No error appears anywhere, including in the console β€” which the report noted, and which turned out to be a fault in its own right rather than a missing detail.


2. Fault one β€” the click never arrived as a tick

The subtask row and its checkbox were wired like this:

<div class="subtask-item" onclick="openDetailPanel(${s.id})">
    <input type="checkbox" … onchange="event.stopPropagation(); toggleSubtask(${s.id})">

The guard was on the wrong event.

Ticking a checkbox fires a click on the input. That click bubbles up to the row, whose job is to open things β€” so the panel navigated into the subtask. Calling stopPropagation() inside onchange cannot help: change is a different event, fired at a different moment, and the row was never listening for it.

So a click did two things at once β€” opened the subtask, and fired the toggle β€” and the navigation repainted the panel over the top of whatever the toggle did.

This one happens on every installation, whatever language it is in. It is the fault you actually see.

// after
<input type="checkbox" …
       onclick="event.stopPropagation()"
       onchange="toggleSubtask(${s.id})">

Ticking a box now ticks the box and nothing else. Clicking the row still opens the subtask, exactly as before.


3. Fault two β€” the server could not complete one at all, if you had renamed a status

Underneath, the endpoint asked the database for a status by name:

// api/tasks/toggle_subtask.php β€” before
$newStatusName = $task['status_is_closed'] ? 'To Do' : 'Done';
$newStatusStmt = $conn->prepare("SELECT id, is_closed FROM task_statuses WHERE name = ? LIMIT 1");

To Do and Done are display names. They are listed under Tasks β†’ Settings β†’ Statuses precisely so they can be renamed, and a German site would reasonably call them Zu erledigen and Erledigt.

Rename them and the lookup matches nothing:

{"success":false,"error":"Status 'Done' not configured"}

Every time, in both directions. On such an installation a subtask could never be completed.

This is the same fault as issue #79, where seven ticket-creation paths asked for the status named Open and got nothing on an installation that had renamed it.

The fix asks the question the code actually means β€” which status counts as complete? β€” using the flag rather than the word:

$task['status_is_closed']
    ? "SELECT id, name, is_closed FROM task_statuses
       WHERE is_closed = 0 AND is_active = 1
       ORDER BY is_default DESC, display_order ASC LIMIT 1"
    : "SELECT id, name, is_closed FROM task_statuses
       WHERE is_closed = 1 AND is_active = 1
       ORDER BY display_order ASC LIMIT 1"

Reopening prefers whichever status is marked as the default β€” that is what a default is for. Completing takes the first closed status in display order, which puts Done ahead of Cancelled: completing a subtask must never cancel it.

The codebase already had the right pattern next door. lookupDefault() in includes/services/tasks.php tries the name and then falls back to is_default. The toggle endpoint had no fallback at all.


4. Fault three β€” the tick was drawn from the English word as well

Even a genuinely completed subtask showed an empty box, because the rendering asked the same bad question:

// before
<input type="checkbox" ${s.status === 'Done' ? 'checked' : ''}>
<span class="subtask-title ${s.status === 'Done' ? 'completed' : ''}">

Both the tick and the strikethrough compared the status name to the English word. The API had been returning status_is_closed alongside it the whole time; the render simply was not using it.

// after
<input type="checkbox" ${s.status_is_closed ? 'checked' : ''}>

5. Fault four β€” the refusal was silent

This is why the console was clean, and why fault two could sit there unnoticed:

// before
async function toggleSubtask(id) {
    try {
        await fetch(API_BASE + 'toggle_subtask.php', { … });   // response discarded
        if (selectedTaskId) openDetailPanel(selectedTaskId);
    } catch (e) { console.error(e); }
}

The response was never parsed, success was never checked, and error was never shown. The server refused in plain terms and nobody was listening. The only visible result was the panel repainting with an empty box.

A silent failure is worse than the failure it hides: even with faults one to three fixed, this one would have concealed whatever went wrong next. The response is now read and a refusal raises a toast.


6. Two more, found while in there

The completion time was written in the wrong clock. completed_datetime was set with date('Y-m-d H:i:s') β€” the server's local time β€” into a column sitting directly beside updated_datetime = UTC_TIMESTAMP(). And api/tasks/reorder.php already wrote that same column in UTC. So the stored value depended on which route happened to close the subtask. Now UTC either way.

Three error messages were dressed as successes. showToast(…, 'success') on failure branches in Tasks β€” a green tick announcing that something had gone wrong.


7. πŸ“ The files involved

File What changed
assets/js/tasks.js Propagation guard moved to onclick; tick and strikethrough read status_is_closed; the toggle response is read and failures surfaced; three toasts restyled.
api/tasks/toggle_subtask.php Status chosen by the is_closed flag, not by name; completed_datetime in UTC.
lang/*/tasks.php Two new strings in all 24 languages.

πŸ—„οΈ Checked and already correct

api/tasks/list.php Parent progress was always counted with SUM(CASE WHEN ts.is_closed = 1 …) β€” flag-driven, so the count was right all along. Nothing ever reached it.
api/tasks/reorder.php Also looks a status up by name, but the name comes from the client, which sends back a real status it was given. Not the same fault.

8. Internationalisation

Two strings in the Tasks panel were still hardcoded English β€” a tag chip's Remove tooltip, and the label on a linked change (Change #12). Both now go through t(), translated into all 23 non-English languages.

Worth recording what was not wrong: the German pack for Tasks was already complete. If a German installation shows English in this area, it is the status names β€” To Do, In Progress, Done β€” which are rows in the database seeded in English, not translated strings. Renaming them under Tasks β†’ Settings β†’ Statuses is the intended route, and after this fix doing so no longer breaks anything.


9. How it was verified

The bug was reproduced first, on a dev installation with the statuses renamed to German exactly as a German site would: {"success":false,"error":"Status 'Done' not configured"} in both directions. After the fix, the same calls return {"success":true,"new_status":"Erledigt"} and "Zu erledigen".

Then the real page was driven in a browser, clicking the actual checkbox:

ticking stays on the parent task PASS
the subtask becomes complete PASS
the strikethrough follows PASS
unticking works PASS
clicking the row still opens the subtask PASS β€” negative control

The assertion had to be chosen carefully

The obvious way to check "did it navigate?" is to read selectedTaskId from the harness. That silently false-passes: it is a top-level let, which is not a window property, so it reads as undefined before and after β€” and undefined === undefined passes however badly the page behaved.

The test asserts on the DOM instead. The subtask section only renders for a parent task, so if the panel navigated into the subtask, #subtaskList disappears entirely. That cannot pass by accident.

And the first attempt broke everything

Worth recording. The explanation of fault one was first written as an HTML comment inside the JavaScript template literal, using backticks around `click` and `change`. Backticks end a template literal. tasks.js stopped parsing, and nothing on the page was defined.

The page still rendered β€” the HTML is server-side β€” so it looked completely normal. Only loading it in a browser and asking whether the functions existed found it:

openDetailPanel: undefined
toggleSubtask:   undefined
loadTasks:       undefined

Rendered markup is not proof that the JavaScript parsed.


10. What this means for you

Ticking a subtask ticks it. Clicking the row still opens it. The parent's progress moves.

And if you have renamed your task statuses β€” or would like to, into your own language β€” subtasks now work regardless of what you call them.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally