Skip to content

Ticket Folder Pane Developer Guide

Ed Mozley edited this page Aug 20, 2026 · 2 revisions

The 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.


1. πŸ“ The files involved

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

⚠️ Editing inbox.js means bumping ?v=NN in tickets/index.php. It is cached hard; without the bump you will test against the old file and conclude your change did nothing.


2. One request for every number

get_ticket_counts.php returns the whole pane in a single response:

{
  "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
}

Both groupings are always sent. The toggle is then a pure re-render with no round trip β€” see Β§5.

πŸ”‘ The rule the counts obey

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 merge

Team access is applied above that: with team assignments, every query is bounded by (t.department_id IN (…accessible…) OR t.department_id IS NULL).

The maps are dense, not sparse

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.


3. currentFilter β€” one object drives the whole pane

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 CSS grid-template-rows: 0fr β†’ 1fr transition, 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 full renderFolders().


4. Expanding: toggleFolder() and its three shapes

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 produces dept_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.


5. The grouping toggle

async function setFolderGrouping(mode) { … }   // 'department' | 'analyst'

Three things happen, in this order:

  1. folderGrouping is set and the buttons' .active class flips.
  2. renderFolders() runs β€” no fetch, because get_ticket_counts.php already sent both structures.
  3. The choice is POSTed to set_user_preference.php as tickets_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.

πŸ”‘ Unassigned is contextual, and that is the interesting bit

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.


6. πŸ”΄ The list is a race unless you make it one

const token = ++loadEmailsToken;
const response = await fetch(url);
const data = await response.json();
if (token !== loadEmailsToken) return;   // a newer request has overtaken us

Two 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.


7. Drag and drop

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.


8. βœ… Fixed: the list is a list of TICKETS

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.id

Read 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 NULL

Four 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 tells selectEmail() to call loadTicketById() instead. A NULL id 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_address fall 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_read defaults to 1. Nothing arrived, so nothing is unread.

Merged-away tickets are now excluded on purpose

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 tickets directly, kept them. A behaviour that is right by accident holds only until somebody changes the thing it was accidentally relying on.

⚠️ The regression this nearly shipped with

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.


9. Testing it

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.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally