-
Notifications
You must be signed in to change notification settings - Fork 16
Ticket Folder Pane Developer Guide
How the left-hand panel of the ticket inbox is built: where the numbers come from, how one filter object drives everything, why the grouping toggle re-renders rather than re-fetches, and the traps that have already bitten.
The user-facing page is The folder pane.
Colour key: βοΈ engine Β· π API Β· π₯οΈ UI
| π¨ | File | What it does |
|---|---|---|
| π₯οΈ | tickets/index.php |
the pane's shell β #folderList, the two data-group buttons, and the inbox.js?v=NN cache-buster |
| βοΈ | assets/js/inbox.js |
everything else: renderFolders(), toggleFolder(), the currentFilter object, drag-and-drop |
| π | api/tickets/get_ticket_counts.php |
every number in the pane, in one request |
| π | api/tickets/get_emails.php |
the ticket list itself |
| π |
api/system/get_user_preference.php / set_user_preference.php
|
remembers the grouping per analyst |
β οΈ Editinginbox.jsmeans bumping?v=NNintickets/index.php. It is cached hard; without the bump you will test against the old file and conclude your change did nothing.
get_ticket_counts.php returns the whole pane in a single response:
Both groupings are always sent. The toggle is then a pure re-render with no round trip β see Β§5.
Every breakdown is built with the same WHERE clause as the total it sits under, with only GROUP BY ts.name added:
| Total | Its breakdown |
|---|---|
total_count |
overall_statuses |
unassigned_count |
unassigned_statuses |
unassigned_analyst_count |
unassigned_analyst_statuses |
That is what makes the children sum to the parent. A breakdown written as "a similar query" instead drifts the first time somebody changes one and not the other, and the symptom β a folder whose numbers do not add up β looks like a data problem rather than a code one.
The shared $ttSql carries four exclusions onto every query at once:
list($ttSql, $ttParams) = ticketTenantFilter($conn, $analystId, 't');
$ttSql .= " AND t.deleted_datetime IS NULL"; // trashed tickets leave every folder
$ttSql .= snoozeHiddenSql($conn, 't'); // and so do sleeping ones
$ttSql .= " AND t.merged_into_id IS NULL"; // and ones absorbed by a mergeTeam access is applied above that: with team assignments, every query is bounded by (t.department_id IN (β¦accessibleβ¦) OR t.department_id IS NULL).
Each breakdown is pre-filled with every active status at zero before the rows are merged in. The UI can then render a full list without inventing keys, and a status with no tickets renders greyed (.empty) rather than vanishing β so the pane does not change height as you work.
There is no router and no per-view state. One module-level object says what is being looked at:
{ type: 'all' }
{ type: 'all_status', status: 'Open' }
{ type: 'unassigned' }
{ type: 'unassigned_status', status: 'Open' }
{ type: 'department', id: 1 }
{ type: 'dept_status', dept_id: 1, status: 'Open' }
{ type: 'analyst', id: 3 }
{ type: 'analyst_status', analyst_id: 3, status: 'Open' }
{ type: 'snoozed' }
{ type: 'trash' }Adding a view means touching four places, and missing one is the usual bug:
| Where | What it does |
|---|---|
renderFolders() |
draws the row, and marks it active by comparing against currentFilter
|
updateActiveFolderClasses() |
moves the highlight without re-rendering |
loadEmails() |
turns the filter into query parameters |
| the click handler | sets currentFilter and calls the two above |
π Why
updateActiveFolderClasses()exists at all. The expand animation is a CSSgrid-template-rows: 0fr β 1frtransition, which only fires if the element persists. Re-rendering the pane to move a highlight replaces the node and the animation never plays. So selection paths flip classes on the existing DOM, and only count changes trigger a fullrenderFolders().
toggleFolder(folderId, groupId, { kind, selectAfter = true, forceExpand = false })expandedFolders is a plain object keyed by folderId β 'all', 'unassigned', dept_1, analyst_3. It is in-memory only: the pane opens collapsed on every load, deliberately and consistently for all folder types.
The awkward part is that three kinds of folder are identified three different ways:
if (kind === 'all') list.querySelector('.folder-item[data-folder-key="all"]');
else if (kind === 'unassigned') list.querySelector('.folder-item[data-drop-type="unassigned"]');
else list.querySelector(`.folder-item[data-drop-type="${kind}"][${dataAttr}="${groupId}"]`);
β οΈ All Tickets and Unassigned have no id. Pushing them through the${kind}_${id}key the other two use producesdept_undefined, which matches nothing β so the folder silently fails to expand and there is no error to notice. This bit the drag hover-to-expand path specifically, where the key is rebuilt from a dataset attribute that is not there.
Expanding must never change what you are looking at. selectAfter sets currentFilter to the folder itself β so clicking All Tickets still shows all tickets, and clicking a department still shows that department, whether or not the statuses beneath it are showing.
async function setFolderGrouping(mode) { β¦ } // 'department' | 'analyst'Three things happen, in this order:
-
folderGroupingis set and the buttons'.activeclass flips. -
renderFolders()runs β no fetch, becauseget_ticket_counts.phpalready sent both structures. - The choice is POSTed to
set_user_preference.phpastickets_folder_grouping, and read back on load.
Preferences live in the generic user_preferences table (analyst_id, preference_key, preference_value), which any module can reuse.
renderFolders() picks both the count and the breakdown by mode:
const unassignedCount = folderGrouping === 'analyst'
? folderCounts.unassigned_analyst_count : folderCounts.unassigned_count;
const unassignedStatusMap = folderGrouping === 'analyst'
? folderCounts.unassigned_analyst_statuses : folderCounts.unassigned_statuses;One folder, drawn once, answering two different questions: no department versus nobody working on it. On a real install those were 60 and 53 β a ticket can be filed to a department and still be nobody's job. loadEmails() has to make the same choice, sending department_id=unassigned or assignee_id=unassigned, and a drop onto it clears whichever column the current mode means.
const token = ++loadEmailsToken;
const response = await fetch(url);
const data = await response.json();
if (token !== loadEmailsToken) return; // a newer request has overtaken usTwo folder clicks in quick succession start two fetches, and without this guard the slower response paints the list β so the sidebar highlights the status you picked while the list shows something else. It always corrects itself on the next click, which is exactly why it survived so long.
π How it was actually found: the same automated test passed, then failed, then passed. It had been dismissed once as a flaky harness, and a screenshot had "proved" the feature worked. An intermittent failure is evidence, not noise β the second time it appeared it turned out to be a real bug in shipped code, not in the test.
Every droppable row carries data-drop-type, and the drop handler builds a payload from it:
data-drop-type |
Payload |
|---|---|
department |
department_id |
analyst |
assigned_analyst_id |
dept_status |
department_id + status
|
analyst_status |
assigned_analyst_id + status
|
all_status |
status only
|
unassigned |
department_id: '' or assigned_analyst_id: '', by mode |
unassigned_status |
the above plus status
|
trash |
soft delete |
π A drop must mean exactly what the row means, and no more. Dropping onto a status under a department says something about the department too, so it sets both. Dropping onto a status under All Tickets says nothing whatever about department or owner β so it sets the status alone. Quietly filing a ticket somewhere because of where a status happened to be drawn would be a genuinely nasty bug to trace.
Multi-drag: draggedTicketIds carries the whole selection. Dragging a row inside the selection drags all of it; dragging a row outside it collapses the selection to that row first. The payload is built once and sent per ticket, so a dropped set behaves exactly as N single drops.
Hover-to-expand waits 600 ms over a collapsed department, analyst or unassigned row, then calls toggleFolder(..., { selectAfter: false, forceExpand: true }) β selectAfter: false matters, or hovering during a drag would change the view underneath you.
Worth knowing about, because the shape it used to have is a shape it would be easy to reintroduce.
The list and the reading pane were both built like this:
FROM LatestEmails le
INNER JOIN tickets t ON le.ticket_id = t.idRead outwards, that starts from email messages and hangs a ticket off each one β so the inbox was a list of emails wearing a ticket's clothes, and it worked only because nearly every ticket has one. The counts, meanwhile, queried tickets. A ticket with no email was therefore counted in every folder, listed in none, and openable by no route at all.
It is now driven from the thing it is actually listing:
FROM tickets t
LEFT JOIN LatestEmails le ON le.ticket_id = t.id AND le.rn = 1
WHERE t.merged_into_id IS NULLFour things travel with that inversion, and all four matter if you touch this query:
-
COALESCE(le.id, -t.id) AS id. A row needs a unique id for selection, dragging and the reading pane. Email ids are positive, so a negative can never collide β and the sign is what tellsselectEmail()to callloadTicketById()instead. ANULLid would be actively harmful: every email-less row would collapse into one selection slot. -
ORDER BY COALESCE(le.received_datetime, t.created_datetime), or email-less rows sink to the bottom whatever their age. -
from_name/from_addressfall back to the requester (LEFT JOIN users u ON u.id = t.user_id), so the row shows a person rather than a ticket number and a blank. -
is_readdefaults to 1. Nothing arrived, so nothing is unread.
WHERE t.merged_into_id IS NULL on the list, and the same clause added to $ttSql so every count gets it at once.
π The list used to exclude merged tickets by ACCIDENT. A merge moves the emails to the surviving ticket, and the list was built from emails β so they fell out of the query without anybody deciding they should. The counts, reading
ticketsdirectly, kept them. A behaviour that is right by accident holds only until somebody changes the thing it was accidentally relying on.
get_email_detail.php supports two lookups, and they need different joins:
if ($emailId) { // clicking a row: ANY message on the thread
$from = "FROM emails e INNER JOIN tickets t ON t.id = e.ticket_id";
} else { // by ticket: the email is optional
$from = "FROM tickets t LEFT JOIN emails e ON e.ticket_id = t.id AND e.is_initial = 1";
}The first attempt used the second form for both. That is right for a ticket-id lookup and wrong for an email-id one, which carries the id of the latest message β usually a reply. It would have fixed a bug affecting three tickets and broken opening every ticket that has ever been replied to.
π When a fix changes a shared query, test the case it was already handling. The new behaviour is the part you are thinking about, which is precisely why it is not the part that breaks. The test now opens a deliberately non-initial email.
Full write-up: The folder said 99 and the list showed 96.
There is no PHP suite for this β it is a browser feature, so it is tested by driving the real page in a same-origin iframe: click the folder, click a status, assert what the list actually shows.
Two rules learned the hard way:
- Assert against the API, not against the folder count. They can legitimately differ (Β§8), and a test that encodes a known bug fails on correct code.
- Include a negative control. "Status X shows 6 rows" proves nothing on its own; "and status Y shows 70, and they differ" proves the filter is doing something.
Seed a session by writing c:/wamp64/tmp/sess_<id>, then set the cookie from a page on the same host before navigating β Chrome will not carry a cookie you only gave to curl.
- The folder pane β the user-facing page
-
Multi-Tenancy β what
ticketTenantFilter()does -
Selecting several tickets β where
draggedTicketIdscomes from -
Snoozing tickets β Developer Guide β
snoozeHiddenSql()
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
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ 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)
{ "total_count": 97, // All Tickets "unassigned_count": 60, // no department "unassigned_analyst_count": 53, // nobody working on it "trash_count": 2, "snoozed_count": 0, "statuses": [ /* active statuses, in display order - drives the UI */ ], "departments": [ { "id": 1, "name": "IT Support", "count": 32, "statuses": {β¦} } ], "analysts": [ { "id": 3, "name": "Sam Cover", "count": 0, "statuses": {β¦} } ], "overall_statuses": { "Open": 70, β¦ }, // under All Tickets "unassigned_statuses": { "Open": 57, β¦ }, // under Unassigned, dept mode "unassigned_analyst_statuses": { "Open": 53, β¦ } // under Unassigned, analyst mode }