Repository navigation
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.
| 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
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- π PDF exports moved to PDFKit (#254)
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- β³ π Switching modules off
- β³ π οΈ Module availability β Developer Guide
- β³ π Module availability β design (#217)
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
-
π¦ Microsoft Entra ID
- β³ π₯ Importing people and groups
- β³ π» Intune: assets to their primary user
- β³ π’ Organisation chart
- β³ π οΈ Entra directory sync β Developer Guide
- β³ π οΈ Entra import - review (#235)
- β³ π οΈ Entra follow-ups - review (#237-#240)
- β³ π οΈ Intune primary user - review (#236)
- β³ π οΈ Organisation chart - reviews (#242, #265)
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π οΈ Empty Procedures fold - review (#247)
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Live updates
- β³ π οΈ Watching a ticket - Developer Guide (1 of 2)
- β³ π οΈ CSAT delay and reminder - Developer Guide
- β³ π οΈ Ticket checklist step types - Developer Guide (1 of 2)
- β³ π οΈ Right-click Attach checklist - Developer Guide
- β³ π οΈ Checklists in the REST API - Developer Guide
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Floating windows & the file viewer β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Simpler ticket and task forms - design (#218)
- β³ ποΈ The ticket workspace
- β³ π Forms on tickets
- β³ π The ticket table
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π§Ύ A type for every new ticket
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ π Ticket dashboard β Developer Guide
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
-
β³ Basic IMAP mailboxes
- β³ π Basic IMAP without the PHP extension (#257)
- β³ π οΈ Basic IMAP client β Developer Guide
- β³ π§ͺ Basic IMAP client β Testing and verifying
- β³ π οΈ Basic IMAP - Sent copy - Developer Guide (#270)
- β³ π οΈ Verifying a mail folder β Developer Guide
- β³ π οΈ Mailbox providers β Developer Guide
- β³ π οΈ How Gmail mail collection works β Developer Guide
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
-
Tasks
- β³ π οΈ Task board and subtasks β Developer Guide
- β³ π§βπ€βπ§ People on a task ("Involved")
- β³ π Repeating tasks
- β³ π’ Moving a task between companies
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Linked configuration item
- β³ π Connected assets
- β³ π Asset review
-
β³ π·οΈ QR asset labels
- β³ π οΈ QR asset labels β Developer Guide
- β³ π Asset label designer - design (#229)
- β³ π· Asset scanning β Developer Guide
- β³ π οΈ Asset tag numbering β Developer Guide
- β³ π οΈ Asset reconciliation β Developer Guide
- β³ π οΈ Reconciliation, tags + labels - review (#164)
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ πͺ Hyper-V and Failover Clusters
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Starters & leavers
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
-
CMDB
- β³ π οΈ CMDB β Developer Guide
- β³ π οΈ Impact analysis β Developer Guide
- β³ π οΈ Data quality β Developer Guide
- β³ π Linked assets
- β³ π Linking an asset to its CI - design (#201)
- β³ π Discovery and redundancy-aware impact - design (#199)
- β³ π οΈ Relationship wording - review (#297)
- Network Mapper
-
Workflows
- β³ π οΈ Workflow engine β Developer Guide
- β³ π οΈ Workflows for tasks β Developer Guide
- β³ π οΈ Workflow notes on a ticket β Developer Guide
- β³ π οΈ Workflow category routing β Developer Guide
- β³ β° Time-Based Triggers
- β³ πͺ Webhooks
- β³ π¬ Webhook Message Formats
- β³ π HTTPS Certificates & CA Bundles
- β³ π οΈ SSL Verification β the Global Setting
- β³ π Workflow & Webhook Pitfalls
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
-
System
- β³ π οΈ Preferences β Developer Guide
- β³ π§Ή Maintenance
- β³ π Finding people in the Analysts list
- β³ πͺ When an analyst leaves
- β³ π οΈ System β Branding β Developer Guide
- β³ π©Ί Page health (D019) β Developer Guide
- β³ β Feature Bingo
- β³ π·οΈ Cost centres
- β³ π· Photo Album
- β³ Demo Data
- β³ π οΈ Demo Data β Developer Guide
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ One company supporting another
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π #301 CSAT delay was never applied
- β³ π #281 Comments imported from Jira never arrived
- β³ π #288 A change failed to open with Enforce approvals on
- β³ π Long non-Latin articles never got an embedding
- β³ π« #280 Email lookups scanned the whole table
- β³ β #266 Dragging a task told nobody
- β³ π #255 Article previews showed HTML codes
- β³ π #252 A link to a Knowledge article did not open
- β³ π #254 PDFs could not print Polish
- β³ π #250 Clicking a folder left the open article on screen
- β³ π« #260 The dashboard cog did nothing
- β³ π« #248 A department rule never matched on four routes
- β³ π A directory account could be matched to someone else
- β³ π« #244 Reply went to the assigned analyst
- β³ π A lookup answer could name any record
- β³ π« #227 A mailbox could not be deleted
- β³ π« #226 A picture on a note could not be closed
- β³ π» #222 The Assets page never loaded in 4.3.0
- β³ βοΈ #214 A "contains" condition did not match capitals
- β³ βοΈ #216 A workflow email from a chosen mailbox always failed
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96