Skip to content

Record Previews Developer Guide

Ed Mozley edited this page Aug 30, 2026 · 1 revision

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.


The shape of it

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.


The two gates

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;
}
  1. 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.
  2. 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.

One answer for three different failures

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.


Adding a preview for a new record type

Worked example: previewing a supplier.

1. Register the type and its module

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.

2. Write one function, named by convention

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.

3. Use the module's own gate β€” never the company filter

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));

4. Add the strings

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.

5. Give it a URL

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;

6. Add it to the tests

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',
];

Wiring a badge into a screen

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>'

The page needs the two assets

<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.

Always guard the call

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.

Case 1 β€” inside a link pill

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" …>&#10005;</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.

Case 2 β€” a table's actions column

<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.)

Case 3 β€” a CSS grid row

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; }

Vertical alignment

.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.

After editing a record

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 them

Changing what a card shows

Adding 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.

Things worth knowing before you add one

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']),

Adding a lead

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.


Testing

Three layers, and they catch different things.

php tests/record-preview.php            # 34 β€” types, fields, refusals
php tests/record-preview-security.php   # 29 β€” the boundary
http://localhost/freeitsm-app/tests/record-preview-live.html?sid=<forged session id>

The rule the PHP tests exist to protect

// 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;
    }
}

Why the live run exists

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.

Traps in the harness itself

Every one of these produced a confident wrong answer during development:

  • window.foo is undefined for a top-level let. Reading page state that way silently gives undefined, and comparisons against it succeed in the wrong direction. Ask the server instead.
  • f.onload fires 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.

Design decisions, and why

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().


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally