Skip to content

Ticket Checklist Step Types Developer Guide 2

Ed Mozley edited this page Nov 11, 2026 · 2 revisions

πŸ› οΈ The same step types on tickets as on tasks - Developer Guide (2 of 2): endpoint, screens, workflow and tests

Page 1 covers the files, the schema and the engine in ChecklistsService: attaching, ticking, results, the step note the server writes, and the closure rule. This page is everything that calls it. Built for #271. User guide: Checklists and SOPs.


1. The browser endpoint - api/tickets/ticket_checklists.php

The panel's endpoint used to hold the copy, the tick and the untick itself. Now every action that changes data is a thin adapter: gate on the ticket, build an ActorContext from the session, call the service.

Action Method Calls
get_ticket_checklists GET ChecklistsService::ticketChecklists()
attach_template POST attachTemplateToTicket(), with skip_if_attached from the right-click menu
toggle_item POST toggleTicketItem()
set_result POST setTicketItemResult() - new in #271
remove_checklist POST removeTicketChecklist()

The guards before the switch:

// The three actions that change data are never run from a GET, which a link on
// another site can send (csrf.php). The reads stay on GET.
if (in_array($action, ["attach_template", "toggle_item", "set_result", "remove_checklist"], true)) {
    csrfRequireWriteMethod();
}

set_result was added to that list with the action. A write action left out of it could be fired by a link on another site.

The two step actions:

        case "toggle_item":
            $itemId = (int)($_POST["item_id"] ?? 0);
            $completed = (!empty($_POST["completed"]) || !empty($_POST["is_completed"])) ? 1 : 0;
            $responseValue = isset($_POST["response_value"]) ? trim((string)$_POST["response_value"]) : null;

            if ($itemId <= 0) throw new Exception("item_id is required");
            checklistTicketGate($conn, checklistTicketForItem($conn, $itemId));

            // GH #271: the shared engine. It refuses a tick with an empty required
            // value or a result step with no result, stores UTC (GH #126), and
            // writes the step's note on the ticket - which the browser used to do
            // itself after the fact, so a tick from anywhere else left no trace.
            $row = ChecklistsService::toggleTicketItem($conn, ActorContext::fromSession($conn), $itemId, (bool)$completed, $responseValue);
            echo json_encode(["success" => true] + array_intersect_key($row, array_flip([
                "is_completed", "response_value", "completed_by_id", "completed_by_name", "completed_datetime", "result", "exception_note",
            ])));
            exit;

        case "set_result":
            // GH #271: Pass, Fail (with what went wrong) or Not applicable, as on tasks (#181).
            $itemId = (int)($_POST["item_id"] ?? 0);
            if ($itemId <= 0) throw new Exception("item_id is required");
            checklistTicketGate($conn, checklistTicketForItem($conn, $itemId));
            $row = ChecklistsService::setTicketItemResult($conn, ActorContext::fromSession($conn), $itemId,
                (string)($_POST["result"] ?? ""), isset($_POST["exception_note"]) ? (string)$_POST["exception_note"] : null);
            echo json_encode(["success" => true] + array_intersect_key($row, array_flip([
                "is_completed", "completed_by_name", "completed_datetime", "result", "exception_note",
            ])));
            exit;
  • The gate is on the ticket, through the child id. checklistTicketForItem() resolves the step to its ticket, then checklistTicketGate() runs analystCanAccessTicket(). The service's header says "Callers gate on the TICKET … before calling"; the service itself does not check access. Any new caller must gate first.
  • A refusal is a 400 with the service's message. The whole switch is wrapped in catch (Throwable $e), so a ServiceError such as "This step needs a value before it can be ticked." reaches the browser as {"success": false, "error": "…"} and the panel shows it in a toast.
  • The request body is JSON. The endpoint merges the decoded body into $_POST before the switch, so item_id, result and exception_note are read from $_POST.

2. The ticket panel - checklists/ticket_view.js

The panel draws the same step rows in two places: the pop-out (openChecklistModal()) and the expanded card on the workspace's Work tab (renderTicketChecklistsInline()). Both call checklistStepRowHtml(), so they cannot drift apart. #271 makes that function hand a result step to its own renderer:

function checklistStepRowHtml(ticketId, it, reopenPopout) {
    if (it.result_mode === 'pass_fail') return checklistResultStepRowHtml(ticketId, it, reopenPopout);
    const isChecked = (it.is_completed == 1 || it.is_completed === true || it.is_completed === '1');
    // …
                <div style="flex: 1;">
                    <span style="${isChecked ? 'text-decoration: line-through; color: var(--text-muted, #94a3b8);' : ''}">
                        ${escapeHtml(it.title)}
                        // … the mandatory star, role and "Note required" badges
                    </span>
                    ${checklistStepDescHtml(it)}
                </div>
    // …
}

The description

/** A step's description (GH #190 on tasks, #271 on tickets), as plain text under its title. */
function checklistStepDescHtml(it) {
    return it.description
        ? `<div style="font-size: 12px; color: var(--text-muted, #64748b); margin-top: 2px; white-space: pre-wrap;">${escapeHtml(it.description)}</div>`
        : '';
}

Plain text, escaped, line breaks kept by white-space: pre-wrap. A description is never HTML.

A result step

/**
 * A step that asks for a RESULT (GH #271, as on tasks #181): Pass, Fail or Not
 * applicable instead of a tick, and Fail asks what went wrong. A failed step
 * still counts as done - the ticket can close; the failure is on the record.
 */
function checklistResultStepRowHtml(ticketId, it, reopenPopout) {
    const isMand = (it.is_mandatory == 1 || it.is_mandatory === true || it.is_mandatory === '1');
    const completedDate = formatStepDatetime(it.completed_datetime);
    const by = it.result && it.completed_by_name
        ? `<div style="font-size: 11px; color: var(--text-muted, #64748b); margin-top: 4px;">${escapeHtml(chkT('checklists.panel.result_by', 'Recorded by {name}').replace('{name}', it.completed_by_name))}${completedDate ? ' Β· ' + escapeHtml(completedDate) : ''}</div>`
        : '';
    const exception = it.result === 'fail' && it.exception_note
        ? `<div style="font-size: 12px; color: #991b1b; background: #fef2f2; padding: 4px 8px; border-radius: 4px; margin-top: 4px; border-left: 2px solid #dc2626;">${escapeHtml(chkT('checklists.panel.result_exception', 'What went wrong: {note}').replace('{note}', it.exception_note))}</div>`
        : '';
    return `
        <div style="padding: 4px 0 4px 24px; font-size: 13px; color: var(--text, #1e293b);">
            <div>
                ${escapeHtml(it.title)}
                // … the mandatory star and role badge
            </div>
            ${checklistStepDescHtml(it)}
            ${checklistResultButtonsHtml(ticketId, it, reopenPopout)}
            ${exception}
            ${by}
        </div>
    `;
}

No checkbox at all: a result step cannot be ticked, in the browser or on the server.

The buttons

/** The Pass / Fail / Not applicable buttons for a step that asks for a result. */
function checklistResultButtonsHtml(ticketId, it, reopenPopout) {
    const isDone = (it.is_completed == 1 || it.is_completed === true || it.is_completed === '1');
    const names = {
        pass: chkT('checklists.panel.result_pass', 'Pass'),
        fail: chkT('checklists.panel.result_fail', 'Fail'),
        na:   chkT('checklists.panel.result_na', 'Not applicable'),
    };
    const colours = { pass: '#16a34a', fail: '#dc2626', na: '#64748b' };
    const btns = ['pass', 'fail', 'na'].map(r => {
        const on = it.result === r;
        return `<button type="button" class="chk-chip" aria-pressed="${on}"
                    onclick="chooseTicketStepResult(${ticketId}, ${it.id}, '${r}', ${reopenPopout ? 'true' : 'false'})"
                    style="${on ? `background: ${colours[r]}; color: #fff; border-color: ${colours[r]};` : ''}">${escapeHtml(names[r])}</button>`;
    }).join('');
    const clear = isDone
        ? `<button type="button" class="chk-chip" onclick="executeToggleItem(${ticketId}, ${it.id}, 0, null, '', '', ${reopenPopout ? 'true' : 'false'})">${escapeHtml(chkT('checklists.panel.result_clear', 'Clear'))}</button>`
        : '';
    return `<div role="group" aria-label="${escapeHtml(chkT('checklists.panel.result_label', 'Result'))}" style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px;">${btns}${clear}</div>`;
}
  • aria-pressed tells a screen reader which result is chosen, as well as the colour.
  • Clear is an untick. It calls executeToggleItem(…, 0, …), and the server's untick clears the result and its note (page 1, section 6). There is no separate "clear result" action.

The collapsed card's "Next:" line

When the workspace card is collapsed it shows only the next pending step. A result step there gets the buttons instead of a tickbox:

        } else if (nextPendingItem && nextPendingItem.result_mode === 'pass_fail') {
            // A result step is answered with Pass / Fail / Not applicable, never a tick (GH #271).
            html += `
                <div style="padding: 2px 0; font-size: 12px;">
                    <strong style="color: var(--text-muted, #64748b); font-weight: normal; margin-right: 4px;">${escapeHtml(chkT('checklists.panel.next', 'Next:'))}</strong>
                    ${escapeHtml(nextPendingItem.title)}
                    <a href="javascript:void(0)" onclick="openChecklistModal(${ticketId})" …>View all steps &raquo;</a>
                    ${checklistResultButtonsHtml(ticketId, nextPendingItem, false)}
                </div>
            `;
        } else if (nextPendingItem) {
            // … the plain tickbox line

Answering

/**
 * Records a step's result. Pass and Not applicable save at once; Fail first asks
 * what went wrong, because a failed check with no word on why is not evidence
 * of anything - the server refuses an empty one too.
 */
function chooseTicketStepResult(ticketId, itemId, result, reopenPopout) {
    if (result !== 'fail') {
        executeSetResult(ticketId, itemId, result, null, reopenPopout);
        return;
    }
    const info = findChecklistItem(itemId);
    let modal = document.getElementById('chkDataCaptureModal');
    // … reuses the value prompt's modal: a "What went wrong?" textarea, Cancel and Save
    document.getElementById('chkFailSave').onclick = () => {
        const note = box.value.trim();
        if (!note) {
            document.getElementById('chkFailError').style.display = 'block';
            box.focus();
            return;
        }
        closeDataCaptureModal();
        executeSetResult(ticketId, itemId, 'fail', note, reopenPopout);
    };
}

/** Saves a step's result, then redraws the checklist and the notes (the server wrote the step's note). */
async function executeSetResult(ticketId, itemId, result, note, reopenPopout) {
    try {
        const res = await fetch(CHK_API, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ action: 'set_result', item_id: itemId, result: result, exception_note: note })
        });
        const data = await res.json();
        if (!data.success) {
            showToast(data.error || chkT('checklists.panel.save_failed', 'Could not update the step'), 'error');
            return;
        }
        await loadTicketChecklists(ticketId);
        if (reopenPopout) openChecklistModal(ticketId);
        if (typeof loadNotes === 'function') loadNotes(ticketId);
    } catch (e) {
        console.error('Error recording a step result:', e);
        showToast(chkT('checklists.panel.save_failed', 'Could not update the step'), 'error');
    }
}

The browser check on the Fail note is for speed. The server's check is the rule.

The browser no longer writes the step note

Before #271, a successful tick ended with logStepNote(), which posted the note to save_note.php. That function is gone. executeToggleItem() now only shows the note the server wrote:

        if (data.success) {
            await loadTicketChecklists(ticketId);
            if (reopenPopout) {
                openChecklistModal(ticketId);
            }
            // The step's note ("βœ… Checklist step completed: ...") is written by the
            // SERVER now (GH #271) - it used to be posted from here after the fact,
            // so a tick made anywhere but this panel left no trace. Just show it.
            if (typeof loadNotes === "function") loadNotes(ticketId);
        } else {
            showToast(data.error || 'Could not update the step', 'error');
        }

πŸ”΄ Do not bring logStepNote() back. The server already writes the note, so a browser-side note would put every step on the ticket twice.

executeToggleItem() still takes checklistTitle and stepTitle. They were only used for that note and are now unused. The Clear button passes '' for both.

The cache-buster

tickets/index.php loads ../checklists/ticket_view.js?v=N. #271 moved it from 13 to 14 (it is 15 at HEAD, after #2785). Grep the current number before bumping it; never recall it.


3. The template editor - a description box, and the save that wiped it (#2781)

What used to go wrong

Step descriptions arrived with #190 (tasks). checklist_template_items.description existed and copyTemplateToTask() copied it, but:

  1. the editor had no field for it, so a description could only be typed into the database; and
  2. every save wiped it. The save deletes every step of the template and inserts them again, and the INSERT did not list description. Any description that existed was erased the first time someone saved the template, even to fix a typo in its title.

#271 made descriptions show on tickets too, which is how the hole was found.

The fix in the save - checklists/api.php

    $del = $conn->prepare("DELETE FROM checklist_template_items WHERE template_id = ?");
    $del->execute([$id]);

    $ins = $conn->prepare("INSERT INTO checklist_template_items (template_id, title, suggested_role, is_mandatory, requires_input, input_placeholder, sort_order, is_demo) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
    // #181: a step can ask for a RESULT on tasks (Pass / Fail / Not applicable).
    // Its own statement, so the save still works before Database Verification.
    $resultUpd = null;
    try { $conn->query("SELECT result_mode FROM checklist_template_items LIMIT 0"); $resultUpd = $conn->prepare("UPDATE checklist_template_items SET result_mode = ? WHERE id = ?"); } catch (Throwable $e) { /* not yet */ }
    $descUpd = $conn->prepare("UPDATE checklist_template_items SET description = ? WHERE id = ?");
    foreach ($items as $idx => $it) {
        $itTitle = is_string($it) ? trim($it) : trim($it['title'] ?? '');
        if ($itTitle === '') continue;
        // …
        $ins->execute([$id, $itTitle, $role, $isMand, $reqInput, $placeholder, $idx + 1, $isDemo]);
        $newItemId = (int)$conn->lastInsertId();
        if ($resultUpd && is_array($it) && ($it['result_mode'] ?? '') === 'pass_fail') {
            $resultUpd->execute(['pass_fail', $newItemId]);
        }
        // TRAP: the steps are deleted and re-inserted on every save, so a column
        // left out of this save is WIPED, not kept. The step description (#190)
        // was - every save of a template erased its steps' descriptions (#271).
        $stepDesc = is_array($it) ? trim((string)($it['description'] ?? '')) : '';
        if ($stepDesc !== '') {
            $descUpd->execute([mb_substr($stepDesc, 0, 2000), $newItemId]);
        }
    }

    // #181: keep this version. Saving rewrites the steps above; the version keeps
    // what the procedure said, and tasks record which version they were given.
    require_once '../includes/services/checklists.php';
    ChecklistsService::recordTemplateVersion($conn, $id, $analystId);

As SQL, per step:

DELETE FROM checklist_template_items WHERE template_id = ?;

INSERT INTO checklist_template_items
       (template_id, title, suggested_role, is_mandatory, requires_input, input_placeholder, sort_order, is_demo)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
UPDATE checklist_template_items SET result_mode = 'pass_fail' WHERE id = ?;   -- only for a result step
UPDATE checklist_template_items SET description = ? WHERE id = ?;             -- only when one was typed
  • Why an UPDATE after the INSERT, not one more column in it. It follows the result_mode pattern already there. A step with no description stays NULL, and the INSERT is left as it was.
  • Capped at 2,000 characters with mb_substr(), so a multi-byte character is never cut in half. The column is TEXT; the cap keeps a step's note-under-the-title short.
  • Legacy string steps (is_string($it)) have no description, so $stepDesc is '' and nothing is written.

The version snapshot keeps it

recordTemplateVersion() (in ChecklistsService) snapshots the template after every save. Its step query did not select description either, so a version said nothing about what each step meant. #2781 adds it:

            // description: the version keeps what each step said (#271 made it editable).
            $i = $conn->prepare("SELECT title, description, suggested_role, is_mandatory, requires_input, input_placeholder, sort_order{$resCol}
                                   FROM checklist_template_items WHERE template_id = ? ORDER BY sort_order, id");

The box - checklists/edit/index.php

Each step's options row gets a full-width textarea, after the value prompt:

                    // The step's description: shown under the step on the ticket or task.
                    '<span class="step-desc-wrap">' +
                      '<textarea class="ed-textarea step-desc" data-f="description" rows="1" aria-label="' + esc(t('checklists.editor.step_desc_label')) + '" placeholder="' + esc(t('checklists.editor.step_desc_ph')) + '">' + esc(s.description) + '</textarea>' +
                    '</span>' +
        /* The step's own description (#190 / #271): a full-width line in the options row, so it takes the row's phone layout. */
        .step-desc-wrap { flex: 1 1 100%; }
        .step-desc { min-height: 38px; font-size: 12px; }

No new event handler was needed. The list already has one delegated input listener that writes any field with a data-f into the steps array:

        document.getElementById('edSteps').addEventListener('input', function (e) {
            const li = e.target.closest('.step-item');
            if (!li || !e.target.dataset.f) return;
            steps[+li.dataset.index][e.target.dataset.f] = e.target.value;
            markDirty();
        });

The field also had to be carried in the two places that build a step object, or it would be lost on load or start as undefined:

        // load(): from the API
                steps = (t.items || []).map(function (it) {
                    return {
                        title: it.title || '',
                        description: it.description || '',
                        // …
                        result_mode: it.result_mode === 'pass_fail' ? 'pass_fail' : 'none'
                    };
                });

        // Add step
            steps.push({ title: '', description: '', suggested_role: '', is_mandatory: 1, requires_input: 0, input_placeholder: '', result_mode: 'none' });

The get action already returned SELECT * FROM checklist_template_items, so the description reached the editor without an API change. The Save button sends items: steps.filter(…) as JSON, description included.

The editor's Ask for a result hint was reworded in the same change: "This step records Pass, Fail or Not applicable instead of a tick, on tasks and on tickets, and Fail asks what went wrong." (en, de, es). The other eight locales had their old "Tickets keep a tick" sentence removed, so they fall back to English rather than say something no longer true.


4. The workflow action - attach_checklist

Before #271 the action in workflow/includes/engine.php had its own copy of the template copy: no descriptions, no result mode, and created_by_id hard-coded to 1. Now it calls the engine:

        foreach ($templateIds as $tplIdRaw) {
            $tplId = (int)$tplIdRaw;
            if ($tplId <= 0) continue;
            self::assertSharedOrRunCompany($conn, 'checklist_templates', $tplId, 'checklist template');

            // A checklist made tasks-only AFTER the workflow was saved (the picker offers ticket
            // and both only). Skip it with a note rather than fail the run, so the other
            // checklists and the later actions still happen - the note puts the run on the
            // Watchtower Workflows card, so somebody finds out and fixes the workflow.
            $sc = $conn->prepare("SELECT title, scope FROM checklist_templates WHERE id = ?");
            $sc->execute([$tplId]);
            $scRow = $sc->fetch(PDO::FETCH_ASSOC);
            if ($scRow && (string)$scRow['scope'] === 'task') {
                $notAttached[] = (string)$scRow['title'];
                continue;
            }

            // GH #271: the one copy of "attach a template", shared with the ticket
            // panel and tasks - so steps keep their descriptions and results. It
            // skips a template already on the ticket, and records no analyst: this
            // used to write created_by_id 1, naming analyst #1 for every attach.
            require_once __DIR__ . '/../../includes/services/checklists.php';
            try {
                $chkId = ChecklistsService::attachTemplateToTicket($conn,
                    new ActorContext(0, null, 'workflow', 'en', 'Workflow'), $ticketId, $tplId, true);
            } catch (ServiceError $e) {
                if ($e->kind === 'not_found') continue;   // a template deleted since the workflow was saved
                throw $e;
            }
            if ($chkId === null) continue;   // already attached
            $t = $conn->prepare("SELECT title FROM checklist_templates WHERE id = ?");
            $t->execute([$tplId]);
            $attached[] = (string)$t->fetchColumn();
        }
  • new ActorContext(0, null, 'workflow', 'en', 'Workflow'): actor 0 means no person. created_by_id is stored NULL, and if the workflow ever ticks a step, its note is a System note with no ticket.note_added event (page 1, section 9).
  • $skipIfAttached = true keeps the action's old rule: a template already on the ticket is skipped, so a workflow that fires twice does not attach twice.
  • A deleted template is skipped, as the old copy's if (!$tpl) continue; did.
  • A task-only template is skipped with a note (changelog #2801). attachTemplateToTicket() refuses scope = 'task' with a validation error, so for a short while after #271 such a template failed the whole run and stopped every later action (the old copy had attached any template). Now the action checks the scope first, skips it, attaches the rest and returns not_attached and an attention note (skipped: true only if nothing was attached); the run succeeds and the later actions run. The picker no longer offers task-only templates: the 'checklist.template_id' lookup is where => "is_active = 1 AND (scope IN ('ticket','both') OR scope IS NULL)". How the note reaches the run history and Watchtower: Workflow engine - Developer Guide β†’ Notes an action leaves.

5. The case report

A ticket's case report shows each checklist with its steps as they stand. includes/case_report.php now reads every column, so the new ones arrive when Database Verify has added them and nothing breaks when it has not:

    /** The ticket's checklists with each step as it stands - ticket checklists keep no step history. */
    private static function ticketChecklists(PDO $conn, int $ticketId): array
    {
        // SELECT *: GH #271's result columns (and template_version) when Database
        // Verification has added them, and nothing missing when it has not.
        $st = $conn->prepare("SELECT * FROM ticket_checklists WHERE ticket_id = ? ORDER BY id");
        $st->execute([$ticketId]);
        $lists = $st->fetchAll(PDO::FETCH_ASSOC);
        $it = $conn->prepare("SELECT * FROM ticket_checklist_items WHERE ticket_checklist_id = ? ORDER BY sort_order, id");
        foreach ($lists as &$l) { $it->execute([(int)$l['id']]); $l['items'] = $it->fetchAll(PDO::FETCH_ASSOC); }
        unset($l);
        return $lists;
    }

includes/case_report_page.php renders a result the way a task's evidence is rendered, with the task strings and CSS classes:

<h3><?php echo $e($c['title']); ?><?php if (!empty($c['template_version'])): ?> <span class="sub"><?php echo $T('template_version', ['n' => $c['template_version']]); ?></span><?php endif; ?></h3>
<!-- … -->
        <td><?php /* GH #271: a ticket step can record a result too, shown as a task's is */ ?>
            <?php if (!empty($it['result'])): ?><span class="res-<?php echo $e($it['result']); ?>"><?php echo $e(t('tasks.evidence.result_' . $it['result'])); ?></span>
            <?php else: ?><?php echo (int)$it['is_completed'] === 1 ? $T('done') : $T('not_done'); ?><?php endif; ?>
            <?php if ((string)($it['response_value'] ?? '') !== ''): ?><div class="sub"><?php echo $e($it['response_value']); ?></div><?php endif; ?>
            <?php if (!empty($it['exception_note'])): ?><div class="exception"><?php echo $e($it['exception_note']); ?></div><?php endif; ?></td>
  • .res-pass, .res-fail, .res-na and .exception are the classes the task evidence already used, so a ticket's failed step looks like a task's.
  • $it['response_value'] ?? '': the ?? '' was added because the row now comes from SELECT *.
  • The step description is not printed in the report.

More on the report: Case report - Developer Guide.


6. The REST API (#273, built on this)

#273 added GET /tickets/{id}/checklists and PATCH /tickets/{id}/checklists/steps/{step_id}. It adds no rules of its own. api/v1/resources/checklists.php calls the same methods, with the API key's context:

        $ctx = ActorContext::fromApiKey($apiKey);
        if ($hasResult) {
            $note = isset($body['note']) ? (string)$body['note'] : null;
            $kind === 'ticket'
                ? ChecklistsService::setTicketItemResult($conn, $ctx, $stepId, (string)$body['result'], $note)
                : ChecklistsService::setTaskItemResult($conn, $ctx, $stepId, (string)$body['result'], $note);
        } else {
            // …
            $kind === 'ticket'
                ? ChecklistsService::toggleTicketItem($conn, $ctx, $stepId, $body['completed'], $value)
                : ChecklistsService::toggleTaskItem($conn, $ctx, $stepId, $body['completed'], $value);
        }

Because the note is written in writeStepNote(), a tick through the API is on the ticket too, ending "(via the API)" ($ctx->source === 'api'). That is the payoff of moving the note to the server. User docs: REST API - Tickets.


7. End to end

An analyst fails a step on a ticket.

  1. The panel loads get_ticket_checklists β†’ ticketChecklists(). The step has result_mode: 'pass_fail', so checklistStepRowHtml() hands it to checklistResultStepRowHtml(): title, description, three buttons, no tickbox.
  2. They press Fail. chooseTicketStepResult() opens the What went wrong? prompt and will not save an empty note.
  3. Save β†’ executeSetResult() β†’ POST set_result with item_id, result: 'fail', exception_note.
  4. The endpoint checks the method (CSRF), resolves the step to its ticket and gates on analystCanAccessTicket().
  5. setTicketItemResult() checks the step asks for a result, the result is one of three, and the note is not empty. It sets result = 'fail', exception_note, is_completed = 1, who and when (UTC).
  6. writeStepNote() β†’ TicketsService::createNote(): an internal note ❌ Checklist step failed: [Checklist] "Step" β€” Exception: …, ticket.note_added fires, tickets.updated_datetime moves.
  7. The browser reloads the checklists (the Fail button filled red, the note under it, Recorded by …) and the notes.
  8. At close, outstandingMandatorySteps() does not list the step, because is_completed = 1. The ticket closes. The case report shows Fail and the note.

A workflow attaches the same template. action_attach_checklist() β†’ attachTemplateToTicket(…, actor 0, skip = true). The template is already there, so it returns null and the action reports nothing attached. On another ticket it copies the steps with their descriptions and result modes, created_by_id NULL.


8. Traps

Where The trap
checklists/api.php, the template save TRAP: the steps are deleted and re-inserted on every save, so a column left out of this save is WIPED, not kept. Any new checklist_template_items column must be written by the save, or it is erased by the next save. (default_value is not written either. Nothing reads it today.)
ChecklistsService, attaching Two copies of "copy a template": copyTemplateToTask() and attachTemplateToTicket(). A new step column must go into both (page 1, section 3).
ChecklistsService::ticketCols() Cached in a static for the process. Run Database Verify in a child process, not in the same script that then uses the service.
writeStepNote() For automation, never name an analyst. NULL is System. Naming 1 was the workflow's old bug.
checklists/ticket_view.js The server writes the step note. A browser-side note would double it.
checklists/ticket_view.js, attachSopToTicket() TRAP (from #2785, right-click attach): the ticket attached to may not be the one open in the reading pane. Loading its checklists would put them on the open ticket's card, and the close warning reads ticketChecklistsData, so it would check the wrong steps.
Any new caller of the ticket methods The service does not check ticket access. Gate on the ticket first, through the child id when you have one.

9. Known gaps

  • The workflow picker offers task-only templates. Closed by changelog #2801: the picker offers ticket and both templates only, and a saved workflow naming a template made tasks-only later skips it with a note instead of failing (section 4).
  • A ticket's steps are not locked when it closes. Tasks refuse changes after completion (assertTaskChecklistChangeAllowed()); tickets never did, and #271 did not add it. Each change is still noted on the ticket.
  • The case report's caption is half out of date. case_report.ticket_checklists_note says "the changes along the way are not recorded". Each change is now an internal note on the ticket. The checklist table itself still shows only the current state.
  • A System step note does not bump tickets.updated_datetime, while a person's (through createNote()) does.
  • Comments still say "on tasks": the result_mode comments on checklist_template_items in database/freeitsm.sql, includes/db_verify_schema.php and checklists/api.php.

10. Tests

tests/ticket-checklists.php - 30 checks

From its header:

Part 1 goes through the REAL endpoint (api/tickets/ticket_checklists.php) as a ZZ analyst with a forged session. Part 2 is the workflow path, at service level.

FREEITSM_TEST_DATABASE=<throwaway db> FREEITSM_TEST_URL=http://localhost/<that install> php tests/ticket-checklists.php
  • Throwaway database only. It skips (exit 0) unless both variables are set and config.php points at the database named.
  • It runs Database Verify first (scripts/db_verify_cli.php --apply) in a child process, for the new columns.
  • The session is forged as a file in FREEITSM_SESS_DIR (default C:/wamp64/tmp).
  • It creates a ZZ analyst and a ZZ template, and removes them, the checklists and the notes it wrote, in finally.
Area Proves
The template editor's save (#2781) the real checklists/api.php saves a step description; get gives it back; a second unchanged save keeps it; the newest version snapshot has it
Attaching the template attaches; its steps arrive in order; the result step is pass_fail and the others none; the description is copied; created_by_id is the analyst
Ticking a step that asks for a value is refused with a blank value (by the server), and accepted with one; a result step cannot just be ticked; an unanswered mandatory result step is outstanding at closure
Results Fail with no note is refused; Fail with a note is saved and ticks the step; a Fail does not hold up closing; a plain step refuses a result; an unknown result is refused; Clear (untick) removes the result and its note; Not applicable needs no note
The note the exact texts for a tick with a value, a Fail, a Clear and a Not applicable; a refused tick wrote no note (the control); every note names the analyst
Workflow $skipIfAttached skips a template already there; attaching without it names no analyst; a workflow's tick is noted as System (analyst_id NULL)
Removing the checklist's steps go with it

Related tests

  • tests/checklists-api.php (#273): the same engine through the REST API with real keys; the server's rules come back as 422s and a tick is noted via the API.
  • tests/task-evidence.php covers the task twins (results, exception notes, the step history). #271 did not change the task methods.

Both test pages: Developer tests - Tickets.


Related

Back to: page 1 - the data and the engine.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally