Skip to content

Ticket Workspace Developer Guide

Ed Mozley edited this page Oct 27, 2026 · 1 revision

Ticket workspace - Developer Guide

How the ticket workspace is put together: three switches owned by three different people, one renderer that draws either layout, and the machinery that stops a re-render landing on top of unsaved work. Also the linked-task editor that opens in its Work tab.

User page: The ticket workspace. Design: The ticket workspace - design (PR #206). Review: Ticket workspace, task editor and table - review (#211-#213).

Contributed by @Dr4inder as #211 (the workspace) and #212 (tasks inside it), the first two of the three pull requests that replaced draft #206. Changelog #2633, #2634, #2637.


1. 📁 The files involved

Colour key: 🗄️ schema · 📖 read · ✏️ write · 🖥️ UI · 🔌 REST · 🧪 tests

File 🎨 What it does
includes/ticket_workspace.php 📖 TicketWorkspaceMode enum, ticketWorkspaceEnabled() (the whole rule), ticketWorkspaceReady() (column guard), ticketWorkspaceConfig() (what the page hands the browser)
database/freeitsm.sql, includes/db_verify_schema.php 🗄️ tickets.is_workspace TINYINT NOT NULL DEFAULT 0
includes/services/tickets.php ✏️ updateTicket() - the workspace key: validated, guarded, audited server-side, and a flag-only change returns before workflows
api/tickets/assign_ticket.php ✏️ Passes workspace through from More → Make workspace
api/tickets/get_email_detail.php 📖 Returns is_workspace with the ticket (0 before Database Verification)
api/v1/resources/tickets.php, api/v1/spec.json, api/v1/lib/openapi_schemas.php 🔌 workspace boolean on the ticket and on PATCH /tickets/{id}
api/settings/save_system_settings.php ✏️ Refuses a ticket_workspace_mode that is not a TicketWorkspaceMode case
tickets/settings/index.php, tickets/settings/manifest.php 🖥️ The General tab's Ticket workspace dropdown; the key is under TICKETS_GENERAL
tickets/index.php 🖥️ window.TICKET_WORKSPACE, the Use the workspace toggle in the view menu, the workspace-choice-available body class
system/preferences/index.php, includes/preference_templates.php 🖥️ The personal switch (ticket_workspace), and its template section
assets/js/inbox.js 🖥️ displayEmail() (the one renderer), ticketReadGuard(), ticketPendingWrites / trackTicketWrite(), buildWorkspaceAction() / setTicketWorkspace(), and a setSectionState() call in each section loader
assets/js/ticket-workspace.js 🖥️ TicketWorkspace: arranging, tabs, section states, leaving, holding a re-render, the personal switch, the task frame
assets/css/ticket-workspace.css 🖥️ All of it scoped to .ticket-workspace / .ticket-workspace-mode
assets/js/mobile.js 🖥️ Skips its toolbar overflow and section moves for a workspace; showAuditHistory(true) stays inline on a phone
checklists/ticket_view.js 📖 The checklist loader reports its state to the Work tab
The task editor (PR #212)
tasks/embedded.php 📖 The frame's page: checks, then runs tasks/index.php embedded
includes/task_embed.php 📖 taskEmbedCanOpen() - ticket, root task and subtask chain
api/tasks/get.php 📖 Re-checks the embed context on every read from the frame
tasks/index.php, tasks/includes/detail-shell.php 🖥️ The embedded variant; the detail panel's markup, now shared
assets/js/task-embed-bridge.js 🖥️ The frame's side: unsaved state, sizing, "may I close you?"
assets/js/tasks.js 🖥️ TASK_HOST === 'ticket' branches, data-task-field on fields, bridge calls
assets/css/task-embed.css 🖥️ The detail panel as page content
tests/ticket-workspace*.php, tests/task-embed*.php, tests/ticket-task-live.php, tests/support/ticket-workspace-*.php 🧪 See section 9

What you do not touch: the folder pane and the message list. Choosing a ticket opens it in the reading pane or the workspace; everything to the left behaves as before.


2. Three switches, three owners

Switch Stored in Owner Default
Install mode system_settings.ticket_workspace_mode (off / selected / all) An administrator (Tickets → Settings → General) Off - an upgrade changes nothing
Personal user_preferences.ticket_workspace ('0' / '1') Each analyst On - only an explicit '0' opts out (ticketWorkspacePersonal())
Ticket flag tickets.is_workspace Anyone who can change the ticket (More → Make workspace) 0
function ticketWorkspaceEnabled(TicketWorkspaceMode $mode, bool $personal, bool $flag): bool
{
    return $personal && ($mode === TicketWorkspaceMode::All
        || ($mode === TicketWorkspaceMode::Selected && $flag));
}

🔑 TRAP: the same rule lives twice - here, and as TicketWorkspace.enabled() in the browser, which reads window.TICKET_WORKSPACE and the ticket's is_workspace. The PHP one exists so tests/ticket-workspace-settings.php can walk all 12 combinations. Change one, change both.

None of the three ever clears another: switching the install to Off and back brings back the same flagged tickets.

Before Database Verification the column does not exist. ticketWorkspaceReady() checks once per request; the detail read selects 0 AS is_workspace, the page's ready: false hides Make workspace, and updateTicket() refuses the key with Run System → Database Verification.


3. ⭐ One renderer

The design asked for this, and it is the most important thing to keep: the workspace does not move the reading pane's controls after they are drawn. An earlier draft did, and that meant any later change to the reading pane could break the workspace with nothing noticing.

Instead displayEmail() in assets/js/inbox.js builds each piece once, then puts the pieces together for whichever layout is in use:

// Shortened from displayEmail() - the markup and the .join('')s are left out.
const workspace = !!window.TicketWorkspace?.enabled(email);
// TRAP: define controls once and choose their destinations while rendering.
// Relocating a rendered classic pane would quietly lose future controls.
const propertyFields = {
    department: `<div class="toolbar-field">...<select id="departmentSelect" onchange="assignDepartment()">...`,
    type: ..., classification: ..., status: ..., priority: ..., sensitivity: ...,
    origin: ..., firstTimeFix: ..., training: ..., team: ..., owner: ..., company: ...,
    custom: `<div class="tf-panel-fields" id="ticketCustomFields"></div>`,
};
const propertyFieldsHtml = workspace
    ? TicketWorkspace.fieldGroup('classification', [department, type, classification, status, priority]) +
      TicketWorkspace.fieldGroup('responsibility', [team, owner, company, sensitivity]) +
      TicketWorkspace.fieldGroup('other',          [origin, firstTimeFix, training, custom])
    : Object.values(propertyFields).join('');     // the classic order: the object's key order
...
const contentHtml = workspace ? TicketWorkspace.render({
    header:       headerHtml + summaryHtml + companyWarningBanner + bannersHtml + actionsHtml,
    properties:   propertiesHtml,
    conversation: attachmentHtml + aiHtml + recordingsHtml + threadHtml + notesHtml,
    work:         checklistHtml + '<div id="twTaskList"></div><div id="twTaskArea"></div>',
    links:        linksHtml,
    time:         timeHtml,
}) : propertiesHtml + companyWarningBanner + headerHtml + ... + '<div class="email-body">' + ... + '</div>';
readingPane.innerHTML = trashBanner + contentHtml;
window.TicketWorkspace?.prepare(readingPane, {ticketId: email.ticket_id, enabled: workspace});

Because each control keeps its id (statusSelect, threadContainer, slaContainer...) in both layouts, every loader and handler in assets/js/inbox.js works unchanged in either. That is why the workspace needed so little in the loaders - just a state report (section 5).

⚠️ The classic order is the object's key order. Object.values(propertyFields) is what draws the reading pane's toolbar, so inserting a key in the middle of the object moves that field in the reading pane too. Put it where you want it to appear there.

Adding a control so it appears in both

  1. Add it to the right piece in displayEmail() - a property field to propertyFields, a toolbar button to actionsHtml, a strip to linksHtml, a section to the conversation / time pieces.
  2. For a property, add its key to one of the three fieldGroup() lists, or it will be missing from the workspace's column.
  3. If it loads asynchronously, start the loader with const isCurrent = ticketReadGuard(ticketId), check isCurrent() after every await, and report window.TicketWorkspace?.setSectionState(ticketId, '<tab>', 'ready' | 'error', null, '<your source>').
  4. If it writes, nothing more: the workspace's fetch counter (section 6) sees it. If its handler re-draws something after saving, wrap it with trackTicketWrite().
  5. Check it in both layouts, and at phone width.

4. Tabs, and what each one holds

TicketWorkspace.render() builds the header, the properties <aside> and five tab panels (conversation, work, links, time, history) with the WAI-ARIA tabs pattern - arrow keys, Home and End move between tabs. ?section=work opens straight on one.

  • Switching tab only hides panels. Inputs on a hidden tab stay live and keep what was typed.
  • The selected tab survives a re-render of the same ticket (prepare() keeps active); a different ticket starts on Conversation.
  • History is loaded on first opening - showAuditHistory(true), where true draws the table into #twAuditBody instead of a modal. assets/js/mobile.js wraps showAuditHistory to open its own sheet on a phone, and lets true through.
  • A required field failing validation on a hidden tab switches to that tab (an invalid listener), and a blocked close (confirmCloseWithMandatoryFields()) calls revealMissing() to take the analyst to the first missing field.
  • Toolbar actions that would show something on another tab switch to it: Link to... (Links), a channel Reply (Conversation).

5. Section states, and reads that arrive late

Each loader that fills a tab reports its own state, under its own source, so the thread failing does not hide that the notes loaded, and one success cannot clear another's error:

window.TicketWorkspace?.setSectionState(ticketId, 'links', 'error', null, 'cmdb');

In the classic reading pane these calls do nothing (there is no #tw-panel-links), and each loader keeps its old behaviour there - the strips stay silent on a failed read.

ticketReadGuard() replaces "is the same ticket id still showing?" with "is the same render still showing?":

function ticketReadGuard(ticketId) {
    const generation = ticketRenderGeneration;   // bumped by every displayEmail()
    return () => generation === ticketRenderGeneration && currentEmail && Number(currentEmail.ticket_id) === Number(ticketId);
}

A ticket id is not enough: re-opening the same ticket (or a held render being drawn) replaces the DOM, and a slow response from the old render would write into elements that no longer exist - or, worse, into the new ones with old data. selectEmail() and loadTicketById() have the same idea for the ticket itself (ticketSelectionGeneration).


6. ⚠️ Nothing unsaved is lost

Three different things could otherwise wipe what the analyst was doing: going to another ticket, switching layout, and the same ticket being re-drawn - loadTicketById() runs again after the open ticket is dragged to a folder, escalated to a tracker, or made a workspace.

Mechanism Where What it does
TicketWorkspace.canLeave() Called before the ticket on screen is replaced: selectEmail, loadTicketById, handleEmailRowClick, onSelectionChanged, clearSelectionToNone, the summary pane, Make workspace, the layout switches false while a write is in flight; otherwise asks to Discard anything unsaved, and on yes resets the custom fields and closes the drafts
holdRender() / flushRender() First line of displayEmail() A re-render of the same ticket while anything is busy is parked, and drawn when the work finishes. A different ticket is never held - that is navigation, and canLeave() was asked
ticketPendingWrites assets/js/inbox.js Writes still in flight
propertyDrafts assets/js/ticket-workspace.js A focused custom field's value when focused - those fields save on change, so a typed-but-unchanged value is visible only here
ticketHasUnsavedInput() / discardTicketDraft() assets/js/inbox.js The reply and note modals, and the channel composer
beforeunload assets/js/ticket-workspace.js The browser's own warning on a reload or tab close

🔴 TRAP: the fetch wrapper

Most of the reading pane's writers (assignStatus, saveTicketCustomField, the link pickers...) are plain fetch() calls that report nothing. Rather than edit every one, assets/js/ticket-workspace.js wraps window.fetch for the whole tickets page and counts each same-origin non-GET request to /api/ while a workspace is on screen:

ticketPendingWrites++;
const finish = () => setTimeout(() => { ticketPendingWrites--; flushRender(); }, 0);
const response = await nativeFetch(input, init);
response.clone().text().then(finish, finish);   // released once the BODY is read
  • It is inert unless a workspace is showing - the classic pane is untouched.
  • It sits on top of assets/js/csrf.js's wrapper, so the CSRF header is still added. Load order matters: assets/js/csrf.js first.
  • api/system/set_user_preference.php is not counted, so the layout switch does not wait for itself.
  • The count is released only after the response body has been read and the caller's own .then() has run (the setTimeout) - otherwise a held render could be drawn between "saved" and the handler updating the screen.
  • Writers that re-load the thread after saving (saveNote, sendEmail, sendChannelMessage) also go through trackTicketWrite(), which counts their whole handler.

If you add a write that does not go through fetch() (an XMLHttpRequest, a form post), it is invisible to this. Use fetch().


7. More → Make workspace

function buildWorkspaceAction(email) {
    const config = window.TICKET_WORKSPACE;
    if (!email || email.deleted_datetime || config?.mode !== 'selected' || !config.ready || !config.can_write) return '';
    ...
}

Offered only in Only tickets turned into a workspace mode (design decision 3), not for a ticket in the trash, not before Database Verification, and not without write access - Tickets has no Read only level yet, so can_write is true for everyone today, but it is the right question and it starts working the day Tickets gets one. The More button is normally desktop-only in the reading pane; body.workspace-choice-available shows it in this mode so the item can be reached.

setTicketWorkspace() asks canLeave(), posts {ticket_id, workspace} to api/tickets/assign_ticket.php, then reloads the ticket rather than trusting a refresh that might still carry the old flag.

In the service

if (array_key_exists('workspace', $in)) {
    if (!is_bool($in['workspace'])) throw new ServiceError('validation', 'invalid_field', "'workspace' must be a boolean.");
    if (!ticketWorkspaceReady($conn)) throw new ServiceError(... 'Run System → Database Verification ...');
    if ($in['workspace'] !== (bool)($current['is_workspace'] ?? false)) {
        $updates[] = 'is_workspace = ?';
        $audits[]  = ['Workspace', 'off'/'on', 'on'/'off', true];   // true = forced
        $workspaceChanged = true;
    }
}
  • The audit is forced (written even when the UI caller asked for no server-side audit), so the flag's history never depends on which channel changed it.
  • A flag-only change returns before workflows, emails and calendar sync - it changes how a ticket is shown, not the work. A save that changes the flag and something else runs everything as normal.

🔑 TRAP (in the code): test what actually changed, not which keys were sent. $workspaceOnly = $workspaceChanged && count($updates) === 1 && $customValues === null - a client that resends every field with only the flag different must still skip the workflows, and a client that sends the flag unchanged alongside a real change must still run them.

When the flag changes, the update and its audit row are written in one transaction (joining the caller's if it has one).


8. The task editor in the Work tab (PR #212)

The full write-up of what the embedded page relies on is in Tasks on a ticket - developer guide, section 10. In short:

  • The task pills on the Work tab call TicketWorkspace.openTask() on a plain click (Ctrl/Shift/Alt-click still opens the task separately).
  • openTask() asks the open task (if any) whether it can close, then puts tasks/embedded.php?task=N&ticket_id=M in a same-origin <iframe>, with Open separately and Close above it as a fallback until the editor says it has its own.
  • The two sides talk by postMessage only. Every message is checked for origin, sender window, and the same {ticketId, taskId}.
  • taskCanLeave() sends freeitsm:task-embed:leave and waits (up to 45 seconds; silence counts as no) for the editor to flush its description, wait out its writes and ask to Discard.
  • While a task is open, holdRender() treats the ticket as busy, so a refresh never destroys the frame.
  • After the ticket's tasks reload, tasksLoaded() tells the editor if its task is no longer linked.

🔴 TRAP: not crypto.randomUUID(). The leave request's id was a UUID. Browsers only provide crypto.randomUUID() in a secure context - HTTPS or localhost - and FreeITSM is often reached over plain HTTP inside a network. There it threw, and no ticket could be left while a task was open. The id only has to tell this page's own questions apart, so it is a counter. The same rule applies to navigator.clipboard (see assets/js/clipboard.js). Found in review; see the review.


9. Testing

Test Needs What it covers
tests/ticket-workspace-settings.php nothing The enum, the personal default, all 12 combinations of the three switches, the before-verification fallbacks
tests/ticket-workspace.php Chrome displayEmail() in both layouts against a stub page: every control present once, trash and snooze actions kept, the badge, Make workspace's conditions
tests/ticket-workspace-actions.php Chrome Leaving with drafts (decline keeps them, accept discards), stale reads, held renders, the personal switch
tests/task-embed-bridge.php Chrome The bridge: sizing, messages, drafts, nested forms, editor baselines
tests/ticket-workspace-api.php database + site UI and REST writes, the audit row, flag-only does not fire a workflow and a mixed change does (a real workflow as the positive control), CSRF, company scope, read-only API keys
tests/task-embedded-workspace.php database + site Forged roots, unrelated tasks, another company, missing module access, read-only writes, unlinking
tests/ticket-workspace-live.php, tests/ticket-task-live.php database + site + Chrome The real page: notes, time, both flag directions, 390-1440 px, dark theme, the native task editor saving, checklists, forms, subtasks

The database tests need FREEITSM_TEST_URL (a localhost install) and FREEITSM_TEST_DATABASE (that install's database name, checked against SELECT DATABASE()), create ZZ fixtures and delete them by id in finally. Point them at a disposable install, not one with real data: they create companies, analysts and tickets.

⚠️ The live tests once assumed FreeITSM was at the web root (fetch('/auth/login.php'), a pathname of exactly /tickets/case-report.php) and failed on a subfolder install. Build every URL from FREEITSM_TEST_URL or relative to the page.

A disposable install that works: a git worktree of the branch under the web root, a config.php pointing at its own db_config.php, and a fresh database loaded from database/freeitsm.sql.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally