Skip to content

Bulk Actions Developer Guide

Ed Mozley edited this page Jul 21, 2026 · 1 revision

Bulk Actions β€” Developer Guide

How multi-select and bulk actions work, on both sides of the wire, and the two design decisions that matter more than the rest. Shipped as #910.

The user-facing page is Selecting several tickets.


1. πŸ“ The files involved

Colour key: πŸ–±οΈ selection Β· 🎬 surfaces Β· πŸ”Œ API Β· βš™οΈ shared service Β· πŸ–₯️ page Β· 🎨 CSS Β· βš™οΈ prefs Β· 🌍 i18n Β· πŸ“„ docs

🎨 File What it does
πŸ–±οΈ assets/js/inbox.js All of the client side. Selection state, the click/keyboard rules, the three surfaces, the chunked apply loop, the drag image
🎬 tickets/index.php One new element: <div id="selectionBar">, the container the "bar" mode fills
πŸ”Œ api/tickets/bulk_update_tickets.php Apply one field change to many tickets β€” the field whitelist and the 100-ticket cap live here
πŸ”Œ api/tickets/bulk_delete_tickets.php Soft-delete many tickets (to Trash)
βš™οΈ includes/services/tickets.php Unchanged, and that is the point β€” TicketsService::updateTicket() / deleteTicket() do the actual work, once per ticket
βš™οΈ api/system/get_user_preference.php Reads tickets_multiselect_pane on inbox load
βš™οΈ api/system/set_user_preference.php Writes it when the analyst changes the setting
πŸ–₯️ system/preferences/index.php The Selecting several tickets section β€” a default in $prefDefaults plus one wireToggle() call
🎨 assets/css/inbox.css .email-item.kb-focus, #emailList.multi-selecting, .sel-summary-*, .selection-bar, .keep-mode-warning, .sel-dropdown, .drag-stack-ghost
🌍 lang/en/tickets.php + lang/pt-BR/tickets.php the bulk block, in the same commit
🌍 lang/en/system.php + lang/pt-BR/system.php the multiselect_* preference strings
πŸ“„ CHANGELOG.local.md, README.md, this wiki logged as #910

Note what is not in that list: no new table, no migration, no db_verify change. The preference rides on the existing generic user_preferences store, and the selection itself is pure client state.


2. πŸ–±οΈ Client: the selection model

Three variables carry everything (assets/js/inbox.js):

let selectedEmailIds  = new Set();   // every row in the selection
let selectionAnchorId = null;        // where a Shift range measures FROM
let selectionFocusId  = null;        // the keyboard cursor

The pre-existing selectedEmailId (singular) is untouched and still means "what the reading pane is showing", so every existing caller keeps working without knowing multi-select exists.

Why three, not one

The anchor is separate from the selection because Shift must measure from the last plain click, not the last row touched. That is what lets a second Shift+click shrink a block β€” click 3, Shift+click 9, Shift+click 6 gives you 3–6. Track only "the last row" and the block can grow but never contract, which feels broken in a way users can't articulate.

The cursor is separate from both because Ctrl+arrow moves over rows that are not selected. Without a third variable there is nowhere to record "the keyboard is here but this row isn't chosen", and Space (the keyboard's Ctrl+click) becomes impossible.

That is also why .kb-focus is styled as an outline and not a fill: it has to read as here, never as chosen.

One entry point

Every modifier combination lands in handleEmailRowClick(event, emailId), so the rules are readable in one place rather than smeared across handlers:

Branch Behaviour
shift Range from anchor to clicked. Plain Shift replaces the selection; Ctrl+Shift adds to it. The anchor deliberately does not move
ctrl Toggle this row. Anchor and cursor both move here
neither Collapse to this one and selectEmail() it β€” the original behaviour, unchanged

Supporting helpers: visibleEmailIds() reads the order from the DOM, not from the emails array, so a range always follows what the analyst can actually see; ticketIdForEmail() maps a row to its ticket (rows are keyed by email id, actions work on tickets); selectedTicketIds() is what every action consumes.

Keyboard

moveSelectionCursor(delta, {extend, keepSelection}) is the whole of it β€” extend is Shift, keepSelection is Ctrl, neither is a plain arrow. toggleFocusedRow() is Space. selectAllVisible() is Ctrl+A.

The keydown listener bails out early on INPUT / TEXTAREA / SELECT / contentEditable, on any open .modal.active, and when TinyMCE has focus. The inbox shares its page with a rich-text reply editor and a dozen dialogs; a shortcut that eats a keystroke mid-reply would be a far worse bug than the feature is a win.


3. 🎬 Client: three surfaces, one code path

multiSelectPaneMode is 'summary' | 'keep' | 'bar', loaded once by loadMultiSelectPanePreference().

        selection changes
               β”‚
        renderSelectionUi()          ← paints rows: .selected + .kb-focus
               β”‚
      updateSelectionSurfaces()      ← THE choke point
               β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 summary      bar        keep

selectionActionsHtml() builds the buttons once and all three surfaces embed it, so an action can never exist in one mode and not another.

πŸ”‘ The n <= 1 branch in updateSelectionSurfaces() is not tidying, it is the fix for a real bug. A plain click already collapsed the selection correctly, but the bar was left on screen still reading "5 tickets selected" while exactly one was held β€” a surface lying about what an action would hit. Every path that changes the selection funnels through renderSelectionUi() into here, so hiding belongs at this one point and not at each caller. If you add a fourth surface, hide it here too.

Two placement details that will bite:

  • The "keep" warning strip is created in JS, not in the page markup. selectEmail() replaces the whole reading pane's innerHTML, so a static element would be destroyed the moment the ticket it warns about finished loading. renderKeepModeWarning() re-prepends it, and displayEmail() ends with a call to updateSelectionSurfaces() to put it back after a render.
  • #emailList.multi-selecting sets user-select: none, only while a block is held. Without it Shift+click smears the browser's text selection across the list.

4. πŸ”‘ Server: loop the service, never one UPDATE

This is the decision the whole feature rests on.

The obvious implementation is one statement:

-- DO NOT DO THIS
UPDATE tickets SET status_id = ? WHERE id IN (…)

It would be faster and it would be wrong. Changing a ticket is not a column write. It moves closed_datetime, syncs the owner, may send a template email, may fire CSAT, and dispatches Workflows triggers β€” all of which live in TicketsService::updateTicket().

A second write path would drift from the first, and the symptom is the worst kind there is: a workflow that fires when you change one ticket and silently doesn't when you change fifty. Nobody notices for months.

So api/tickets/bulk_update_tickets.php calls the same service method the single-ticket endpoint calls, once per ticket:

foreach ($ids as $rawId) {
    try {
        TicketsService::updateTicket($conn, $ctx, (int)$rawId, $in, true);
        $updated++;
    } catch (ServiceError $e) {
        $failed[] = ['id' => $ticketId, 'error' => $e->getMessage()];
    }
}

Three properties fall out of that for free:

  1. Every side effect is identical by construction. Not "kept in sync" β€” identical.
  2. Per-ticket access control needs no new code. loadTicket() inside the service already throws for a ticket that is unknown or out of the caller's scope, so putting another company's ticket ids in the array gets you a failed[] entry, not their data. On a multi-company install that is the isolation boundary, and it is the same one the single-ticket path uses.
  3. One failure doesn't abandon the run. A ticket somebody else just deleted is reported and the other forty-nine still apply. An all-or-nothing bulk action that discards good work because of one bad row is not what anyone means by "apply to selection".

writeAudit is true here β€” and false next door

assign_ticket.php passes false and lets the browser write the audit row, because the browser already knows the old value of the one ticket on screen.

A bulk caller does not know the previous value of fifty tickets, and making the browser fetch them all first would be slow and a lie waiting to happen. So the bulk endpoints pass true and the service reads each ticket's real previous value.

Verified live: four tickets set to Low produced Normal β†’ Low three times and High β†’ Low once β€” correct per ticket, rather than one approximate value copied across.

The whitelist and the cap

const BULK_MAX_TICKETS = 100;
$allowed = ['department_id', 'ticket_type_id', 'status', 'origin_id',
            'first_time_fix', 'it_training_provided', 'priority_id', 'assigned_analyst_id'];

The whitelist mirrors assign_ticket.php exactly. A bulk endpoint that accepted any key would be a wider hole than the single-ticket endpoint it is meant to match β€” note subject is absent from both. The cap is a backstop against an unbounded write, not the user-facing limit; the client chunks at BULK_CHUNK = 25 well below it.

Both endpoints are guarded by requireModuleAccessJson('tickets') and nothing more β€” they are ordinary ticket work, not settings, so no capability applies. Run api/system/debug-tools/D005_endpoint_permissions.php after touching them.


5. πŸ” Client: the apply loop

applyToSelection(endpoint, payloadFor, label) chunks the selection, POSTs each chunk, accumulates failed[], and reports.

bulkSetField({ status: 'Resolved' }, t('tickets.bulk.label_status'));

Chunking at 25 keeps each request short enough to stay inside PHP's execution limit β€” remember each ticket runs the full service, possibly including an outbound email β€” and gives the progress counter something honest to count.

⚠️ showToast(message, type) is fire-and-forget. It returns nothing, has no sticky option and auto-dismisses after four seconds, so there is no handle to update. Live progress therefore lives in bulkProgressHtml(), rendered into whichever surface is active β€” all of which we own. Do not teach the shared toast component to be sticky for the benefit of one screen.

Failures are never rounded up into a success: t('tickets.bulk.partial') reports applied and failed counts separately, and the ids go to console.warn.


6. πŸ–±οΈ Right-click and drag: the same rule, twice

πŸ”‘ Inside the selection acts on all of it; outside collapses to that row first. Getting this backwards is how a bulk action hits the wrong tickets.

openTicketContextMenu() checks whether the right-clicked row is in selectedEmailIds, collapses if not, then sets ctxActsOnSelection and retitles the menu with the count. Each of the five set*FromContext() handlers gains one line at the top:

if (ctxActsOnSelection) return bulkSetField({ status: statusName }, t('tickets.bulk.label_status'));

The single-ticket path below it is untouched. That was deliberate: this is the busiest module in the product, and a bulk feature is no reason to rewrite the one-ticket flow every analyst uses all day. The cost is a little duplication; the benefit is that a bug in bulk cannot become a bug in the path that matters most.

attachEmailDragHandlers() applies the identical rule on dragstart, then fills draggedTicketIds. In handleTicketDrop() the existing code already builds a payload describing what the drop means (department, status, analyst, or a combination); the multi case reuses that payload verbatim β€” including '' for "clear" β€” so a dropped set is treated exactly as N dropped tickets would have been.

The drag image

.drag-stack-ghost is three offset, slightly rotated sheets plus a count badge, built from real child elements rather than ::before/::after: setDragImage() rasterises the node, and pseudo-element support in that snapshot is not worth relying on across browsers. It must be in the document to rasterise at all, so it is parked off-screen (top: -1000px) and removed on the next tick, by which time the browser has its bitmap. Its dimensions are fixed px rather than tokenised β€” it is a bitmap that never reflows, and changing its metrics would only move the grab point away from the pointer.

Row dimming during a drag is driven from selectedEmailIds, not from whichever rows are painted .selected, so what dims can never disagree with what is being dragged.


7. βœ… How this was verified

php -l proves nothing (a fatal is served as HTTP 200) and neither does grepping for a function name (it can sit in a script block that never parsed). What was actually done:

  1. Real MouseEvents with ctrlKey / shiftKey dispatched at the rows in headless Chrome β€” the genuine onclick path, not a direct call to the handler. Asserted plain click = 1, Shift = 4, Escape = 0, Ctrl+A = 79/79, and screenshotted all three pane modes.
  2. The stale-surface bug caught this way, not by reading the code: after a plain click the count was 1 and one row was highlighted, but the bar still said "5 tickets selected".
  3. One bad id mixed into a batch β€” four applied, the bogus one reported in failed[].
  4. Per-ticket audit checked in the database, confirming High β†’ Low for the ticket that differed.
  5. Module guard proven with a positive control: revoked tickets from an analyst, confirmed the refusal, confirmed the write had not landed, restored access.
  6. Over-cap, unauthenticated, and non-whitelisted-field requests all refused.
  7. Drag fired five times over to confirm no ghost elements leak into the DOM.
  8. D005 re-run; both endpoints classified Module access: 'tickets'.

8. Extending it

  • A new bulk field: add it to $allowed in bulk_update_tickets.php (and confirm the service handles it), then a bulkSetField() call from a menu item. Ask first whether it is meaningful fifty at a time β€” that is why subject is excluded.
  • A fourth pane mode: add the value to the preference toggle, a branch in updateSelectionSurfaces(), and β€” importantly β€” remember the n <= 1 hide path.
  • Multi-select in another module's list (tasks, assets, problems): the selection engine is generic in shape but currently written against #emailList and the emails array. Lifting it into a shared file would mean parameterising the container, the row selector and the id mapping. Worth doing on the second module that needs it, not in anticipation.
  • Touch support would need a long-press to enter a selection mode, since there are no modifier keys β€” a genuine design job, not a port. See Mobile-Friendly.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally