-
-
Notifications
You must be signed in to change notification settings - Fork 28
Record Previews Developer Guide
How the β preview works, how to add a preview for a new kind of record, how to wire a badge into a screen that does not have one, and how to change what an existing card shows.
For what it does from a user's point of view, see Record Previews.
Six files, and only two of them are the feature:
| File | Job |
|---|---|
includes/record_preview.php |
The whole read side. Gates, queries, field lists. One function per record type. |
api/system/record_preview.php |
A thin HTTP wrapper. GET ?type=&id=
|
assets/js/record-preview.js |
FreeITSMPreview.badge(), the popover, the fetch, the cache |
assets/css/record-preview.css |
The badge and the card |
tests/record-preview.php |
34 assertions β types, fields, refusals |
tests/record-preview-security.php |
29 assertions β the boundary |
Plus tests/record-preview-live.html, which drives the nine real screens in a browser.
Everything goes through recordPreview(). Wiring a preview into a new screen must never mean writing a query. If you find yourself writing SQL to render a badge, you are in the wrong file.
This is the part to understand before anything else.
function recordPreview(PDO $conn, int $analystId, string $type, int $id): ?array
{
if ($id <= 0 || !isset(RECORD_PREVIEW_MODULES[$type])) {
return null;
}
// The module gate first: somebody with no access to Assets should not learn
// anything about one, however they arrived at the link.
if (!analystCanAccessModule($conn, $analystId, RECORD_PREVIEW_MODULES[$type])) {
return null;
}
$fn = 'recordPreview' . str_replace(' ', '', ucwords(str_replace('_', ' ', $type)));
if (!function_exists($fn)) {
return null;
}
$preview = $fn($conn, $analystId, $id);
if ($preview === null) {
return null;
}
$preview['type'] = $type;
$preview['id'] = $id;
$preview['url'] = entityLink($type, $id);
return $preview;
}- The module gate lives here, once, driven by a map. An analyst with no Assets module learns nothing about an asset however they reached the link.
- The record gate lives in each type's own function, because each module answers "may I read this one?" differently.
recordPreview() also fills in type, id and url, so a type function never sets them. url comes from includes/entity_links.php β the single recordβURL map. Do not build a URL by hand: there were once three of these and they disagreed.
An unknown type, a record that does not exist, and a record you may not see all return null, and the API turns all of them into the same sentence.
This is not tidiness. If "not found" and "not allowed" looked different, anyone could confirm a record exists by watching which reply came back β which is precisely the fact the access check exists to withhold. The same rule holds in the browser: a network failure renders the same card, because a distinguishable failure mode is a distinguishable answer.
If you add a type, do not add a more helpful error. "Contract 41 belongs to another company" is a leak wearing a helpful tone.
Worked example: previewing a supplier.
const RECORD_PREVIEW_MODULES = [
'ticket' => 'tickets',
'task' => 'tasks',
'change' => 'changes',
'problem' => 'problems',
'asset' => 'assets',
'contract' => 'contracts',
'knowledge_article' => 'knowledge',
'supplier' => 'contracts', // β new; suppliers live in Contracts
];The value is the module string analystCanAccessModule() understands. If your record has no module of its own, name the module a user would need in order to reach it legitimately.
The dispatcher derives the function name from the type: supplier β recordPreviewSupplier, knowledge_article β recordPreviewKnowledgeArticle. Underscores become word breaks.
// ββ Supplier ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function recordPreviewSupplier(PDO $conn, int $analystId, int $id): ?array
{
// β οΈ No tenancy filter: suppliers carry no tenant_id, like contracts. The
// module gate is the whole of the check. Say so explicitly β a reader has
// to be able to tell a deliberate absence from a forgotten one.
$stmt = $conn->prepare(
"SELECT s.legal_name, s.trading_name, s.website,
(SELECT COUNT(*) FROM contracts c WHERE c.supplier_id = s.id) AS contracts
FROM suppliers s
WHERE s.id = ?"
);
$stmt->execute([$id]);
$r = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$r) return null; // missing and forbidden look the same
return [
'heading' => $r['trading_name'] ?: $r['legal_name'],
'fields' => rpFields([
rpField(t('common.preview.legal_name'), $r['legal_name']),
rpField(t('common.preview.website'), $r['website']),
rpField(t('common.preview.contracts'), (string)(int)$r['contracts']),
]),
];
}Return ['heading' => string, 'fields' => array], optionally 'lead' => string. Nothing else.
This is the mistake that shipped and had to be fixed in #1345, so it is worth stating flatly:
// π΄ WRONG. activeTenantFilter() answers "is this in the company I am currently
// LOOKING AT" β a view setting, not a permission. It refuses records the
// analyst is entitled to open, and the refusal is indistinguishable from a real
// one. A ticket showed a problem pill and the preview behind it denied it.
[$where, $args] = activeTenantFilter($conn, $analystId, 'p');
$stmt = $conn->prepare("SELECT ... WHERE p.id = ?" . $where);
// β
RIGHT. The same gate the module's own get.php uses.
if (!analystCanAccessProblem($conn, $analystId, $id)) {
return null;
}The available gates live in includes/tenancy.php:
analystCanAccessTicket() analystCanAccessTask()
analystCanAccessProblem() analystCanAccessChange()
analystCanAccessAsset() analystCanAccessArticle()
analystCanAccessUser() analystCanAccessCmdbObject()
A preview must never be stricter than the module it belongs to β otherwise the UI shows a link and then denies what is behind it. There is a test for exactly this; see Testing below.
If a module has its own visibility system rather than a tenancy one, reuse it whole:
// π΄ Knowledge has its own visibility rules β folders, audiences, lifecycle β
// and they are not a tenancy filter. Reuse them rather than approximating:
// an approximation here would be a way to read a restricted article.
require_once __DIR__ . '/knowledge/visibility.php';
$viewer = KnowledgeViewer::forAnalyst($conn, $analystId);
[$vis, $args] = knowledgeVisibilitySql($conn, $viewer, 'a');
$stmt = $conn->prepare("SELECT a.title, a.body FROM knowledge_articles a WHERE a.id = ?" . $vis);
$stmt->execute(array_merge([$id], $args));Field labels live under common.preview.* in lang/<code>/common.php, because seven modules render them and they are not any one module's property.
'preview' => [
// ...
'legal_name' => 'Registered name',
'website' => 'Website',
'contracts' => 'Contracts',
],Add them to en, de and da. Missing keys fall back to English per-key, so a partial locale degrades to English rather than printing a key.
entityLink() in includes/entity_links.php must know the type, or url comes back null and the card renders without its Open link:
case 'supplier':
return 'contracts/suppliers.php?id=' . $id;tests/record-preview.php drives its type list from a probe table β add a row and both the happy path and the refusals are covered:
$probe = [
// ...
'supplier' => 'SELECT id FROM suppliers ORDER BY id DESC LIMIT 1',
];FreeITSMPreview.badge() returns an HTML string, because every caller builds its rows with template literals:
FreeITSMPreview.badge('ticket', 42)
// β '<span class="rp-badge" role="button" tabindex="0" data-rp-type="ticket" data-rp-id="42" β¦>β¦</span>'<link rel="stylesheet" href="../assets/css/record-preview.css?v=1">
<script src="../assets/js/record-preview.js?v=1"></script>There is nothing to initialise. The click handler is delegated from document, so a badge drawn into a table an hour later works without anyone remembering to bind it.
Every module defines a one-line local helper. This is not ceremony:
/**
* The β preview badge (#91). Guarded, so a page that somehow loaded without
* record-preview.js loses the preview rather than the panel it belongs to.
*/
function assetPreviewBadge(type, id) {
return window.FreeITSMPreview ? window.FreeITSMPreview.badge(type, id) : '';
}An unguarded call inside a template literal throws, and takes the whole row β often the whole panel β with it. A missing preview should cost you a preview.
The badge is a <span role="button">, not a <button>, precisely so it can live inside an anchor. A <button> inside an <a> is invalid HTML that browsers recover from differently.
return `<a class="pm-ticket-badge" href="../tasks/index.php?task=${tk.id}" target="_blank"
title="${escapeHtml(bits.join(' Β· '))}">
${box} ${escapeHtml(tk.title)}${prog}
${rpBadge('task', tk.id)}
<span class="pm-ticket-unlink" β¦>✕</span>
</a>`;The click handler runs on document in the capture phase and calls preventDefault() and stopPropagation(), so clicking the badge inside an anchor does not navigate.
<td class="pm-actions">
${pmPreviewBadge('ticket', i.id)}
<a class="pm-icon-btn" href="β¦" title="Open incident">${PM_OPEN_SVG}</a>
<button class="pm-icon-btn danger" onclick="pmUnlinkIncident(${i.id})">${PM_UNLINK_SVG}</button>
</td>Put the badge first. It is present on every row, whereas Resolve-style actions are conditional β and a conditional icon in the middle of a row shifts everything to its left when it disappears. (That was #1341, in Service Status.)
Give the badge its own column rather than smuggling it into another cell, and remember the responsive block:
.asset-ticket-row {
display: grid;
/* Five columns, the last being the β preview badge (#91). It is a column of
its own rather than a passenger in the date cell so it lines up down the
panel, and so it survives the phone layout below. */
grid-template-columns: minmax(90px, auto) 1fr auto auto auto;
}
@media (max-width: 700px) {
/* Subject, status, and the preview badge β which keeps its column here
precisely because the reference and the date have gone. */
.asset-ticket-row { grid-template-columns: 1fr auto auto; }
.asset-ticket-ref, .asset-ticket-when { display: none; }
}
β οΈ A heading row is a separate element from the rows it labels. If the list has one, every item added beside a row needs an empty stand-in of the same width in the head, or the headings sit a badge to the right of the columns they name:.asset-contract-item > .rp-badge { flex-shrink: 0; margin: 0 2px 0 6px; } .asset-contract-preview-spacer { flex-shrink: 0; width: 20px; margin: 0 2px 0 6px; }
.rp-badge carries position: relative; top: 2px. That is optical, not geometric:
/* β οΈ Optical, not geometric. Beside text the badge measures as EXACTLY centred
and still reads as sitting high, because a line box is centred on
ascent-to-descent while the eye centres on the cap-height band. At 13px those
differ by 2px, measured β ascent 10, descent 3, so the cap band's middle is
5px above the baseline and the line box's is 7px. */
position: relative;
top: 2px;It is reset to 0 where the badge sits in a row of other icons rather than beside text:
.pm-actions > .rp-badge,
.linked-incidents-actions > .rp-badge,
.asset-contract-item > .rp-badge,
.asset-ticket-row > .rp-badge { top: 0; }If you wire a badge into a new icon row, add its selector there, or your badge is 2px out of line with its neighbours.
The card caches per page load, keyed type:id. Drop it when the record behind it changes:
FreeITSMPreview.forget('ticket', 42); // one
FreeITSMPreview.forget(); // all of themAdding a field is usually a three-line change. To put the team on a task card:
$stmt = $conn->prepare(
"SELECT tk.title, tk.due_date,
s.name AS status, s.colour AS status_colour,
a.full_name AS assignee, tm.name AS team,
...
);
return [
'heading' => $r['title'],
'fields' => rpFields([
rpField(t('common.preview.status'), $r['status'], $r['status_colour']),
rpField(t('common.preview.assignee'), $r['assignee'] ?: $r['team']),
rpField(t('common.preview.team'), $r['team']), // β new
rpField(t('common.preview.due'), $r['due_date']),
rpField(t('common.preview.subtasks'), $progress),
]),
];Then add common.preview.team to en/de/da. That is the whole change β no API, no JS, no CSS. The card renders whatever fields it is given.
rpField() drops empties, so list every field you might have. There is no need to branch:
function rpField(string $label, $value, ?string $colour = null): ?array
{
$value = is_string($value) ? trim($value) : $value;
// An empty field is left out rather than shown blank. A preview is a glance;
// six labels with nothing beside them is worse than three with something.
if ($value === null || $value === '' ) {
return null;
}
...
}rpFields() then filters the nulls out of the list.
Show a zero deliberately. rpField() treats '' and null as empty but keeps '0', which is what you want when the number is the point:
// Shown even when zero: "no tickets attached" is a fact about a
// problem worth knowing, unlike an empty due date.
rpField(t('common.preview.tickets'), (string)(int)$r['tickets']),Cast it to a string yourself β an integer 0 is fine, but being explicit stops the next person "tidying" it into a falsy check.
The third argument is a colour, and it is filtered. It renders as a dot via an inline style, so anything that is not a plain colour is dropped server-side:
// π The colour is the ONE value that does not reach the browser as text β it is
// written into a style attribute. Escaping stops it breaking OUT of the
// attribute, but not from adding a second declaration INSIDE it.
if ($colour !== null && !preg_match('/^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{3,20}|rgba?\([0-9,.\s%]{5,40}\))$/', trim($colour))) {
$colour = null;
}Do not let free text in without stripping it. Only lead is long-form, and it is flattened before it leaves PHP. The browser escapes everything again, but two layers is the intent.
Do not fetch a column that may not exist. Some columns only appear once Database Verification has run. Ask first rather than naming it in the main query:
// The asset tag column only exists once Database Verification has run, so it
// is fetched separately rather than naming it in the query above.
$tag = null;
require_once __DIR__ . '/asset_labels.php';
if (assetLabelsSchemaReady($conn)) {
$q = $conn->prepare("SELECT asset_tag FROM assets WHERE id = ?");
$q->execute([$id]);
$tag = $q->fetchColumn() ?: null;
}Say the true thing when the data is plural. An asset can be held by several people, so the card says so rather than naming one:
rpField(t('common.preview.held_by'), (int)$r['holders'] > 1
? t('common.preview.held_by_many', ['name' => $r['holder'], 'n' => (int)$r['holders'] - 1])
: $r['holder']),lead is the one long-form field β a block of text under the fields, clamped to four lines by CSS. Only Knowledge uses it. Strip markup before returning it; the test asserts no < survives.
Three layers, and they catch different things.
php tests/record-preview.php # 34 β types, fields, refusals
php tests/record-preview-security.php # 29 β the boundaryhttp://localhost/freeitsm-app/tests/record-preview-live.html?sid=<forged session id>
// The rule: if the module's own analystCanAccessβ¦() says yes, the preview
// must answer. Records in another company are exactly where the two used to
// disagree, so they are what this looks for.
foreach ($gates as $type => [$table, $gate]) {
foreach ($ids as $rid) {
if (!$gate($conn, $admin, (int)$rid)) continue; // genuinely out of reach
ok("{$type} #{$rid} is allowed by {$gate}(), so it previews",
recordPreview($conn, $admin, $type, (int)$rid) !== null);
break;
}
}A parse check and a unit test both pass happily on a badge that is never rendered, or one rendered inside a collapsed panel nobody opens. The live harness therefore insists the badge is visible before clicking it:
const badge = await waitFor(() => {
const list = d().querySelectorAll('.rp-badge');
// β οΈ Only a badge that is actually VISIBLE counts. A badge in a
// display:none panel is exactly the failure this file exists to catch.
for (const b of list) { if (b.offsetParent !== null) return b; }
return null;
}, 12000);Add a case to CASES when you wire a badge into a new screen. It is the only layer that would notice the badge never arriving.
Every one of these produced a confident wrong answer during development:
-
window.fooisundefinedfor a top-levellet. Reading page state that way silently givesundefined, and comparisons against it succeed in the wrong direction. Ask the server instead. -
f.onloadfires before an SPA has drawn anything. Wait for a specific element, not the load event, or you will drive a page that is not there yet and report the feature broken. -
if (dialog) { β¦ }skips in silence, and a skipped assertion is indistinguishable from a passing one in the output. Assert the dialog exists, then act on it. -
A cleanup that writes a guess is worse than no cleanup. One that sent
Number(undefined)unlinked the record it existed to restore. Refuse to write anything that is not a real id. - Positive controls everywhere. "It refused" proves nothing if the thing was simply broken.
A badge, not a hover or a right-click. Hover is invisible until you happen to do it and does not exist on touch; right-click has the same two problems. This was dschipfel's request and it is the right one.
A <span role="button">, not a <button>. Half the places a linked record appears are anchors. Enter and Space are handled by hand as the price of that.
One popover for the whole page. Two open cards would be two answers to "what is this", and the second would be read as belonging to the first badge.
Read-only. dschipfel's own request: "allowing edits from the preview would add complexity and could increase the risk of accidental changes."
Scrolling closes it. The card is positioned in viewport coordinates so it is not clipped by a scrolling panel; the trade is that it does not travel with the page, so anything that moves the badge dismisses it.
The API base comes from the script's own src:
// β οΈ Derived from this file's own URL, not from the page's depth. Modules live
// at different depths and pretty URLs make a page's apparent depth a lie, so a
// hardcoded '../api/' would be right in some places and quietly wrong in others.
return s ? s.replace(/assets\/js\/record-preview\.js.*$/, '') : '../';The API is not gated on one module. Seven kinds of record are reachable from it and each needs a different one, so naming a single module there would either refuse somebody legitimately previewing a task from a ticket, or wave through a type that module has nothing to do with. The gate belongs in recordPreview().
- Record Previews β the user-facing page
-
Multi-Tenancy Isolation β what the
analystCanAccessβ¦()gates actually check - Knowledge Folders and Security Developer Guide β the visibility system the article preview reuses
-
Internationalisation β the
common.*namespace and per-key fallback - Security Hardening 2026-08 β the house rules the refusal behaviour follows
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
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
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- 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: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- 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
- β³ π’ One board across every company
- β³ 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)
- What this is
-
π Bugs resolved
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96