Right Click Attach Checklist Developer Guide - edmozley/freeitsm GitHub Wiki
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.
| 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).
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.
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.
// 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">✓</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.forholds the ids this fetch was for. If the analyst right-clicks another ticket before the answer arrives, the second call overwritesdataset.for. The first answer then sees a mismatch and drops itself, so ticket A's ticks never land on ticket B's menu. Thecatchchecks 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_allrow has noonclick,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 throughNumber(), so nothing from the database ends up raw inside anonclick.
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 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.
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.
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_idFETCH_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 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 3Differences 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,
$textisnulland 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
usortcomparing two-element arrays.
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.
{ "success": true, "templates": [
{ "id": 4, "title": "New starter", "category": "HR", "effective_closure_mode": "block", "score": 7, "attached_all": false }
] }// 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: truealways, 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.
// 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.
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.
// 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).
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.
// 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.
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:
-
Rows must use inline
onclickcalling a global function. A listener attached withaddEventListeneris not copied byinnerHTML, so the row would do nothing on a phone. That's why the rows callattachChecklistFromContext(<id>)andopenContextChecklistPicker()by name, unlike most ofinbox.js. -
The copy is a snapshot. It is taken when the parent is tapped. If the analyst taps Attach checklist before
context_templateshas 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.
- The analyst right-clicks a ticket row (or long-presses on a phone).
openTicketContextMenu()keeps or replaces the selection and setsctxTargetTicketIdandctxActsOnSelection. -
populateContextChecklistSubmenu()writes Loading…, stampsdataset.forand callsGET ticket_checklists.php?action=context_templates&ticket_ids=…. - 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.
- If
dataset.forstill matches, the submenu shows up to 12 rows: ticked and inert forattached_all, a padlock forblock, and Search all checklists… for one ticket. - A click on a row calls
attachChecklistFromContext(id). It closes the menu and postsattach_templatewithskip_if_attached: truefor each ticket. -
ChecklistsService::attachTemplateToTicket()returnsnullfor a ticket that already has it, or copies the template and its steps in a transaction. - The open ticket's card is reloaded if it was a target. The toast counts attached, already-had-it and failed.
-
Search all checklists… opens
openAttachChecklistModal(ctxTargetTicketId). An attach there leaves the reading pane alone unless it was the open ticket.
-
🔴 Phone sheet - inline
onclick, and the copy is a snapshot (section 8). -
🔴
ctxTargetTicketIdis 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_templateslooks 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.
$skipIfAttachedis aSELECTbefore 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 sendskip_if_attached. That is the ticket's own Attach behaviour, left as it was. -
Title order is a byte compare. The
usortcompares 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_idandcreated_datetime.
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 |
- Checklists & SOPs (user guide) · Checklists & SOPs - Developer Guide
- Checklist closure gating - Developer Guide, where the padlock's meaning comes from
- Inbox JS - Developer Guide, the right-click menu and the phone sheet
- Forms on tickets - Developer Guide (2 of 2) § 5, the Add form ▸ submenu built the same way