Skip to content

Command Palette Developer Guide

Ed Mozley edited this page Jul 28, 2026 · 1 revision

Command Palette β€” Developer Guide

How the ⌘K launcher is built: one global injection point, one client file, and one search endpoint that fans out across seven modules while never returning a row the analyst couldn't already see. Shipped as #932.

The user-facing page is The command palette.


1. πŸ“ The files involved

Colour key: ⌨️ client Β· 🎨 CSS Β· πŸ”Œ API Β· 🧩 injection Β· πŸ–₯️ module deep-link Β· πŸ“„ docs

🎨 File What it does
⌨️ assets/js/command-palette.js All of the client side. Builds the overlay, the ⌘K/Ctrl-K trigger, keyboard navigation, client-side filtering of modules + actions, and the debounced entity search
🎨 assets/css/command-palette.css The overlay, styled entirely from theme.css tokens so it follows light/dark
πŸ”Œ api/system/global_search.php The aggregator: one request fans out to tickets, changes, problems, knowledge, contracts, assets and CMDB, each gated and scoped
🧩 includes/waffle-menu.php Injection point β€” renderWaffleMenuJS() emits the assets, window.CP_BASE and the visibility-filtered window.CP_MODULES, so the palette loads on every analyst page
πŸ–₯️ asset-management/index.php Gained a ?asset_id= deep link so asset results can open the asset itself (see Β§5)
πŸ“„ CHANGELOG.local.md, README.md, this wiki logged as #932

Note what is not in that list: no new table, no migration, no db_verify change. The palette is read-only β€” it navigates and searches existing data and stores nothing.


2. 🧩 One injection point, on every page

There is no per-page wiring. renderWaffleMenuJS() in includes/waffle-menu.php already runs on every analyst page (it's how toast.js and confirm.js get everywhere), so the palette rides along with it:

<link rel="stylesheet" href="<?php echo BASE_URL; ?>assets/css/command-palette.css?v=1">
<script>
    window.CP_BASE    = <?php echo json_encode(BASE_URL); ?>;
    window.CP_MODULES = <?php echo json_encode($cpModules, JSON_UNESCAPED_SLASHES); ?>;
</script>
<script src="<?php echo BASE_URL; ?>assets/js/command-palette.js?v=2"></script>

Two things worth knowing:

πŸ”‘ $modules is a top-level global in this file, but renderWaffleMenuJS() is a function β€” so it needs global $modules;. Miss that and $cpModules builds from an undefined variable and the palette ships with an empty module list: it opens, but "Go to" is blank. This bit on the first build.

CP_MODULES is filtered server-side with the exact rule the waffle panel uses β€” so the palette can never offer a destination the launcher wouldn't:

foreach ($modules as $cpKey => $cpMod) {
    if ($cpKey === 'system') {
        if (!sessionIsAdmin()) continue;          // System is admin-only
    } elseif ($cpAllowed !== null && !in_array($cpKey, $cpAllowed)) {
        continue;                                  // everything else honours allowed_modules
    }
    $cpModules[] = ['key' => $cpKey, 'name' => $cpMod['name'],
                    'path' => $cpMod['path'], 'icon' => $cpMod['icon']];
}

The module display names are already localised ($modules resolves them through t()), so the palette is translated for free.

Cache-buster: the manual ?v= convention applies. command-palette.js is at ?v=2 β€” bump it on every edit or browsers run the stale file. The CSS is at ?v=1.


3. ⌨️ The client

command-palette.js is a single IIFE, guarded against double-inclusion by window.__cmdpInit. It reads the two globals the page injected (CP_BASE, CP_MODULES) and owns everything else.

Three kinds of row, one render path. render(serverResults) builds the list from, in order:

  1. Go to β€” CP_MODULES, filtered client-side by a substring match with a small prefix boost (score()), so tas ranks Tasks first.
  2. Actions β€” a static COMMANDS array (Toggle dark mode, Sign out), same matcher.
  3. Search results β€” the server response, grouped by type in a fixed order.

Each rendered row pushes an activate thunk into a parallel pending[] array; after innerHTML, the rows are wired to their thunk by index. A nav row navigates (location.href = BASE + path), an action row runs its run(), a result row navigates to BASE + result.url.

Keyboard model (onKeydown): ↑/↓ move activeIx with wrap, Enter fires the active thunk, Esc closes. Escape also calls stopPropagation() so the page's own global Escape handler (which closes the waffle panel) doesn't also fire.

The search is debounced and race-guarded:

function doSearch(q) {
    var seq = ++searchSeq;                     // monotonic ticket
    fetch(BASE + 'api/system/global_search.php?q=' + encodeURIComponent(q), …)
        .then(function (data) {
            if (seq !== searchSeq) return;      // a newer query superseded this one
            if (input.value.trim() !== q) return;
            render(data && data.success ? data.results : []);
        });
}

searchSeq means a slow response for ser can never overwrite the results for serv typed a moment later β€” the classic autocomplete bug. Client-side rows (modules, actions) render instantly on every keystroke; only the entity search waits 180 ms and hits the network.

πŸ”‘ The search min-length lives in two places on purpose. The client skips the fetch below two characters, and the endpoint returns [] below two characters. The client rule saves a request; the server rule is the one that actually holds, because the endpoint is a URL anyone signed-in can call directly.

To add a new result type to the client you touch four small maps: ICONS, TYPE_LABEL, pluralType(), and the group-order array in render(). That's it β€” the rendering itself is type-agnostic.


4. πŸ”Œ The aggregator: two gates on every source

api/system/global_search.php is the whole backend. It follows the standard read-endpoint preamble (session_start(['read_and_close' => true]), auth check, Content-Type: application/json) and then fans out. Every source passes two gates before it contributes a single row:

Gate 1 β€” module access

$allowed = $_SESSION['allowed_modules'] ?? null;   // null = unrestricted
$can = function (string $key) use ($allowed): bool {
    return $allowed === null || in_array($key, $allowed, true);
};

This mirrors the CP_MODULES filter, so what you can search matches what you can navigate to. Reads aren't capability-gated in FreeITSM β€” module membership is the right gate here, not a Layer-2 capability.

Gate 2 β€” company scope

Each source runs through its own module's tenancy filter, exactly as that module's own list endpoint does β€” never a generic one, because the meaning of NULL differs by table:

Source $can() key Scope helper Table & match
Tickets tickets ticketTenantFilter($conn, $id, 't') tickets β€” ticket_number / subject, deleted_datetime IS NULL
Changes changes activeTenantFilter($conn, $id, 'c') changes β€” title, or id from a CHG-#### (see below)
Problems problems ticketTenantFilter($conn, $id, 'p') problems β€” problem_number / title
Knowledge knowledge knowledgeTenantFilter($conn, $id, 'a') knowledge_articles β€” title, non-archived
Contracts contracts none contracts β€” contract_number / title
Assets assets activeTenantFilter($conn, $id, 'a') assets β€” hostname / service_tag
CMDB cmdb activeTenantFilter($conn, $id, 'o') cmdb_objects β€” name

πŸ”‘ Knowledge uses knowledgeTenantFilter, not activeTenantFilter. For Knowledge a NULL tenant means "shared with every company" β€” the opposite of tickets/assets, where NULL means "unassigned, owned by Default". Feed Knowledge through the wrong filter and every shared article silently vanishes from a non-Default company. See Multi-Tenancy: worked examples.

πŸ”‘ Contracts has no tenant_id column β€” it's install-wide β€” so there's deliberately no scope filter to apply. Don't add one reflexively; check the table.

Each source in its own try/catch

if ($can('changes')) {
    try {
        [$tSql, $tArgs] = activeTenantFilter($conn, $analystId, 'c');
        …
    } catch (Exception $e) { /* table not ready β€” no change results */ }
}

On a part-migrated install (one that hasn't run Database Verify) a missing table or column throws. Wrapping each source means that source simply contributes nothing, rather than a missing problems table failing the entire search. It's the same defensive posture the tenancy helpers themselves take.

The CHG-#### trick

Changes have no stored reference β€” CHG-0042 is derived from the id at display time. So searching "CHG-42" or "42" must match changes.id = 42:

$digits = preg_replace('/\D+/', '', $q);                 // "CHG-42" β†’ "42"
$idClause = $digits !== '' ? ' OR c.id = ?' : '';
$sql = "SELECT c.id, c.title FROM changes c
         WHERE (c.title LIKE ?" . $idClause . ")" . $tSql . " …";

and the row's subtitle is re-derived with sprintf('CHG-%04d', $id) so the palette shows the reference the analyst knows.

Each source caps at $perType = 6 and the query is %…% on a 2-char minimum. Results are a flat list of { type, module, id, title, subtitle, url }; the client does the grouping.


5. πŸ–₯️ Deep links: mostly free, one added

A palette result is only useful if it opens the record. Six of the seven modules already supported opening one record by URL, so their result url just points at the existing route:

Module URL
Tickets tickets/?ticket_id=<id>
Changes change-management/?change_id=<id>
Problems problem-management/?problem_id=<id>
Knowledge knowledge/?article=<id>
Contracts contracts/view.php?id=<id>
CMDB cmdb/object.php?id=<id>

Assets were the exception β€” the module selected a record via an in-page selectAsset(id) click with no URL routing at all. Rather than link to a bare list, the fix was a small deep link in asset-management/index.php:

Promise.all([ loadAssets(), loadAssetTypesForDropdown(), … ]).then(function () {
    var aid = new URLSearchParams(window.location.search).get('asset_id');
    if (aid) { var n = parseInt(aid, 10); if (n) selectAsset(n); }
});

The Promise.all matters: selectAsset() renders the Type/Status/Location dropdowns from lookups that load separately, so the deep link waits for them β€” otherwise a deep-linked asset paints with empty dropdowns on first load.


6. βœ… How this was verified

php -l proves nothing (a fatal is served as HTTP 200), and grepping rendered HTML for a function name proves less. What was actually done:

  1. Server side, against a forged admin session (session_start file with analyst_id): confirmed the page emits CP_MODULES populated with all 21 modules β€” the check that caught the missing global $modules, which would otherwise have shipped an empty launcher.
  2. The endpoint hit directly for a spread of queries, asserting all seven types come back with correct url and subtitle β€” CHG-0008 β†’ change-management/?change_id=8, PRB-00024 β†’ problem-management/?problem_id=24, CON-2023-005 β†’ contracts/view.php?id=3, and so on. Searching a reference (CHG-…, PRB) was checked as well as searching a title.
  3. The 2-char floor confirmed to return [] at the endpoint, not just in the client.
  4. The client in headless Chrome: a real Ctrl+K KeyboardEvent dispatched at document opened the palette; asserted the empty-query view lists modules + actions, that typing tas filters to Tasks, that ArrowDown moves the active row, and that Escape closes it β€” with an error listener proving no exceptions were thrown on load.

7. Extending it

  • A new search source. Add a gated try/catch block to global_search.php returning { type, module, id, title, subtitle, url }, using that module's own tenancy filter and a real deep-link URL (add one to the module first if it has none, as assets needed). Then teach the client four maps (Β§3). Check the multi-tenancy meaning of NULL for that table before choosing the filter β€” it is the easiest thing to get wrong.
  • A new global action. Push to the COMMANDS array in command-palette.js: a label, some keywords, and a run().
  • Contextual actions (assign-to-me / close the ticket you're looking at) are the intended next step. They differ from today's global actions in that they depend on page context, so they'd be injected per-page (or read from the URL/DOM) rather than hard-coded in COMMANDS, and would run through the same ticket service the inbox uses β€” never a second write path.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally