Ticket Table Developer Guide - edmozley/freeitsm GitHub Wiki
How the ticket list becomes a paginated, server-side table without becoming a second ticket list: one query shared with the message list, the shared data table's new server mode, rows dressed as message-list rows so every inbox action still works, and a CSV export that is a setting.
User page: The ticket table. Design: The ticket workspace - design (PR #206), section 6. Review: Ticket workspace, task editor and table - review (#211-#213).
Contributed by @Dr4inder as #213, the third of the pull requests that replaced draft #206. Changelog #2635, #2637.
Colour key: π read Β· βοΈ write Β· π₯οΈ UI Β· π§© shared engine Β· π§ͺ tests
| File | π¨ | What it does |
|---|---|---|
includes/ticket_list.php |
π |
The inbox's query, moved here from api/tickets/get_emails.php (ticketListBase()), and everything the table adds: ticketListQuery() (validation), ticketListSql(), ticketListRead() (a page + filter counts), the column maps, TicketListExport, ticketListCanExport(), ticketListCsvCell()
|
api/tickets/get_emails.php |
π | The message list - now three lines around ticketListBase()
|
api/tickets/table.php |
π | One page of the table |
api/tickets/table_export.php |
π | The whole list as CSV, if the setting allows |
assets/js/tickets-table.js |
π₯οΈ |
TicketTable: builds the table, maps the folder to filters, dresses the rows, the Messages / Table switch |
assets/js/data-table.js, assets/css/data-table.css, includes/data-table-skeleton.php
|
π§© | The shared table: server mode, sortable / filterable column flags, onRowContextMenu / onRowsRendered hooks, $dtShowCsv
|
assets/js/inbox.js |
π₯οΈ |
ticketRowSelector(), and loadEmails() / renderEmailList() handing over to the table |
assets/css/tickets-table.css |
π₯οΈ | The table in the inbox's middle column |
tickets/index.php |
π₯οΈ | The container, the action-row dropdown, window.TICKETS_LIST_LAYOUT
|
includes/table_views.php |
π |
'tickets' added to TABLE_VIEW_KEYS - saved views |
tickets/settings/index.php, tickets/settings/manifest.php, api/settings/save_system_settings.php
|
βοΈ |
Who can export the ticket list (ticket_list_export), checked against the enum |
system/preferences/index.php, includes/preference_templates.php
|
π₯οΈ |
tickets_list_layout (messages / table) |
tests/ticket-workspace-table.php, tests/data-table-server.php, tests/ticket-table-live.php
|
π§ͺ | See section 8 |
What you do not touch: the other five tables (Assets, Tasks, Calendar, Changes, Domains). They pass no server, so the engine behaves for them exactly as before - tests/data-table-server.php checks that.
The single most important rule: the table must never show a ticket the message list would not, nor miss one it would. So there is one query:
ticketListBase($conn, $analystId, $request, $extraColumns, $extraJoins)
βββ get_emails.php - the message list, $_GET passed straight in
βββ ticketListSql() - the table: adds search + column filters
βββ ticketListRead() - COUNT, one page, the filter counts
βββ table_export.php - every row, in batches
ticketListBase() holds the company scope, trash, snooze, merged-away tickets, and the My tickets / Hide closed filter - with all the comments they had in api/tickets/get_emails.php.
π TRAP: change which tickets an analyst may see in
ticketListBase()only. A rule added to one endpoint and not here is a list and a table that disagree - and with the export, a CSV that contains a ticket the screen would not show.
The first draft added the table's columns with str_replace() on the inbox's SQL - which would silently match nothing the next time anyone edited that query. Now they are parameters:
[$sql, $args] = ticketListBase($conn, $analystId, [...],
['t.created_datetime', 't.status_id', 't.ticket_type_id', 't.priority_id',
'tp.display_order AS priority_order', 'qd.name AS department_name', ...],
['LEFT JOIN departments qd ON qd.id = t.department_id', ...]);They are trusted code - never request values.
ticketListQuery($_GET) turns the raw request into a checked array, and nothing after it reads $_GET. Anything it does not recognise is a 400, not ignored:
-
pagea whole number,page_size25, 50 or 100; -
sorta key ofticketListSorts(),dirasc/desc,viewactive/snoozed/trash; -
filtersJSON, at most one entry per filter column, at most 200 values each, each a positive id or'unassigned'; -
qat most 500 characters; the booleans0or1.
Every column name that reaches SQL comes out of one of three fixed maps, keyed by the table's own column keys:
| Map | Key β | Used for |
|---|---|---|
ticketListFilters() |
[row field, tickets column, lookup table, label column] |
the IN (...) filters, and the filter counts with their labels |
ticketListSorts() |
row field |
ORDER BY (priority sorts by display order, not name) |
ticketListExportColumns() |
row field | the CSV's columns |
π TRAP: the search box goes through
likeContainsPattern()(includes/functions.php), never'%' . $term . '%'. Email subjects carry typographic quotes - Gmail sends Weβre with U+2019 - and a keyboard types the straight one, so the plain pattern missed them (reported on the first day). Every apostrophe or quote becomes_, and a typed%or_is escaped.api/tickets/search_tickets.phpuses it too.
ticketListOrder() puts empty values last whichever way you sort, and adds ticket_id DESC as a tie-break - without it, rows with the same value could swap between two page loads and one ticket would appear on both pages while another appeared on neither.
ticketListRead() runs the query once per filter column leaving that column's own filter out ($omitFilter) and keeping every other one. So ticking Status = Open still shows how many are Closed - the list of values you could switch to. Six small grouped queries per page load; they share the page's scope, so a count can never reveal a ticket the analyst cannot see.
assets/js/data-table.js was built to load every row and do the rest in the browser. A ticket queue can be tens of thousands of rows, so the table gained a second mode, chosen by passing server:
createDataTable({
...,
server: {
load: async query => ({rows, total, page, pageSize, facets}),
exportCsv: async (query, abortSignal) => { ... },
},
});
// query = {page, pageSize, sort, search, filters, context}-
Any change to sort, filter or search reloads page 1 -
render()compares a signature of the query (less the page) with the one the rows on screen answer, and fetches when they differ. -
Last request wins. Each load takes a
generation; a slower, older response is dropped. -
A failed load keeps the old rows, greyed and
inert, with Retry. Never acted on - they may no longer be true. -
Filters list the server's values and counts (
facets), with the server's label for each id, and keep a ticked value at 0 so it can be unticked. -
manual: true- the table is started by its module (table.start()), not on page load, because the ticket table may never be shown. -
getContext/applyContextcarry state outside the table into a saved view and back: for tickets, the view (active / snoozed / trash), My tickets, Hide closed, and content search.
Every other table passes no server, and runs exactly as before.
The inbox's actions - click, Ctrl/Shift-click, the right-click menu, drag to a folder, the SLA dot, bulk actions, merge - all find rows with #emailList .email-item[data-email-id=...]. Rather than teach each of them about a table, onRowsRendered gives every <tr> what they look for:
node.classList.add('email-item', 'ticket-queue-row');
node.dataset.emailId = row.inbox_id; // the message list's row id
node.dataset.ticketId = row.ticket_id;
node.dataset.ticketNumber = row.ticket_number;
node.draggable = true;and assets/js/inbox.js looks in whichever list is showing:
function ticketRowSelector(suffix = '') {
return (window.TicketTable?.isActive() ? '#ticketTableContainer' : '#emailList') + ' .email-item' + suffix;
}row.inbox_id is the message list's row id: an email id, or the negative ticket id for a ticket with no email (see the π in ticketListBase()). It must be the same id the message list uses, or the reading pane, which keys off it, would not find the row.
β οΈ Exactly one list is in the page's flow at a time (TicketTable.paint()setshiddenon the other). If both were, a Shift-click range or a select-all would pick up rows from the hidden list.
β οΈ A page change clears the selection (onLoading). A bulk action must never reach a ticket on another page that is no longer on screen.
The folder is the starting filter. folderFilters() turns the folder pane's currentFilter into table filters - a department, team or analyst folder, its status sub-folder, or an Unassigned folder (empty in whichever column the folders are grouped by). Change a column filter so the rows no longer match the folder, and the folder highlight goes to All tickets rather than claim a folder the rows are not.
loadEmails() - called on a folder click, the view filter, and when the 60-second mailbox check finds something new - hands over to TicketTable.reload(), which replaces the filters on a new folder, goes to page 1 on a new view filter, and otherwise re-reads the current page.
In table mode the reading pane is hidden until something is open, and the table takes the width. All of it is in assets/js/tickets-table.js and assets/css/tickets-table.css, and all of it is scoped to body.ticket-table-mode, so the message list never changes.
| Piece | What it does |
|---|---|
showDetail(open) |
Called by inbox.js wherever a ticket opens, the selection summary shows, or the pane empties - including a failed load, so the pane's visibility never depends on an API answering |
layout() |
Sets body.ticket-table-detail (something is open), body.ticket-table-narrow (too narrow for both: the ticket alone) and --ticket-table-width. Minimums: 440px table, 600px ticket |
availableWidth() |
The page less the folder pane's computed width. TRAP: the folder pane is hidden in narrow mode, and measuring its rectangle there makes the layout flip back and forth |
The divider #ticketTableSplitter
|
role="separator" with aria-value*; arrows, Home, End and a pointer drag with pointer capture. Its share of the width is saved in localStorage (tickets_table_split) and clamped when read |
backToList() |
Waits while ticketPendingWrites > 0, hides the pane without emptying it, so a draft or task editor survives, then calls leaveTicketPopout() (inbox.js), which sets ticketPopoutPaused and leaves full screen without touching the saved preference. It does not ask TicketWorkspace.canLeave() - nothing is discarded; that is asked when a different ticket is opened (PR #289) |
Escape and full screen's own Back (GH #225) both call backToTicketList() (inbox.js), which in Table mode is backToList(): one step from full screen to the table alone. In full screen the table's Back is hidden by CSS. TRAP: never route Back through toggleTicketPopout() - that is the layout switch, and it saves full screen off. Redraws after an action pass {refresh: true} to selectEmail() / loadTicketById() so they do not undo the pause; see the review.
A one-click export of the whole filtered queue is a new way for ticket data to leave FreeITSM, so it is a setting (design decision 2), TicketListExport:
enum TicketListExport: string
{
case Admins = 'admins'; // the default, also when never saved
case TicketUsers = 'ticket_users';
case Nobody = 'nobody';
}-
ticketListCanExport()is checked byapi/tickets/table_export.phpon every request, not only by hiding the button ($dtShowCsvintickets/index.php). A direct request is refused with 403. - The setting is under the
TICKETS_GENERALcapability, like the rest of the General tab - whoever may change General settings may open the export to everyone. That is deliberate; it is the same trust as the other settings there. -
Columns: only
ticketListExportColumns(), each once - an export is never a way to reach a field the table does not offer. - Every row, in batches of 500, in one transaction, so every batch reads the same snapshot and a ticket closed mid-export cannot appear twice or not at all.
-
Written to a buffer and sent only when complete (
php://temp, spilling to the system temp directory past 2 MB - never the web root), so a failure half way returns an error, not half a file that looks whole. -
Formula injection:
ticketListCsvCell()puts an apostrophe before a cell starting with= + - @(after any spaces or control characters). Ticket subjects are text strangers wrote. -
Times: created and snoozed-until are UTC and get a
Z. Work begins / ends are the wall-clock times as typed, with no zone - aZwould invent one (aTRAP:in the code). - UTF-8 with a BOM, so Excel reads accents.
The browser fetches it with an AbortController; clicking CSV again cancels.
- Select it in
ticketListSql()'s extra columns (with any join it needs). - Add it to
setup()'s column list inassets/js/tickets-table.js: key, the row field it shows, visible by default, sortable. - Sortable? Add it to
ticketListSorts(). Filterable (an id column)? Add it toticketListFilters()with its lookup table and label, and to thefilterablelist insetup(). In the CSV? Add it toticketListExportColumns(). - Its heading:
tickets.queue.columns.<key>inlang/en/tickets.php- used by the table and the CSV header. - A date: decide whether it is UTC or wall-clock, and format it in
displayValue()and inapi/tickets/table_export.phpto match.
Worked example - Requester and Requester email (#292)
The two newest columns needed no SQL at all: step 1 was already done. ticketListBase() in includes/ticket_list.php has always selected the requester for the message list's row display (#143):
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.username), '')) AS requester_name,
u.email AS requester_address,u is the users row joined on t.user_id - the ticket's requester, not the sender of the latest email (that is from_name / from_address). A blank display name falls through to the username rather than showing an empty cell.
So the change is three list entries.
assets/js/tickets-table.js, setup() - hidden by default, sortable, not filterable:
// GH #292: who the ticket is for, so the table answers it without opening
// the ticket. Hidden until an analyst picks them in the column chooser.
['requester_name', null, false, true],
['requester_address', null, false, true],includes/ticket_list.php - ticketListSorts() and ticketListExportColumns() each gained:
'requester_name' => 'requester_name', // GH #292
'requester_address' => 'requester_address',The sort value is the select alias, so ORDER BY uses the same COALESCE the cell shows. The headings are tickets.queue.columns.requester_name (Requester) and tickets.queue.columns.requester_address (Requester email) in lang/en/tickets.php, mirrored in the complete languages; the CSV header uses the same keys. tickets/index.php loads tickets-table.js?v=7.
Why not filterable: a filter column lists every value with its count (ticketListFilters()), and a requester list is as long as the user table. Search already covers the requester's address.
Why hidden by default: an analyst's saved layout (user_preferences, key tickets_table, {"cols":[{"k":...,"v":1}],...}) only knows the columns it saved; a new column appears with its default visibility. Turning it on for everyone would have changed every existing table overnight.
| Test | Needs | What it covers |
|---|---|---|
tests/data-table-server.php |
Chrome | The engine's server mode against a fake server: paging, global sorting, stale responses, failure and Retry, the export's query - and that a table without server still loads all its rows |
tests/ticket-workspace-table.php |
database + site | The export policy (default, admins, nobody), unauthorised settings changes refused, the table's tickets equal the message list's, 525 rows across pages and CSV batches, sort and filter validation (an SQL expression as a sort is a 400), formula protection, date labelling, saved-view visibility, sorting by Requester / Requester email and both in the CSV header (#292) - 24 checks |
tests/ticket-table-live.php |
database + site + Chrome | The real page: folder kept, the reading pane, selection, right-click, drag, search, paging, export |
The database tests need FREEITSM_TEST_URL and FREEITSM_TEST_DATABASE, and should run against a disposable install - see the workspace guide's testing section.
- The ticket table Β· Saved table views - Developer Guide Β· Inbox.js - Developer Guide
-
Ticket workspace - Developer Guide Β· Review (#211-#213) Β· The folder said 99 and the list showed 96 - why the query is driven from
tickets