-
Notifications
You must be signed in to change notification settings - Fork 15
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 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 onesTeam 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 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.
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.
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.
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": 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 }