Skip to content

Right Click Attach Checklist Developer Guide

Ed Mozley edited this page Nov 11, 2026 · 1 revision

πŸ› οΈ Right-click Attach checklist - Developer Guide

How the inbox's right-click Attach checklist β–Έ submenu lists, ticks and attaches checklists, for one ticket or a whole selection. There is no GitHub issue - it was the owner's own idea, changelog #2785 (commits 9f569758f and a2fb5241e). The same work fixed the full picker loading another ticket's checklists into the open one. User guide: Checklists & SOPs β†’ Using one on a ticket. The module as a whole: Checklists & SOPs - Developer Guide.

The one-line version: the menu item is a submenu parent like Set type. Each time the menu opens, populateContextChecklistSubmenu() asks a new read action, context_templates, for every ticket checklist with a score (one ticket only), its effective closure mode (the padlock) and attached_all (on every ticket asked about). The first 12 rows are shown, best match first, with ticked rows inert. Clicking a row calls attachChecklistFromContext(), which posts attach_template once per ticket with skip_if_attached: true. The service then skips a ticket that already has that template, and the toast counts attached, skipped and failed. Search all checklists… opens the existing picker for the right-clicked ticket. The picker now leaves the reading pane alone when that ticket is not the open one.


1. The files

File 🎨 What changed
tickets/index.php πŸ–₯️ #ctxChecklistItem is now a submenu parent with #ctxChecklistSubmenu; only rendered when the analyst can use Checklists; inbox.js and ticket_view.js cache-busters
assets/js/inbox.js πŸ–₯️ CTX_CHECKLIST_SHOWN, populateContextChecklistSubmenu(), openContextChecklistPicker(), attachChecklistFromContext(); called from openTicketContextMenu()
api/tickets/ticket_checklists.php πŸ”Œ new read action context_templates; attach_template takes skip_if_attached and answers skipped; the scoring moved into checklistTemplateScore() and checklistTicketText(), shared with suggest_template
includes/services/checklists.php βš™οΈ not changed - attachTemplateToTicket() already had $skipIfAttached (the workflow action's rule)
checklists/ticket_view.js πŸ–₯️ attachSopToTicket() no longer loads the attached ticket's checklists into the open ticket's card
lang/{en,de,es}/tickets.php πŸ”— tickets.context.attach_checklist (lost its …), search_checklists, no_checklists, checklists_failed, checklist_attached, checklist_attached_one, checklist_attached_many, checklist_already, checklist_failed
lang/{en,de,es}/checklists.php πŸ”— checklists.panel.attached_elsewhere; the help text checklists.help.using_step1 describes the submenu
CHANGELOG.local.md πŸ”— row #2785

No schema change, so nothing for πŸ—„οΈ database/freeitsm.sql or Database Verify. No tests were added (πŸ§ͺ, section 11).


2. The markup

tickets/index.php, in the ticket context menu, after Watch. The whole item is inside the module check, so an analyst who can't use Checklists (or an install with the module off) never gets it:

<?php if ($ticketsModuleOn['checklists']): /* Attach a checklist without opening the ticket - to a selection too.
    Best matches first, already-attached ones ticked, and "Search all" for the full picker. */ ?>
<div class="ticket-context-menu-item ticket-context-menu-parent" role="menuitem" tabindex="0" id="ctxChecklistItem">
    <svg …></svg>
    <span><?php echo htmlspecialchars(t('tickets.context.attach_checklist')); ?></span>
    <svg class="ctx-sub-arrow" …><polyline points="9 6 15 12 9 18"/></svg>
    <div class="ticket-context-submenu" id="ctxChecklistSubmenu" role="menu">
        <!-- Populated by populateContextChecklistSubmenu(). -->
    </div>
</div>
<?php endif; ?>

$ticketsModuleOn is worked out once at the top of the page, and the same answer goes to the browser as window.TICKETS_MODULE_ON:

// tickets/index.php
// Which other modules' parts this pane may offer: false when the analyst has no
// access OR the module is switched off for the install (GH #217). The context
// menu below and inbox.js (window.TICKETS_MODULE_ON) both read this one answer.
foreach (['problems', 'changes', 'assets', 'cmdb', 'tasks', 'checklists', 'knowledge'] as $__m) {
    try {
        $ticketsModuleOn[$__m] = analystCanAccessModule(connectToDatabase(), (int)($_SESSION['analyst_id'] ?? 0), $__m);
    } catch (Throwable $e) {
        $ticketsModuleOn[$__m] = true;   // the endpoints still refuse; the button just fails
    }
}

The first commit (9f569758f) made this a plain <button> that opened the picker, hidden on a selection like Schedule. The second (a2fb5241e) turned it into the submenu and removed the "hide on a selection" line, because the submenu can act on many tickets.

The parent uses the classes every other submenu uses (ticket-context-menu-parent, ticket-context-submenu), so hover, keyboard focus, the flip at the right edge and the phone sheet all work with no new CSS. Rows reuse .ctx-status-name (which takes flex: 1) and .ctx-status-check from Set status.


3. Which tickets the menu acts on

openTicketContextMenu() in inbox.js decides this before any submenu is filled. Right-clicking a row inside the selection acts on the whole selection. Right-clicking a row outside it replaces the selection with that row:

// assets/js/inbox.js - openTicketContextMenu()
ctxTargetTicketId = ticketId;
// …
// Outlook's rule, and the one people expect without being able to name it:
// right-clicking a row INSIDE the selection acts on the whole selection;
// right-clicking a row OUTSIDE it throws the selection away and targets just
// that row. Getting this backwards is how a bulk action hits the wrong set.
const rowEl   = event.target.closest ? event.target.closest('.email-item') : null;
const rowMail = rowEl ? Number(rowEl.dataset.emailId) : null;
if (rowMail !== null && !selectedEmailIds.has(rowMail)) {
    selectedEmailIds  = new Set([rowMail]);
    // …
    renderSelectionUi();
}
ctxActsOnSelection = selectionCount() > 1;
// …
populateContextCompanySubmenu();
populateContextChecklistSubmenu();

The selection is held as email ids (rows are keyed by email). selectedTicketIds() turns them into ticket ids:

/** Returns the ticket ids behind every selected row. */
function selectedTicketIds() {
    return Array.from(selectedEmailIds).map(ticketIdForEmail).filter(id => id);
}

Both the submenu and the attach use the same expression, so what was listed is what gets attached to:

const ids = ctxActsOnSelection ? selectedTicketIds() : [ctxTargetTicketId];

ctxTargetTicketId may not be the ticket in the reading pane. That fact is behind the picker bug in section 7.


4. Filling the submenu

// assets/js/inbox.js
/** How many checklists the right-click submenu lists before "Search all checklists…". */
const CTX_CHECKLIST_SHOWN = 12;

/**
 * Right-click -> Attach checklist β–Έ : the templates a ticket may use, like Set type.
 * One ticket: best matches first (the suggestion box's scoring); a selection: by name.
 * A checklist already on the ticket - on EVERY selected ticket - is ticked and inert.
 * Critical ones carry the padlock. "Search all checklists…" opens the full picker for
 * one ticket. Fetched each time the menu opens (the attached state changes), so it
 * shows "Loading…" for a moment; the phone sheet copies it when tapped into.
 */
async function populateContextChecklistSubmenu() {
    const sub = document.getElementById('ctxChecklistSubmenu');
    if (!sub) return;
    const ids = ctxActsOnSelection ? selectedTicketIds() : [ctxTargetTicketId];
    const asked = ids.join(',');
    sub.dataset.for = asked;
    sub.innerHTML = '<div class="ticket-context-submenu-item" style="color:#999; font-style: italic; cursor: default;">' + escapeHtml(t('common.loading')) + '</div>';
    let list = [];
    try {
        const res = await fetch(API_BASE + 'ticket_checklists.php?action=context_templates&ticket_ids=' + encodeURIComponent(asked));
        const data = await res.json();
        if (sub.dataset.for !== asked) return;   // the menu was reopened on another ticket meanwhile
        if (!data.success) throw new Error(data.error || '');
        list = data.templates || [];
    } catch (e) {
        if (sub.dataset.for === asked) sub.innerHTML = '<div class="ticket-context-submenu-item" style="color:#999; font-style: italic; cursor: default;">' + escapeHtml(t('tickets.context.checklists_failed')) + '</div>';
        return;
    }
    if (!list.length) {
        sub.innerHTML = '<div class="ticket-context-submenu-item" style="color:#999; font-style: italic; cursor: default;">' + escapeHtml(t('tickets.context.no_checklists')) + '</div>';
        return;
    }
    const lock = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#dc2626" … style="margin-left:4px;flex-shrink:0"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
    const rows = list.slice(0, CTX_CHECKLIST_SHOWN).map(tpl => {
        const crit = tpl.effective_closure_mode === 'block' ? lock : '';
        if (tpl.attached_all) {
            return `<div class="ticket-context-submenu-item" style="cursor: default; opacity: .6;" title="${escapeHtml(t('tickets.context.checklist_attached'))}">
                <span class="ctx-status-name">${escapeHtml(tpl.title)}</span>${crit}<span class="ctx-status-check">&#10003;</span></div>`;
        }
        return `<div class="ticket-context-submenu-item" onclick="attachChecklistFromContext(${Number(tpl.id)})">
            <span class="ctx-status-name">${escapeHtml(tpl.title)}</span>${crit}</div>`;
    }).join('');
    // The full picker is for one ticket: search, categories, descriptions.
    const more = !ctxActsOnSelection
        ?`<div class="ticket-context-submenu-item" style="border-top: 1px solid var(--border, #e5e7eb);" onclick="openContextChecklistPicker()">
               <span class="ctx-status-name" style="font-style: italic;">${escapeHtml(t('tickets.context.search_checklists'))}</span></div>`
        : '';
    sub.innerHTML = rows + more;
}

Piece by piece:

  • Fetched every time. Most submenus are built from lookups loaded once. This one can't be: whether a checklist is attached changes from ticket to ticket and minute to minute. So it shows Loading… first.
  • The stale-response guard. sub.dataset.for holds the ids this fetch was for. If the analyst right-clicks another ticket before the answer arrives, the second call overwrites dataset.for. The first answer then sees a mismatch and drops itself, so ticket A's ticks never land on ticket B's menu. The catch checks the same thing before writing its error row.
  • The padlock follows effective_closure_mode, not the template's own mode, so it also shows when the company forces every checklist to block (section 5).
  • Ticked and inert. An attached_all row has no onclick, cursor: default, 60% opacity and a tooltip (Already attached). It is the Set status look for "this is the current value".
  • 12 rows. The rest are reached through Search all checklists…, which is only offered for one ticket. The full picker attaches to one ticket.
  • Escaping. Titles go through escapeHtml(). The id goes through Number(), so nothing from the database ends up raw inside an onclick.

5. The endpoint: context_templates

api/tickets/ticket_checklists.php. A GET, like the other reads. The file's gates apply first: signed in, Tickets access (requireModuleAccessJson('tickets')) and the Checklists module switched on (requireModuleOnJson(…, 'checklists')).

case "context_templates":
    // The inbox's right-click "Attach checklist" submenu: every template a
    // ticket may use, best match first for ONE ticket (the suggestion box's
    // scoring), and whether it is already on every ticket asked about - so the
    // menu can tick it and not attach it twice.
    $ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string)($_GET["ticket_ids"] ?? ''))))));
    if (!$ids) throw new Exception("ticket_ids is required");
    $ids = array_slice($ids, 0, 500);
    foreach ($ids as $id) checklistTicketGate($conn, $id);
    $tenantId = ticketTenantId($conn, $ids[0]);
    $tpls = $conn->query("SELECT id, title, category, keywords, closure_mode FROM checklist_templates
                           WHERE scope IN ('ticket', 'both') AND (is_active = 1 OR is_active IS NULL)")->fetchAll(PDO::FETCH_ASSOC);
    $in = implode(',', array_fill(0, count($ids), '?'));
    $on = $conn->prepare("SELECT template_id, COUNT(DISTINCT ticket_id) FROM ticket_checklists
                           WHERE ticket_id IN ($in) AND template_id IS NOT NULL GROUP BY template_id");
    $on->execute($ids);
    $onCount = $on->fetchAll(PDO::FETCH_KEY_PAIR);
    $text = count($ids) === 1 ? checklistTicketText($conn, $ids[0]) : null;
    $out = [];
    foreach ($tpls as $tpl) {
        $out[] = [
            'id'                     => (int)$tpl['id'],
            'title'                  => (string)$tpl['title'],
            'category'               => (string)($tpl['category'] ?? ''),
            'effective_closure_mode' => ChecklistsService::effectiveClosureMode($conn, (string)($tpl['closure_mode'] ?: 'warn'), $tenantId),
            'score'                  => $text !== null ? checklistTemplateScore($text, $tpl)['score'] : 0,
            'attached_all'           => (int)($onCount[$tpl['id']] ?? 0) === count($ids),
        ];
    }
    usort($out, fn($a, $b) => [$b['score'], $a['title']] <=> [$a['score'], $b['title']]);
    echo json_encode(["success" => true, "templates" => $out]);
    exit;

The ids

The comma list is cast to integers, zeros are dropped, duplicates removed and the list capped at 500. Every id goes through checklistTicketGate(), which runs analystCanAccessTicket() and throws Ticket not found otherwise. One ticket the analyst can't reach fails the whole request, so the menu shows Could not load the checklists rather than leak anything.

Which templates

The same rows the picker (list_templates_for_ticket) and the suggestion box (suggest_template) offer: scope ticket or both, active or an old row with is_active NULL.

"Attached on every selected ticket"

One grouped query counts, per template, how many distinct tickets in the list already carry it:

SELECT template_id, COUNT(DISTINCT ticket_id)
  FROM ticket_checklists
 WHERE ticket_id IN (?, ?, …) AND template_id IS NOT NULL
 GROUP BY template_id

FETCH_KEY_PAIR turns that into [template_id => count]. A template is attached_all only when its count equals the number of tickets asked about. DISTINCT matters: a ticket carrying the same checklist twice (the picker allows that) must count once, or two copies on one ticket of a two-ticket selection would read as "on both". A template on only some of the selection stays clickable. The attach then skips the ones that have it (section 6).

template_id IS NOT NULL leaves out checklists with no template behind them.

The scoring - one rule, shared

The best-match order uses the suggestion box's scoring. Before this change it was written inline in suggest_template. It now lives in two functions at the top of the file, and both actions call them:

/**
 * How well a template matches a ticket's text: 5 for its title in the text, 2 per
 * keyword found. The ONE scoring rule - the "best match" suggestion and the
 * right-click menu's order both use it.
 * …
 */
function checklistTemplateScore(string $ticketText, array $tpl): array
{
    $score = 0; $titleMatch = false; $kwCount = 0;
    $titleLower = strtolower((string)$tpl['title']);
    if ($titleLower !== '' && str_contains($ticketText, $titleLower)) { $score += 5; $titleMatch = true; }
    if (!empty($tpl['keywords'])) {
        foreach (array_map('trim', explode(',', strtolower((string)$tpl['keywords']))) as $kw) {
            if ($kw !== '' && str_contains($ticketText, $kw)) { $score += 2; $kwCount++; }
        }
    }
    return ['score' => $score, 'title_match' => $titleMatch, 'keywords' => $kwCount];
}

/** A ticket's subject and first five notes, lower-cased - the text templates are matched against. */
function checklistTicketText(PDO $conn, int $ticketId): string
{
    $t = $conn->prepare("SELECT subject FROM tickets WHERE id = ? LIMIT 1");
    $t->execute([$ticketId]);
    $n = $conn->prepare("SELECT note_text FROM ticket_notes WHERE ticket_id = ? ORDER BY id ASC LIMIT 5");
    $n->execute([$ticketId]);
    return strtolower(((string)$t->fetchColumn()) . ' ' . strip_tags(implode(' ', $n->fetchAll(PDO::FETCH_COLUMN)));
}

suggest_template now reads:

$ticketText = checklistTicketText($conn, $ticketId);
// …
foreach ($tpls as $tpl) {
    ['score' => $score, 'title_match' => $hasTitleMatch, 'keywords' => $matchedKeywordsCount] = checklistTemplateScore($ticketText, $tpl);
    if ($score >= 2) {
        // … confidence %, top 3

Differences from the suggestion box:

  • Nothing is dropped. The suggestion box hides anything under 2 and shows the top 3. The menu lists every template, so a score of 0 just sorts by name.
  • One ticket only. For a selection, $text is null and every score is 0. There's no single ticket text to match against, so the menu is alphabetical.
  • The order is score descending, then title ascending, in one usort comparing two-element arrays.

The padlock

ChecklistsService::effectiveClosureMode() gives block when the template is Critical, or when the company's setting is block every checklist:

// includes/services/checklists.php
public static function effectiveClosureMode(PDO $conn, string $mode, ?int $tenantId = null): string
{
    if (function_exists('ticketChecklistClosureMode')
        && ticketChecklistClosureMode($conn, $tenantId) === 'block_all') {
        return 'block';
    }

    return $mode === 'block' ? 'block' : 'warn';
}

The company is the first ticket's (ticketTenantId($conn, $ids[0])), the same rule the picker uses for its one ticket. See section 10 for a selection that spans companies.

The response

{ "success": true, "templates": [
    { "id": 4, "title": "New starter", "category": "HR", "effective_closure_mode": "block", "score": 7, "attached_all": false }
] }

6. Attaching

The browser

// assets/js/inbox.js
/**
 * Attach the chosen checklist to the right-clicked ticket, or to every selected one -
 * skipping any that already has it. Refreshes the reading pane's checklist card only
 * when the ticket open there was one of them.
 *
 * @param templateId  the checklist_templates id
 */
async function attachChecklistFromContext(templateId) {
    closeTicketContextMenu();
    const ids = ctxActsOnSelection ? selectedTicketIds() : [ctxTargetTicketId];
    let done = 0, already = 0, failed = 0;
    for (const id of ids) {
        try {
            const res = await fetch(API_BASE + 'ticket_checklists.php', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ action: 'attach_template', ticket_id: id, template_id: templateId, skip_if_attached: true })
            });
            const data = await res.json();
            if (!data.success) failed++;
            else if (data.skipped) already++;
            else done++;
        } catch (e) { failed++; }
    }
    const openId = currentEmail ? Number(currentEmail.ticket_id) : null;
    if (openId && ids.map(Number).includes(openId) && typeof loadTicketChecklists === 'function') loadTicketChecklists(openId);
    let msg = ids.length === 1
        ? (done ? t('tickets.context.checklist_attached_one') : '')
        : t('tickets.context.checklist_attached_many').replace('%d', String(done));
    if (already) msg += (msg ? ' ' : '') + t('tickets.context.checklist_already').replace('%d', String(already));
    if (failed)  msg += ' ' + t('tickets.context.checklist_failed').replace('%d', String(failed));
    showToast(msg, failed ? 'error' : 'success');
}
  • One request per ticket, in turn. There's no bulk attach action. Each ticket goes through the endpoint's own gate and the one service method, and a failure on one ticket doesn't stop the rest.
  • skip_if_attached: true always, even for one ticket. Clicking a row that wasn't ticked but became attached meanwhile (another analyst, a workflow) is skipped, not doubled.
  • The reading pane is refreshed only when the open ticket was one of the targets, and it is refreshed with the open ticket's id. That keeps the rule the picker fix in section 7 is about.
  • The toast. One ticket: Checklist attached., or 1 already had it. A selection: Attached to 3 ticket(s). 1 already had it. Any failure adds %d failed. and turns the toast red.

The endpoint

// api/tickets/ticket_checklists.php
// 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();
}
// …
case "attach_template":
    $ticketId = (int)($_POST["ticket_id"] ?? 0);
    $templateId = (int)($_POST["template_id"] ?? 0);
    if ($ticketId <= 0 || $templateId <= 0) throw new Exception("ticket_id and template_id required");
    checklistTicketGate($conn, $ticketId);

    // skip_if_attached: the right-click menu on a selection - a ticket that
    // already has this checklist is left alone rather than given a second copy.
    $chkId = ChecklistsService::attachTemplateToTicket($conn, ActorContext::fromSession($conn), $ticketId, $templateId,
        !empty($_POST["skip_if_attached"]));
    echo json_encode(["success" => true, "checklist_id" => $chkId, "skipped" => $chkId === null]);
    exit;

The JSON body is merged into $_POST near the top of the file ($_POST = array_merge($_POST, $json)), which is how skip_if_attached arrives. Callers that don't send it (the picker, the ticket's own Attach button) behave exactly as before. skipped is new in the response, and checklist_id is null when it is true.

The service

skip_if_attached reuses a flag the workflow action already had. Nothing in the service changed:

// includes/services/checklists.php
/**
 * Attach a template to a ticket: copy its title, gate, steps, their
 * descriptions and whether each asks for a result. Returns the new id, or
 * null when $skipIfAttached and that template is already on the ticket
 * (the workflow action's rule).
 * …
 */
public static function attachTemplateToTicket(PDO $conn, ActorContext $ctx, int $ticketId, int $templateId, bool $skipIfAttached = false): ?int
{
    $tpl = $conn->prepare("SELECT title, closure_mode, scope FROM checklist_templates WHERE id = ?");
    // … not_found; "That checklist is for tasks only."
    if ($skipIfAttached) {
        $ex = $conn->prepare("SELECT 1 FROM ticket_checklists WHERE ticket_id = ? AND template_id = ? LIMIT 1");
        $ex->execute([$ticketId, $templateId]);
        if ($ex->fetchColumn()) return null;
    }
    // … transaction: INSERT ticket_checklists (created_by_id = the analyst), template_version,
    //   then one ticket_checklist_items row per template step
}

So the menu, the picker and the workflow action all copy a template through the one method. See Checklists & SOPs - Developer Guide Β§ 3.


7. Search all checklists…, and the picker bug

Opening the picker

// assets/js/inbox.js
/** "Search all checklists…": the full picker (checklists/ticket_view.js) for the right-clicked ticket. */
function openContextChecklistPicker() {
    closeTicketContextMenu();
    if (!ctxTargetTicketId || typeof openAttachChecklistModal !== 'function') return;
    openAttachChecklistModal(ctxTargetTicketId);
}

openAttachChecklistModal(ticketId) is the dialogue behind the ticket's own Attach button. It loads list_templates_for_ticket for that ticket, then search, category pills, descriptions and an Attach button per row that calls attachSopToTicket(ticketId, templateId).

What went wrong

attachSopToTicket() was written for the ticket open in the reading pane. After a successful attach it did this:

await loadTicketChecklists(ticketId);
openChecklistModal(ticketId);

Opened from the right-click menu, ticketId can be a different ticket from the one open. loadTicketChecklists() then filled ticketChecklistsData, the open ticket's checklist state, with the other ticket's checklists, re-rendered the open card with them and opened their modal. Worse, the warning when the open ticket is closed reads ticketChecklistsData, so closing it would have checked the wrong ticket's steps.

The fix

// checklists/ticket_view.js - attachSopToTicket()
if (data.success) {
    closeAttachModal();
    // TRAP: attached from the inbox's right-click menu, the ticket may not be
    // the one open in the reading pane. Loading its checklists would put them
    // on the OPEN ticket's card - and the warning when that ticket is closed
    // reads ticketChecklistsData, so it would check the wrong steps. Say it
    // worked and leave the reading pane alone.
    const openId = (typeof currentEmail !== 'undefined' && currentEmail) ? Number(currentEmail.ticket_id) : null;
    if (openId !== Number(ticketId)) {
        showToast(chkT('checklists.panel.attached_elsewhere', 'Checklist attached'), 'success');
        return;
    }
    await loadTicketChecklists(ticketId);
    openChecklistModal(ticketId);
}

When the ticket is the open one, nothing changes: the card reloads and the checklist opens. When it isn't, a toast (Checklist attached) and nothing else. The typeof currentEmail check keeps ticket_view.js safe on a page that has no inbox.

attachChecklistFromContext() (section 6) follows the same rule from the other side: it reloads the card only for the open ticket's own id.


8. πŸ”΄ TRAP: the phone sheet copies the submenu's HTML

On a phone the context menu is a full-screen sheet. Tapping a submenu parent slides in a second level, which is a copy of the submenu's innerHTML:

// assets/js/inbox.js
/**
 * Slides a submenu into the phone sheet as its second level, with a Back button and the parent's label.
 *
 * The items are a copy of the desktop submenu, so their inline onclick handlers work unchanged.
 */
function openTicketMenuLevel(menu, label, sub) {
    const level2 = menu.querySelector('.ctx-sheet-level2');
    level2.innerHTML =
        '<div class="ctx-sheet-level2-head">'
      // … Back button, title
      + '<div class="ctx-sheet-level2-list">' + sub.innerHTML + '</div>';
    // …
}

Two rules follow:

  1. Rows must use inline onclick calling a global function. A listener attached with addEventListener is not copied by innerHTML, so the row would do nothing on a phone. That's why the rows call attachChecklistFromContext(<id>) and openContextChecklistPicker() by name, unlike most of inbox.js.
  2. The copy is a snapshot. It is taken when the parent is tapped. If the analyst taps Attach checklist before context_templates has answered, the sheet shows the Loading… row, and nothing updates it when the answer arrives. Tapping Back and then the parent again copies the finished list. The JS doc comment says this in passing ("the phone sheet copies it when tapped into"). Add form β–Έ has the same shape.

9. End to end

  1. The analyst right-clicks a ticket row (or long-presses on a phone). openTicketContextMenu() keeps or replaces the selection and sets ctxTargetTicketId and ctxActsOnSelection.
  2. populateContextChecklistSubmenu() writes Loading…, stamps dataset.for and calls GET ticket_checklists.php?action=context_templates&ticket_ids=….
  3. The endpoint gates every ticket, reads the ticket templates, counts which are on every ticket, scores them against the one ticket's subject and first five notes, works out the padlock for the first ticket's company and sorts.
  4. If dataset.for still matches, the submenu shows up to 12 rows: ticked and inert for attached_all, a padlock for block, and Search all checklists… for one ticket.
  5. A click on a row calls attachChecklistFromContext(id). It closes the menu and posts attach_template with skip_if_attached: true for each ticket.
  6. ChecklistsService::attachTemplateToTicket() returns null for a ticket that already has it, or copies the template and its steps in a transaction.
  7. The open ticket's card is reloaded if it was a target. The toast counts attached, already-had-it and failed.
  8. Search all checklists… opens openAttachChecklistModal(ctxTargetTicketId). An attach there leaves the reading pane alone unless it was the open ticket.

10. Traps and limits

  • πŸ”΄ Phone sheet - inline onclick, and the copy is a snapshot (section 8).
  • πŸ”΄ ctxTargetTicketId is not the open ticket. Anything that reloads checklist state after an attach must use the open ticket's own id. That was the picker bug (section 7).
  • A selection with more than 12 checklists. Only the first 12, alphabetically, are listed, and Search all isn't offered for a selection. A checklist further down can only be attached one ticket at a time.
  • A selection across companies. The padlock is worked out for the first ticket's company. If one company sets block every checklist and another doesn't, the padlock can be wrong for some tickets. It is only a hint - the close gate itself is resolved per ticket.
  • More than 500 selected. context_templates looks at the first 500 ids, so the ticks describe those. The attach still loops over every selected ticket.
  • Who sees it, and who can call it. The item is rendered only when analystCanAccessModule(…, 'checklists') is true. The endpoint itself checks Tickets access and that the Checklists module is on, plus per-ticket access. It doesn't check the analyst's own Checklists access. That is the whole endpoint's existing rule, not new here.
  • Skip, not lock. $skipIfAttached is a SELECT before the transaction and there's no unique key on (ticket_id, template_id). Two analysts attaching the same checklist to the same ticket at the same moment could both get a copy. It is the same race the workflow action has always had.
  • The picker still allows a second copy. Search all checklists… goes through attachSopToTicket(), which doesn't send skip_if_attached. That is the ticket's own Attach behaviour, left as it was.
  • Title order is a byte compare. The usort compares titles with <=>, so upper-case titles sort before lower-case ones.
  • No ticket history line. Like the picker, attaching from the menu writes no ticket note. The checklist itself records created_by_id and created_datetime.

11. Tests

No automated test covers this. tests/ has no reference to context_templates, skip_if_attached or attachChecklistFromContext. The changelog row (#2785) says it was "checked in a real inbox in headless Chrome", in English, German and Spanish.

The service half predates this change, as the workflow action's rule, and is already covered. tests/ticket-checklists.php checks that attachTemplateToTicket(…, true) returns null for a template already on the ticket:

check('a workflow skips a template already on the ticket', ChecklistsService::attachTemplateToTicket($conn, $wf, $ticketId, $tplId, true) === null);

A test, if one is written, should prove:

Area Should prove
context_templates refuses with no ids; one ticket the analyst can't reach fails the whole call; task-only and inactive templates are left out; best match first for one ticket, by name for a selection; attached_all true only when every ticket has it, and a ticket with two copies counts once; effective_closure_mode is block under block every checklist
attach_template skip_if_attached returns skipped: true and adds no row; without it a second copy is added as before; refused on GET
Browser a selection attaches to the ones without it and the toast counts the rest; the open ticket's card refreshes only when it was a target; Search all on a ticket that isn't open leaves the reading pane's checklists alone

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally