-
Notifications
You must be signed in to change notification settings - Fork 15
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.
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.
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 cursorThe 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.
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.
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.
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.
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 <= 1branch inupdateSelectionSurfaces()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 throughrenderSelectionUi()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'sinnerHTML, so a static element would be destroyed the moment the ticket it warns about finished loading.renderKeepModeWarning()re-prepends it, anddisplayEmail()ends with a call toupdateSelectionSurfaces()to put it back after a render. -
#emailList.multi-selectingsetsuser-select: none, only while a block is held. Without it Shift+click smears the browser's text selection across the list.
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:
- Every side effect is identical by construction. Not "kept in sync" β identical.
-
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 afailed[]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. - 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".
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.
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.
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 inbulkProgressHtml(), 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.
π 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.
.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.
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:
-
Real
MouseEvents withctrlKey/shiftKeydispatched at the rows in headless Chrome β the genuineonclickpath, 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. - 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".
-
One bad id mixed into a batch β four applied, the bogus one reported in
failed[]. -
Per-ticket audit checked in the database, confirming
High β Lowfor the ticket that differed. -
Module guard proven with a positive control: revoked
ticketsfrom an analyst, confirmed the refusal, confirmed the write had not landed, restored access. - Over-cap, unauthenticated, and non-whitelisted-field requests all refused.
- Drag fired five times over to confirm no ghost elements leak into the DOM.
-
D005 re-run; both endpoints classified
Module access: 'tickets'.
-
A new bulk field: add it to
$allowedinbulk_update_tickets.php(and confirm the service handles it), then abulkSetField()call from a menu item. Ask first whether it is meaningful fifty at a time β that is whysubjectis excluded. -
A fourth pane mode: add the value to the preference toggle, a branch in
updateSelectionSurfaces(), and β importantly β remember then <= 1hide path. -
Multi-select in another module's list (tasks, assets, problems): the selection engine is generic in shape but currently written against
#emailListand theemailsarray. 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.
- Selecting several tickets β the analyst-facing page
- Service Layer Architecture β why the service is the only write path
- Canned Responses β Developer Guide β the sibling feature, same inbox
-
Multi-Tenancy Concepts β what
loadTicket()is enforcing per ticket - Internationalisation β why the pt-BR twin ships in the same commit
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- 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
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« 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: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ 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)