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": 99,                    // All Tickets
  "unassigned_count": 62,               // no department
  "unassigned_analyst_count": 54,       // 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 three things 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

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 62 and 54 β€” 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. πŸ”΄ Known: the counts and the list disagree

The counts query tickets. The list is built FROM emails … INNER JOIN tickets, so a ticket with no email row β€” raised through the portal, by an analyst, or over the REST API β€” is counted in a folder and never appears in it.

On one live install that is 3 tickets: All Tickets reads 99 and lists 96; Closed reads 6 and lists 3.

Reproduce with:

SELECT t.id, t.ticket_number, ts.name
FROM tickets t LEFT JOIN ticket_statuses ts ON ts.id = t.status_id
WHERE t.deleted_datetime IS NULL
  AND NOT EXISTS (SELECT 1 FROM emails e WHERE e.ticket_id = t.id);

Fixing it means driving the list from tickets with a LEFT JOIN to the latest email instead, which also changes the sort key (le.received_datetime is NULL for those rows) and touches every view in the inbox. It is a change of its own, not a rider on a UI ticket.


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