Rebuild global search as a live search palette

- Press / or Ctrl+K (or click the top bar box) for results as you type,
  grouped by type, with keyboard navigation and recent clients when empty
- Ticket/invoice/quote numbers jump to the top; Enter opens immediately
- Phone, IP (any interface) and MAC matching; app pages offered by name
- Full results page rebuilt on the same search: totals, links into each
  list page, ticket conversation and document text matches
- Passwords no longer rendered into the results page; reveal/copy go
  through the audited endpoint
- Fix stray }); breaking inline scripts on asset and contact pages
This commit is contained in:
johnnyq
2026-10-08 17:40:46 -04:00
parent 9bfd26d57b
commit f7e5655e05
10 changed files with 1681 additions and 908 deletions

View File

@@ -382,6 +382,37 @@ if (isset($_GET['get_active_clients'])) {
echo json_encode($response);
}
/*
* Live results for the search palette in the top bar (js/global_search.js).
*
* Same search as the full results page - functions/search.php - so the two can
* never disagree about what this user may see. Five per group plus any app page
* whose name matches; with nothing typed yet, the recently opened clients.
* Values are raw: the palette renders them as text, never as HTML.
*/
if (isset($_GET['global_search'])) {
$global_search_query = (string) ($_GET['q'] ?? '');
$response = [
'query' => $global_search_query,
'pages' => [],
'groups' => [],
'recent' => [],
];
if (trim($global_search_query) === '') {
$response['recent'] = globalSearchRecentClients();
} else {
$response['pages'] = globalSearchPages($global_search_query);
$response['groups'] = globalSearch($global_search_query, 5);
}
header('Content-Type: application/json');
echo json_encode($response, JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
/*
* Returns the body of one canned response, for the picker on the ticket reply form.
* Fetched on demand rather than rendered into every ticket page, because the bodies are

View File

@@ -1365,7 +1365,6 @@ if (isset($_GET['asset_id'])) {
syncAuthForm(select);
});
});
});
</script>
<!-- Include scripts to fetch TOTP codes and passwords via the credential ID -->

View File

@@ -1241,7 +1241,6 @@ if (isset($_GET['contact_id'])) {
syncAuthForm(select);
});
});
});
</script>
<!-- Include scripts to fetch TOTP codes and passwords via the credential ID -->

File diff suppressed because it is too large Load Diff

View File

@@ -1568,3 +1568,228 @@ html {
.client-header-toggle:hover .client-header-chevron {
color: var(--bs-body-color);
}
/* Search palette - js/global_search.js
Opens over the page on / or Ctrl+K. Sits high rather than centred, so the
results grow downwards from the box instead of the box jumping as they
arrive. Everything takes its colour from the Bootstrap variables, so dark
mode needs nothing extra; the highlighted row uses the theme accent. */
.itflow-search .modal-dialog {
max-width: 44rem;
margin-top: 8vh;
}
.itflow-search .modal-content {
overflow: hidden;
border: 0;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, .35);
}
.itflow-search-bar {
display: flex;
align-items: center;
gap: .75rem;
padding: .75rem 1rem;
border-bottom: 1px solid var(--bs-border-color);
}
.itflow-search-icon,
.itflow-search-spinner {
flex: 0 0 auto;
color: var(--bs-secondary-color);
}
.itflow-search-input {
flex: 1 1 auto;
min-width: 0;
padding: .25rem 0;
border: 0;
outline: 0;
background: transparent;
color: var(--bs-body-color);
font-size: 1.15rem;
}
.itflow-search-input::placeholder {
color: var(--bs-secondary-color);
}
.itflow-search-results {
max-height: min(60vh, 34rem);
overflow-y: auto;
padding: .25rem 0 .5rem;
}
.itflow-search-group {
padding: .65rem 1rem .25rem;
font-size: .7rem;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--bs-secondary-color);
}
.itflow-search-group .fa-fw {
margin-right: .35rem;
}
.itflow-search-item {
display: flex;
align-items: center;
gap: .75rem;
padding: .45rem 1rem .45rem calc(1rem - 3px);
border-left: 3px solid transparent;
color: var(--bs-body-color);
text-decoration: none;
cursor: pointer;
}
.itflow-search-item:hover,
.itflow-search-item:focus {
color: var(--bs-body-color);
text-decoration: none;
}
.itflow-search-item.active {
border-left-color: var(--itflow-accent, #007bff);
background-color: rgba(var(--bs-emphasis-color-rgb), .06);
}
.itflow-search-item-icon {
flex: 0 0 auto;
color: var(--bs-secondary-color);
}
.itflow-search-item.active .itflow-search-item-icon {
color: var(--itflow-accent, #007bff);
}
.itflow-search-item-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
}
.itflow-search-item-title,
.itflow-search-item-sub,
.itflow-search-item-context {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.itflow-search-item-sub,
.itflow-search-item-context {
font-size: .8rem;
color: var(--bs-secondary-color);
}
.itflow-search-item-aside {
display: flex;
flex: 0 1 auto;
align-items: center;
gap: .5rem;
max-width: 40%;
}
.itflow-search-ref {
margin-right: .5rem;
font-family: var(--bs-font-monospace);
font-size: .8rem;
color: var(--bs-secondary-color);
}
.itflow-search mark {
padding: 0;
background: transparent;
color: inherit;
font-weight: 700;
}
.itflow-search-more .itflow-search-item-title,
.itflow-search-all .itflow-search-item-title {
color: var(--bs-secondary-color);
}
.itflow-search-more.active .itflow-search-item-title,
.itflow-search-all.active .itflow-search-item-title {
color: var(--bs-body-color);
}
.itflow-search-all {
margin-top: .25rem;
border-top: 1px solid var(--bs-border-color-translucent);
padding-top: .6rem;
}
.itflow-search-empty,
.itflow-search-hint {
padding: 1.25rem 1rem;
text-align: center;
color: var(--bs-secondary-color);
}
.itflow-search-empty .fa-fw {
font-size: 1.5rem;
margin-bottom: .5rem;
}
.itflow-search-hint {
padding: .75rem 1rem;
font-size: .85rem;
}
.itflow-search-footer {
gap: 1rem;
align-items: center;
padding: .45rem 1rem;
border-top: 1px solid var(--bs-border-color);
background-color: var(--bs-tertiary-bg);
font-size: .75rem;
color: var(--bs-secondary-color);
}
.itflow-search kbd,
.navbar-search-kbd {
padding: .05rem .35rem;
border: 1px solid var(--bs-border-color);
border-radius: .25rem;
background-color: var(--bs-body-bg);
color: var(--bs-secondary-color);
font-family: inherit;
font-size: .7rem;
box-shadow: none;
}
@media (max-width: 575.98px) {
.itflow-search .modal-dialog {
margin-top: 0;
}
.itflow-search .modal-content {
display: flex;
flex-direction: column;
}
.itflow-search-results {
flex: 1 1 auto;
max-height: none;
}
/* No room beside the title on a phone: client and status drop under it */
.itflow-search-item {
flex-wrap: wrap;
row-gap: .15rem;
}
.itflow-search-item-body {
flex: 1 1 0;
}
.itflow-search-item-aside {
order: 3;
max-width: none;
width: 100%;
padding-left: calc(1.25em + .75rem);
}
}
/* The / hint inside the top bar search box. Pointer events off so a click on
it still lands on the box underneath and opens the palette. */
.navbar-search .navbar-search-field .navbar-search-kbd {
position: absolute;
top: 50%;
right: .6rem;
transform: translateY(-50%);
pointer-events: none;
background-color: transparent;
border-color: rgba(var(--bs-emphasis-color-rgb), .25);
}
.navbar-search .navbar-search-field .form-control {
cursor: pointer;
}
/* Full search results - agent/global_search.php. Same bold-not-yellow match
marking as the palette, and the client column capped so a long client name
cannot push the title off the row. */
.global-search-group mark {
padding: 0;
background: transparent;
color: inherit;
font-weight: 700;
}
.global-search-context {
max-width: 14rem;
}
.global-search-group {
scroll-margin-top: 4rem;
}

View File

@@ -27,3 +27,4 @@ require_once __DIR__ . '/functions/ai.php';
require_once __DIR__ . '/functions/export.php';
require_once __DIR__ . '/functions/calendar.php';
require_once __DIR__ . '/functions/backup.php';
require_once __DIR__ . '/functions/search.php';

602
functions/search.php Normal file
View File

@@ -0,0 +1,602 @@
<?php
/*
* Global search.
*
* One search behind two surfaces:
* - the live search palette in the top bar (press / or Ctrl+K), which asks
* agent/ajax.php?global_search as you type
* - the full results page, agent/global_search.php, reached by pressing Enter
*
* Both call globalSearch(), so the two can never disagree about what a user is
* allowed to find. Every group is gated on the module its own list page enforces
* and runs through clientScopeSql(), the same two checks the record's own page
* applies.
*
* The palette is for getting somewhere: it matches names, numbers, emails,
* phone numbers, IPs, MACs and serials, which is cheap enough to run on every
* keystroke. The results page adds $deep, which also searches the long text -
* ticket details, ticket conversations and document contents.
*
* Values come back RAW, not HTML-escaped. The palette renders them with
* textContent; the results page escapes them at output.
*/
/**
* The query, prepared once for every group: equality, LIKE (with % and _
* matched literally, so searching "100%" does not match everything), and the
* digits of a phone number when the query looks like one.
*/
function globalSearchTerms($raw) {
// escapeSql() strips tags, so do it first: the length check, the
// highlighting and the SQL then all agree on what was searched for
$raw = trim(strip_tags((string) $raw));
$raw = mb_substr($raw, 0, 100);
$digits = preg_replace('/\D/', '', $raw);
return [
'raw' => $raw,
'eq' => escapeSql($raw),
'like' => escapeSql(addcslashes($raw, '%_\\')),
'phone' => (strlen($digits) >= 3 && preg_match('/^[\d\s().+-]+$/', $raw)) ? $digits : null,
];
}
/**
* A record number typed the way people actually type them: "1042", "#1042",
* "TCK-1042", "tck1042". The letters only count when they are this record type's
* own prefix, otherwise "srv01" would match ticket 1.
*/
function globalSearchNumber($raw, $prefix) {
$prefix = rtrim((string) $prefix, '-');
$prefix_pattern = $prefix === '' ? '' : '|' . preg_quote($prefix, '/') . '-?';
if (preg_match('/^(?:#' . $prefix_pattern . ')?\s*0*(\d{1,9})$/i', $raw, $match)) {
return intval($match[1]);
}
return null;
}
/** Plain text around the first match in a long HTML field, for the results page. */
function globalSearchSnippet($html, $raw, $radius = 80) {
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string) $html), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
$position = mb_stripos($text, $raw);
if ($position === false) {
return mb_substr($text, 0, $radius * 2) . (mb_strlen($text) > $radius * 2 ? '…' : '');
}
$start = max(0, $position - $radius);
$snippet = mb_substr($text, $start, mb_strlen($raw) + $radius * 2);
return ($start > 0 ? '…' : '') . $snippet . ($start + mb_strlen($snippet) < mb_strlen($text) ? '…' : '');
}
function globalSearchMoney($amount, $currency_code) {
global $currency_format;
if (!$currency_format) {
return number_format(floatval($amount), 2);
}
return numfmt_format_currency($currency_format, floatval($amount), $currency_code ?: 'USD');
}
/** Where a client-scoped record lives: the client's own copy of a page, or the global one. */
function globalSearchClientUrl($page, $client_id, $params = []) {
if (intval($client_id) > 0) {
$params = array_merge(['client_id' => intval($client_id)], $params);
}
return "/agent/$page" . ($params ? '?' . http_build_query($params) : '');
}
/**
* Every searchable record type, in display order. A group whose module the
* user cannot read, or whose feature is switched off, is left out entirely.
*/
function globalSearchGroups($terms, $deep) {
global $config_ticket_prefix, $config_invoice_prefix, $config_quote_prefix;
global $config_module_enable_ticketing, $config_module_enable_accounting;
$eq = $terms['eq'];
$like = $terms['like'];
$phone = $terms['phone'];
$q = $terms['raw'];
$ticketing = intval($config_module_enable_ticketing) === 1;
$accounting = intval($config_module_enable_accounting) === 1;
$groups = [];
$groups[] = [
'key' => 'clients', 'label' => 'Clients', 'icon' => 'users', 'module' => 'module_client',
'select' => 'client_id, client_name, client_type, client_abbreviation',
'from' => 'clients',
'where' => "client_archived_at IS NULL
AND (client_name LIKE '%$like%' OR client_abbreviation LIKE '%$like%' OR client_website LIKE '%$like%')",
'scope' => 'clients.client_id',
'order' => "(client_name = '$eq') DESC, (client_name LIKE '$like%') DESC, client_accessed_at DESC, client_name",
'list_url' => '/agent/clients.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
return [
'title' => $row['client_name'],
'subtitle' => $row['client_type'],
'url' => '/agent/client_overview.php?client_id=' . intval($row['client_id']),
];
},
];
$ticket_number = globalSearchNumber($q, $config_ticket_prefix);
$ticket_number_match = $ticket_number !== null ? "OR ticket_number = $ticket_number" : '';
$ticket_deep_match = $deep ? "OR ticket_details LIKE '%$like%'" : '';
$groups[] = [
'key' => 'tickets', 'label' => 'Tickets', 'icon' => 'life-ring', 'module' => 'module_support', 'enabled' => $ticketing,
'select' => 'ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_client_id, ticket_closed_at,
client_name, ticket_status_name, ticket_status_color',
'from' => 'tickets
LEFT JOIN clients ON ticket_client_id = client_id
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id',
'where' => "ticket_archived_at IS NULL
AND (ticket_subject LIKE '%$like%' OR CONCAT(ticket_prefix, ticket_number) LIKE '%$like%' $ticket_number_match $ticket_deep_match)",
'scope' => 'ticket_client_id',
'order' => ($ticket_number !== null ? "(ticket_number = $ticket_number) DESC, " : '') . "(ticket_closed_at IS NULL) DESC, ticket_id DESC",
'exact' => $ticket_number !== null ? "ticket_number = $ticket_number" : null,
'list_url' => '/agent/tickets.php?' . http_build_query(['state' => 'all', 'q' => $q]),
'item' => function ($row) {
return [
'ref' => $row['ticket_prefix'] . $row['ticket_number'],
'title' => $row['ticket_subject'],
'context' => $row['client_name'],
'badge' => $row['ticket_status_name'] ? ['text' => $row['ticket_status_name'], 'hex' => $row['ticket_status_color']] : null,
'url' => globalSearchClientUrl('ticket.php', $row['ticket_client_id'], ['ticket_id' => intval($row['ticket_id'])]),
];
},
];
// Stored numbers may or may not carry punctuation, so compare digits to digits
$contact_phone_match = $phone
? "OR REGEXP_REPLACE(contact_phone, '[^0-9]', '') LIKE '%$phone%' OR REGEXP_REPLACE(contact_mobile, '[^0-9]', '') LIKE '%$phone%'"
: '';
$groups[] = [
'key' => 'contacts', 'label' => 'Contacts', 'icon' => 'address-book', 'module' => 'module_client',
'select' => 'contact_id, contact_name, contact_title, contact_email, contact_client_id, client_name',
'from' => 'contacts LEFT JOIN clients ON contact_client_id = client_id',
'where' => "contact_archived_at IS NULL
AND (contact_name LIKE '%$like%' OR contact_email LIKE '%$like%' OR contact_title LIKE '%$like%' $contact_phone_match)",
'scope' => 'contact_client_id',
'order' => "(contact_name = '$eq') DESC, (contact_name LIKE '$like%') DESC, contact_name",
'list_url' => '/agent/contacts.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
return [
'title' => $row['contact_name'],
'subtitle' => implode(' · ', array_filter([$row['contact_title'], $row['contact_email']])),
'context' => $row['client_name'],
'url' => globalSearchClientUrl('contact.php', $row['contact_client_id'], ['contact_id' => intval($row['contact_id'])]),
];
},
];
// Any interface counts, not just the primary one - the address someone has
// in hand is as likely to be the iDRAC as the OS NIC.
$groups[] = [
'key' => 'assets', 'label' => 'Assets', 'icon' => 'desktop', 'module' => 'module_support',
'select' => 'asset_id, asset_name, asset_type, asset_make, asset_model, asset_client_id, client_name,
primary_interface.interface_ip AS asset_ip',
'from' => 'assets
LEFT JOIN clients ON asset_client_id = client_id
LEFT JOIN asset_interfaces AS primary_interface
ON primary_interface.interface_asset_id = asset_id AND primary_interface.interface_primary = 1',
'where' => "asset_archived_at IS NULL
AND (asset_name LIKE '%$like%' OR asset_serial LIKE '%$like%' OR asset_type LIKE '%$like%'
OR asset_make LIKE '%$like%' OR asset_model LIKE '%$like%' OR asset_os LIKE '%$like%'
OR asset_description LIKE '%$like%'
OR EXISTS (SELECT 1 FROM asset_interfaces
WHERE interface_asset_id = asset_id AND interface_archived_at IS NULL
AND (interface_ip LIKE '%$like%' OR interface_nat_ip LIKE '%$like%' OR interface_ipv6 LIKE '%$like%' OR interface_mac LIKE '%$like%')))",
'scope' => 'asset_client_id',
// An exact address beats every partial one: 192.168.10.1 before 192.168.10.14
'order' => "EXISTS (SELECT 1 FROM asset_interfaces WHERE interface_asset_id = asset_id
AND (interface_ip = '$eq' OR interface_nat_ip = '$eq')) DESC,
(asset_name = '$eq') DESC, (asset_name LIKE '$like%') DESC, asset_name",
'list_url' => '/agent/assets.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
return [
'title' => $row['asset_name'],
'subtitle' => implode(' · ', array_filter([$row['asset_type'], trim($row['asset_make'] . ' ' . $row['asset_model']), $row['asset_ip']])),
'context' => $row['client_name'],
'url' => globalSearchClientUrl('asset.php', $row['asset_client_id'], ['asset_id' => intval($row['asset_id'])]),
];
},
];
// Name and description only. Usernames and passwords are encrypted at rest,
// and the results page reveals passwords through the audited endpoint, never
// in the markup.
$groups[] = [
'key' => 'credentials', 'label' => 'Credentials', 'icon' => 'key', 'module' => 'module_credential',
'select' => 'credential_id, credential_name, credential_description, credential_client_id, client_name',
'from' => 'credentials LEFT JOIN clients ON credential_client_id = client_id',
'where' => "credential_archived_at IS NULL
AND (credential_name LIKE '%$like%' OR credential_description LIKE '%$like%' OR credential_uri LIKE '%$like%')",
'scope' => 'credential_client_id',
'order' => "(credential_name = '$eq') DESC, (credential_name LIKE '$like%') DESC, credential_name",
'list_url' => '/agent/credentials.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
return [
'id' => intval($row['credential_id']),
'title' => $row['credential_name'],
'subtitle' => $row['credential_description'],
'context' => $row['client_name'],
'url' => globalSearchClientUrl('credentials.php', $row['credential_client_id'], ['q' => $row['credential_name']]),
];
},
];
$groups[] = [
'key' => 'domains', 'label' => 'Domains', 'icon' => 'globe', 'module' => 'module_support',
'select' => 'domain_id, domain_name, domain_expire, domain_client_id, client_name',
'from' => 'domains LEFT JOIN clients ON domain_client_id = client_id',
'where' => "domain_archived_at IS NULL AND domain_name LIKE '%$like%'",
'scope' => 'domain_client_id',
'order' => "(domain_name = '$eq') DESC, (domain_name LIKE '$like%') DESC, domain_name",
'list_url' => '/agent/domains.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
return [
'title' => $row['domain_name'],
'subtitle' => $row['domain_expire'] ? 'Expires ' . $row['domain_expire'] : '',
'context' => $row['client_name'],
'url' => globalSearchClientUrl('domains.php', $row['domain_client_id'], ['q' => $row['domain_name']]),
];
},
];
$document_deep_match = $deep ? "OR MATCH(document_content_raw) AGAINST ('$eq')" : '';
$groups[] = [
'key' => 'documents', 'label' => 'Documents', 'icon' => 'file-alt', 'module' => 'module_support',
'select' => 'document_id, document_name, document_description, document_client_id, client_name',
'from' => 'documents LEFT JOIN clients ON document_client_id = client_id',
'where' => "document_archived_at IS NULL
AND (document_name LIKE '%$like%' OR document_description LIKE '%$like%' $document_deep_match)",
'scope' => 'document_client_id',
'order' => "(document_name LIKE '$like%') DESC, document_updated_at DESC",
'list_url' => null,
'item' => function ($row) {
return [
'title' => $row['document_name'],
'subtitle' => $row['document_description'],
'context' => $row['client_name'],
'url' => globalSearchClientUrl('document.php', $row['document_client_id'], ['document_id' => intval($row['document_id'])]),
];
},
];
$groups[] = [
'key' => 'files', 'label' => 'Files', 'icon' => 'paperclip', 'module' => 'module_support',
'select' => 'file_id, file_name, file_description, file_client_id, file_folder_id, client_name, folder_name',
'from' => 'files
LEFT JOIN clients ON file_client_id = client_id
LEFT JOIN folders ON file_folder_id = folder_id',
'where' => "file_archived_at IS NULL AND (file_name LIKE '%$like%' OR file_description LIKE '%$like%')",
'scope' => 'file_client_id',
'order' => "(file_name LIKE '$like%') DESC, file_id DESC",
'list_url' => null,
'item' => function ($row) {
return [
'title' => $row['file_name'],
'subtitle' => implode(' · ', array_filter([$row['folder_name'], $row['file_description']])),
'context' => $row['client_name'],
'url' => globalSearchClientUrl('files.php', $row['file_client_id'], ['folder_id' => intval($row['file_folder_id'])]),
];
},
];
$invoice_number = globalSearchNumber($q, $config_invoice_prefix);
$invoice_number_match = $invoice_number !== null ? "OR invoice_number = $invoice_number" : '';
$groups[] = [
'key' => 'invoices', 'label' => 'Invoices', 'icon' => 'file-invoice-dollar', 'module' => 'module_sales', 'enabled' => $accounting,
'select' => 'invoice_id, invoice_prefix, invoice_number, invoice_scope, invoice_status, invoice_amount,
invoice_currency_code, invoice_client_id, client_name',
'from' => 'invoices LEFT JOIN clients ON invoice_client_id = client_id',
'where' => "invoice_archived_at IS NULL
AND (CONCAT(invoice_prefix, invoice_number) LIKE '%$like%' OR invoice_scope LIKE '%$like%' $invoice_number_match)",
'scope' => 'invoice_client_id',
'order' => ($invoice_number !== null ? "(invoice_number = $invoice_number) DESC, " : '') . 'invoice_number DESC',
'exact' => $invoice_number !== null ? "invoice_number = $invoice_number" : null,
'list_url' => '/agent/invoices.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
return [
'ref' => $row['invoice_prefix'] . $row['invoice_number'],
'title' => $row['invoice_scope'] ?: 'Invoice',
'subtitle' => globalSearchMoney($row['invoice_amount'], $row['invoice_currency_code']),
'context' => $row['client_name'],
'badge' => ['text' => $row['invoice_status'], 'variant' => getInvoiceBadgeColor($row['invoice_status'])],
'url' => globalSearchClientUrl('invoice.php', $row['invoice_client_id'], ['invoice_id' => intval($row['invoice_id'])]),
];
},
];
$quote_number = globalSearchNumber($q, $config_quote_prefix);
$quote_number_match = $quote_number !== null ? "OR quote_number = $quote_number" : '';
$groups[] = [
'key' => 'quotes', 'label' => 'Quotes', 'icon' => 'comment-dollar', 'module' => 'module_sales', 'enabled' => $accounting,
'select' => 'quote_id, quote_prefix, quote_number, quote_scope, quote_status, quote_amount,
quote_currency_code, quote_client_id, client_name',
'from' => 'quotes LEFT JOIN clients ON quote_client_id = client_id',
'where' => "quote_archived_at IS NULL
AND (CONCAT(quote_prefix, quote_number) LIKE '%$like%' OR quote_scope LIKE '%$like%' $quote_number_match)",
'scope' => 'quote_client_id',
'order' => ($quote_number !== null ? "(quote_number = $quote_number) DESC, " : '') . 'quote_number DESC',
'exact' => $quote_number !== null ? "quote_number = $quote_number" : null,
'list_url' => '/agent/quotes.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
$quote_badge_variants = ['Sent' => 'warning', 'Viewed' => 'primary', 'Accepted' => 'success', 'Declined' => 'danger', 'Invoiced' => 'info'];
return [
'ref' => $row['quote_prefix'] . $row['quote_number'],
'title' => $row['quote_scope'] ?: 'Quote',
'subtitle' => globalSearchMoney($row['quote_amount'], $row['quote_currency_code']),
'context' => $row['client_name'],
'badge' => ['text' => $row['quote_status'], 'variant' => $quote_badge_variants[$row['quote_status']] ?? 'secondary'],
'url' => globalSearchClientUrl('quote.php', $row['quote_client_id'], ['quote_id' => intval($row['quote_id'])]),
];
},
];
$vendor_phone_match = $phone ? "OR REGEXP_REPLACE(vendor_phone, '[^0-9]', '') LIKE '%$phone%'" : '';
$groups[] = [
'key' => 'vendors', 'label' => 'Vendors', 'icon' => 'building', 'module' => 'module_client',
'select' => 'vendor_id, vendor_name, vendor_description, vendor_client_id, client_name',
'from' => 'vendors LEFT JOIN clients ON vendor_client_id = client_id',
'where' => "vendor_archived_at IS NULL
AND (vendor_name LIKE '%$like%' OR vendor_description LIKE '%$like%' OR vendor_account_number LIKE '%$like%' $vendor_phone_match)",
'scope' => 'vendor_client_id',
'order' => "(vendor_name = '$eq') DESC, (vendor_name LIKE '$like%') DESC, vendor_name",
'list_url' => '/agent/vendors.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
return [
'title' => $row['vendor_name'],
'subtitle' => $row['vendor_description'],
'context' => $row['client_name'],
'url' => globalSearchClientUrl('vendors.php', $row['vendor_client_id'], ['q' => $row['vendor_name']]),
];
},
];
$recurring_deep_match = $deep ? "OR recurring_ticket_details LIKE '%$like%'" : '';
$groups[] = [
'key' => 'recurring_tickets', 'label' => 'Recurring Tickets', 'icon' => 'redo-alt', 'module' => 'module_support', 'enabled' => $ticketing,
'select' => 'recurring_ticket_id, recurring_ticket_subject, recurring_ticket_frequency, recurring_ticket_next_run,
recurring_ticket_client_id, client_name',
'from' => 'recurring_tickets LEFT JOIN clients ON recurring_ticket_client_id = client_id',
'where' => "(recurring_ticket_subject LIKE '%$like%' $recurring_deep_match)",
'scope' => 'recurring_ticket_client_id',
'order' => "(recurring_ticket_subject LIKE '$like%') DESC, recurring_ticket_next_run",
'list_url' => '/agent/recurring_tickets.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
return [
'title' => $row['recurring_ticket_subject'],
'subtitle' => implode(' · ', array_filter([$row['recurring_ticket_frequency'], $row['recurring_ticket_next_run'] ? 'Next ' . $row['recurring_ticket_next_run'] : ''])),
'context' => $row['client_name'],
'url' => globalSearchClientUrl('recurring_tickets.php', $row['recurring_ticket_client_id'], ['q' => $row['recurring_ticket_subject']]),
];
},
];
$groups[] = [
'key' => 'products', 'label' => 'Products', 'icon' => 'box-open', 'module' => 'module_sales', 'enabled' => $accounting,
'select' => 'product_id, product_name, product_code, product_price, product_currency_code',
'from' => 'products',
'where' => "product_archived_at IS NULL
AND (product_name LIKE '%$like%' OR product_code LIKE '%$like%' OR product_description LIKE '%$like%')",
'scope' => null,
'order' => "(product_name = '$eq') DESC, (product_name LIKE '$like%') DESC, product_name",
'list_url' => '/agent/products.php?' . http_build_query(['q' => $q]),
'item' => function ($row) {
return [
'title' => $row['product_name'],
'subtitle' => implode(' · ', array_filter([$row['product_code'], globalSearchMoney($row['product_price'], $row['product_currency_code'])])),
'url' => '/agent/products.php?' . http_build_query(['q' => $row['product_name']]),
];
},
];
// Ticket conversations: matched inside reply bodies, so results page only.
if ($deep) {
$groups[] = [
'key' => 'ticket_replies', 'label' => 'Ticket Conversations', 'icon' => 'comments', 'module' => 'module_support', 'enabled' => $ticketing,
'select' => 'ticket_reply_id, ticket_reply, ticket_reply_created_at, ticket_id, ticket_prefix, ticket_number,
ticket_subject, ticket_client_id, client_name',
'from' => 'ticket_replies
JOIN tickets ON ticket_reply_ticket_id = ticket_id
LEFT JOIN clients ON ticket_client_id = client_id',
'where' => "ticket_reply_archived_at IS NULL AND ticket_archived_at IS NULL AND ticket_reply LIKE '%$like%'",
'scope' => 'ticket_client_id',
'order' => 'ticket_reply_id DESC',
'list_url' => null,
'item' => function ($row) use ($q) {
return [
'ref' => $row['ticket_prefix'] . $row['ticket_number'],
'title' => $row['ticket_subject'],
'snippet' => globalSearchSnippet($row['ticket_reply'], $q),
'context' => $row['client_name'],
'url' => globalSearchClientUrl('ticket.php', $row['ticket_client_id'], ['ticket_id' => intval($row['ticket_id'])]),
];
},
];
}
return $groups;
}
/**
* Run the search.
*
* Returns the groups that matched, in display order, each as:
* key, label, icon, items[], more (bool), total (int, only with $with_totals), list_url
* A group with an exact record-number hit (ticket 1042 for "TCK-1042") moves to
* the front, since that is unambiguously what was asked for.
*/
function globalSearch($raw_query, $limit = 5, $deep = false, $with_totals = false) {
global $mysqli;
$terms = globalSearchTerms($raw_query);
if (mb_strlen($terms['raw']) < 2) {
return [];
}
$limit = max(1, intval($limit));
$results = [];
foreach (globalSearchGroups($terms, $deep) as $group) {
if (array_key_exists('enabled', $group) && !$group['enabled']) {
continue;
}
if (lookupUserPermission($group['module']) < 1) {
continue;
}
$scope = $group['scope'] ? clientScopeSql($group['scope']) : '';
$where = "{$group['where']} $scope";
$sql = mysqli_query(
$mysqli,
"SELECT {$group['select']} FROM {$group['from']} WHERE $where ORDER BY {$group['order']} LIMIT " . ($limit + 1)
);
$items = [];
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = $group['item']($row);
}
if (!$items) {
continue;
}
$more = count($items) > $limit;
$items = array_slice($items, 0, $limit);
$exact = false;
if (!empty($group['exact'])) {
$exact_row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM {$group['from']} WHERE $where AND {$group['exact']} LIMIT 1"));
$exact = (bool) $exact_row;
}
$total = null;
if ($with_totals) {
$total = $more
? intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM {$group['from']} WHERE $where"))[0])
: count($items);
}
$results[] = [
'key' => $group['key'],
'label' => $group['label'],
'icon' => $group['icon'],
'items' => $items,
'more' => $more,
'total' => $total,
'list_url' => $group['list_url'],
'exact' => $exact,
];
}
// Exact record-number hits first; otherwise keep display order (usort is stable in PHP 8)
usort($results, function ($a, $b) {
return intval($b['exact']) - intval($a['exact']);
});
return $results;
}
/** Recently opened clients, shown in the palette before anything is typed. */
function globalSearchRecentClients($limit = 6) {
global $mysqli;
if (lookupUserPermission('module_client') < 1) {
return [];
}
$limit = max(1, intval($limit));
$sql = mysqli_query(
$mysqli,
"SELECT client_id, client_name, client_type FROM clients
WHERE client_archived_at IS NULL AND client_accessed_at IS NOT NULL
" . clientScopeSql('clients.client_id') . "
ORDER BY client_accessed_at DESC LIMIT $limit"
);
$items = [];
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = [
'title' => $row['client_name'],
'subtitle' => $row['client_type'],
'url' => '/agent/client_overview.php?client_id=' . intval($row['client_id']),
];
}
return $items;
}
/**
* The app's own pages, so "inv" can also take you to Invoices. Same gates as
* the side nav, which is the only other place these links appear.
*/
function globalSearchPages($raw_query) {
global $config_module_enable_ticketing, $config_module_enable_accounting, $session_is_admin;
$query = mb_strtolower(trim((string) $raw_query));
if (mb_strlen($query) < 2) {
return [];
}
$ticketing = intval($config_module_enable_ticketing) === 1;
$accounting = intval($config_module_enable_accounting) === 1;
$support = lookupUserPermission('module_support') >= 1;
$client = lookupUserPermission('module_client') >= 1;
$sales = lookupUserPermission('module_sales') >= 1;
$financial = lookupUserPermission('module_financial') >= 1;
$pages = [
['Dashboard', '/agent/dashboard.php', true],
['Clients', '/agent/clients.php', $client],
['All Client Docs', '/agent/contacts.php', $client],
['Vendors', '/agent/vendors.php', true],
['Calendar', '/agent/calendar.php', true],
['Tickets', '/agent/tickets.php', $support && $ticketing],
['Recurring Tickets', '/agent/recurring_tickets.php', $support && $ticketing],
['Projects', '/agent/projects.php', $support && $ticketing],
['Quotes', '/agent/quotes.php', $sales && $accounting],
['Invoices', '/agent/invoices.php', $sales && $accounting],
['Recurring Invoices', '/agent/recurring_invoices.php', $sales && $accounting],
['Products', '/agent/products.php', $sales && $accounting],
['Income', '/agent/income.php', $financial && $accounting],
['Expenses', '/agent/expenses.php', $financial && $accounting],
['Recurring Expenses', '/agent/recurring_expenses.php', $financial && $accounting],
['Trips', '/agent/trips.php', $financial && $accounting],
['Accounts', '/agent/accounts.php', $financial && $accounting],
['Transactions', '/agent/transactions.php', $financial && $accounting],
['Transfers', '/agent/transfers.php', $financial && $accounting],
['Reports', '/agent/reports/', lookupUserPermission('module_reporting') >= 1],
['Administration', '/admin/', (bool) $session_is_admin],
['My Account', '/agent/user/user_details.php', true],
];
$items = [];
foreach ($pages as [$label, $url, $allowed]) {
if (!$allowed) {
continue;
}
// Match the start of any word: "inv" finds Invoices and Recurring Invoices
foreach (preg_split('/\s+/', mb_strtolower($label)) as $word) {
if (strpos($word, $query) === 0 || strpos(mb_strtolower($label), $query) === 0) {
$items[] = ['title' => $label, 'url' => $url];
break;
}
}
}
return array_slice($items, 0, 4);
}

View File

@@ -81,6 +81,7 @@
<script src="/js/ajax_modal.js"></script>
<script src="/js/confirm_modal.js"></script>
<script src="/js/date_filter.js"></script>
<script src="/js/global_search.js"></script>
</body>
</html>

View File

@@ -8,16 +8,19 @@
</li>
</ul>
<!-- SEARCH FORM -->
<!-- SEARCH - js/global_search.js turns this box into the opener for the
search palette (also on / and Ctrl+K). Without the script it is still
a plain form posting to the full results page. -->
<form class="navbar-search ms-auto" role="search" action="/agent/global_search.php">
<label for="navbar-search-input" class="visually-hidden">Search everywhere</label>
<div class="navbar-search-field">
<input class="form-control" type="search" id="navbar-search-input" name="query"
placeholder="Search everywhere" autocomplete="off"
placeholder="Search everywhere" autocomplete="off" aria-keyshortcuts="/ Control+K"
value="<?php if (isset($_GET['query'])) { echo escapeHtml($_GET['query']); } ?>">
<button class="navbar-search-submit" type="submit" aria-label="Submit search">
<i class="fas fa-search" aria-hidden="true"></i>
</button>
<kbd class="navbar-search-kbd d-none d-lg-inline" aria-hidden="true">/</kbd>
</div>
</form>

640
js/global_search.js Normal file
View File

@@ -0,0 +1,640 @@
/*
* ITFlow - the search palette.
*
* Press / (or Ctrl+K / Cmd+K) anywhere, or click the search box in the top bar,
* and a search window opens over the page. Results arrive as you type, grouped by
* record type, from agent/ajax.php?global_search - the same search the full
* results page runs (functions/search.php), so neither can show a record the
* other would hide.
*
* Up / Down move through results
* Enter open the highlighted result
* Ctrl/Cmd+Enter open it in a new tab
* Esc close
*
* With nothing typed, it lists the clients you opened most recently. The last
* row is always "Search everything", which goes to the full results page and
* also searches ticket conversations and document text.
*
* Everything the server sends is rendered with textContent, never innerHTML:
* client names, ticket subjects and the like are user-entered.
*
* Loaded once per page from includes/footer.php. It is never re-run by ajax
* modals, but the window flag keeps a second copy from binding twice anyway.
*/
(function () {
'use strict';
if (window.itflowGlobalSearch || typeof bootstrap === 'undefined') {
return;
}
const ENDPOINT = '/agent/ajax.php';
const RESULTS_PAGE = '/agent/global_search.php';
const DEBOUNCE_MS = 150;
const MIN_LENGTH = 2;
const CACHE_SIZE = 50;
const BADGE_VARIANTS = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
const MOD_KEY = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '') ? '⌘' : 'Ctrl';
let modalEl = null;
let modal = null;
let input = null;
let list = null;
let spinner = null;
let announcer = null;
let options = [];
let active = -1;
let renderedQuery = null;
let lastQuery = '';
let debounceTimer = null;
let spinnerTimer = null;
let controller = null;
let requestSeq = 0;
let pendingOpen = null; // Enter pressed before the results for the current text arrived
let optionSeq = 0;
let returnFocus = null; // what had focus before the palette opened
let navbarBox = null;
const cache = new Map();
/* ---------- building blocks ---------- */
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text !== undefined && text !== null) {
node.textContent = text;
}
return node;
}
function icon(name, extraClass) {
const i = el('i', 'fas fa-fw fa-' + (/^[a-z0-9-]+$/.test(name || '') ? name : 'circle') + (extraClass ? ' ' + extraClass : ''));
i.setAttribute('aria-hidden', 'true');
return i;
}
// Only same-origin paths. The server builds every URL itself, so anything
// else means something is wrong - fall back to a dead link, not a javascript: one.
function safeUrl(url) {
if (typeof url === 'string' && url.charAt(0) === '/' && url.charAt(1) !== '/' && url.charAt(1) !== '\\') {
return url;
}
return '#';
}
function resultsPageUrl(query, groupKey) {
return RESULTS_PAGE + '?query=' + encodeURIComponent(query) + (groupKey ? '#search-' + groupKey : '');
}
// The matched text in bold, built from text nodes so nothing is ever parsed as HTML
function highlighted(text, query) {
const frag = document.createDocumentFragment();
const value = text === null || text === undefined ? '' : String(text);
const needle = query.trim().toLowerCase();
const hay = value.toLowerCase();
// toLowerCase can change the length of some characters; then offsets
// would not line up, so show the text plain rather than mark the wrong bit
if (!needle || hay.length !== value.length) {
frag.append(value);
return frag;
}
let from = 0;
let at;
while ((at = hay.indexOf(needle, from)) !== -1) {
frag.append(value.slice(from, at));
frag.append(el('mark', null, value.slice(at, at + needle.length)));
from = at + needle.length;
}
frag.append(value.slice(from));
return frag;
}
function badge(spec) {
if (!spec || !spec.text) {
return null;
}
const node = el('span', 'badge rounded-pill', spec.text);
if (spec.variant && BADGE_VARIANTS.indexOf(spec.variant) !== -1) {
node.classList.add('text-bg-' + spec.variant);
} else if (spec.hex && /^#[0-9a-f]{3,8}$/i.test(spec.hex)) {
node.style.backgroundColor = spec.hex;
node.classList.add('text-white');
} else {
node.classList.add('text-bg-secondary');
}
return node;
}
function option(url, iconName, query, parts, extraClass) {
const link = el('a', 'itflow-search-item' + (extraClass ? ' ' + extraClass : ''));
link.href = safeUrl(url);
link.id = 'itflow-search-opt-' + (++optionSeq);
link.tabIndex = -1;
link.setAttribute('role', 'option');
link.setAttribute('aria-selected', 'false');
const body = el('span', 'itflow-search-item-body');
const title = el('span', 'itflow-search-item-title');
if (parts.ref) {
const ref = el('span', 'itflow-search-ref');
ref.append(highlighted(parts.ref, query));
title.append(ref);
}
title.append(highlighted(parts.title, query));
body.append(title);
if (parts.subtitle) {
const sub = el('span', 'itflow-search-item-sub');
sub.append(highlighted(parts.subtitle, query));
body.append(sub);
}
link.append(icon(iconName, 'itflow-search-item-icon'), body);
const status = badge(parts.badge);
if (status || parts.context) {
const aside = el('span', 'itflow-search-item-aside');
if (status) {
aside.append(status);
}
if (parts.context) {
aside.append(el('span', 'itflow-search-item-context', parts.context));
}
link.append(aside);
}
return link;
}
function heading(label, iconName) {
const node = el('div', 'itflow-search-group');
node.setAttribute('role', 'presentation');
node.append(icon(iconName), document.createTextNode(label));
return node;
}
/* ---------- the window ---------- */
function build() {
if (modalEl) {
return;
}
// Static skeleton only - no data goes through this string.
// No .fade: a search box should be there the instant the key is pressed.
modalEl = el('div', 'modal itflow-search');
modalEl.id = 'itflowSearch';
modalEl.tabIndex = -1;
modalEl.setAttribute('aria-label', 'Search');
modalEl.innerHTML =
'<div class="modal-dialog modal-lg modal-fullscreen-sm-down">' +
'<div class="modal-content">' +
'<div class="itflow-search-bar">' +
'<i class="fas fa-search itflow-search-icon" aria-hidden="true"></i>' +
'<input type="text" class="itflow-search-input" autofocus autocomplete="off" spellcheck="false"' +
' maxlength="100" role="combobox" aria-expanded="true" aria-autocomplete="list"' +
' aria-controls="itflowSearchResults" aria-label="Search everything"' +
' placeholder="Search clients, tickets, assets, credentials...">' +
'<span class="spinner-border spinner-border-sm itflow-search-spinner" hidden aria-hidden="true"></span>' +
'<kbd class="d-none d-sm-inline">Esc</kbd>' +
'<button type="button" class="btn btn-link btn-sm d-sm-none" data-bs-dismiss="modal">Cancel</button>' +
'</div>' +
'<div class="itflow-search-results" id="itflowSearchResults" role="listbox" aria-label="Search results"></div>' +
'<div class="itflow-search-footer d-none d-sm-flex">' +
'<span><kbd>&uarr;</kbd> <kbd>&darr;</kbd> to move</span>' +
'<span><kbd>&crarr;</kbd> to open</span>' +
'<span><kbd class="itflow-search-mod"></kbd> + <kbd>&crarr;</kbd> new tab</span>' +
'<span class="ms-auto"><kbd>/</kbd> or <kbd class="itflow-search-mod"></kbd> + <kbd>K</kbd> from any page</span>' +
'</div>' +
'<div class="visually-hidden" aria-live="polite"></div>' +
'</div>' +
'</div>';
modalEl.querySelectorAll('.itflow-search-mod').forEach(function (k) {
k.textContent = MOD_KEY;
});
document.body.appendChild(modalEl);
input = modalEl.querySelector('.itflow-search-input');
list = modalEl.querySelector('.itflow-search-results');
spinner = modalEl.querySelector('.itflow-search-spinner');
announcer = modalEl.querySelector('[aria-live]');
modal = new bootstrap.Modal(modalEl);
try {
lastQuery = sessionStorage.getItem('itflowSearchLast') || '';
} catch (e) {
lastQuery = '';
}
// Bootstrap's focus trap parks focus on the dialog once it is shown;
// the cursor belongs in the box
modalEl.addEventListener('shown.bs.modal', function () {
input.focus();
});
modalEl.addEventListener('hidden.bs.modal', function () {
// Chrome can leave focus on the now-hidden box for a moment, and the
// next / would count as typing in it. Hand focus back to where it was -
// except the top bar box, which would just open the palette again.
input.blur();
if (returnFocus && returnFocus !== navbarBox && document.contains(returnFocus) && typeof returnFocus.focus === 'function') {
returnFocus.focus({ preventScroll: true });
}
returnFocus = null;
lastQuery = input.value;
// Kept for the tab, so the next page reopens on the same search
try {
sessionStorage.setItem('itflowSearchLast', lastQuery);
} catch (e) {
// storage blocked - the search just starts empty next page
}
clearTimeout(debounceTimer);
if (controller) {
controller.abort();
}
pendingOpen = null;
setBusy(false);
});
input.addEventListener('input', function () {
pendingOpen = null;
schedule(input.value, false);
});
input.addEventListener('keydown', onInputKeydown);
// Follow the pointer, but only when it actually moves - a list scrolling
// under a still mouse must not steal the keyboard's place
list.addEventListener('mousemove', function (e) {
const item = e.target.closest('.itflow-search-item');
if (item) {
const index = options.indexOf(item);
if (index !== -1 && index !== active) {
setActive(index, false);
}
}
});
}
function isOpen() {
return !!modalEl && modalEl.classList.contains('show');
}
function open(text) {
build();
const value = typeof text === 'string' ? text : lastQuery;
input.value = value;
if (!isOpen()) {
returnFocus = document.activeElement;
modal.show();
}
input.focus();
input.select();
if (value.trim() !== renderedQuery) {
schedule(value, true);
}
}
function go(url, newTab) {
if (url === '#') {
return;
}
if (newTab) {
window.open(url, '_blank', 'noopener');
} else {
window.location.href = url;
}
}
/* ---------- keyboard ---------- */
function setActive(index, scroll) {
if (active !== -1 && options[active]) {
options[active].classList.remove('active');
options[active].setAttribute('aria-selected', 'false');
}
active = options.length ? (index + options.length) % options.length : -1;
if (active === -1) {
input.removeAttribute('aria-activedescendant');
return;
}
const current = options[active];
current.classList.add('active');
current.setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', current.id);
if (scroll) {
current.scrollIntoView({ block: 'nearest' });
}
}
function onInputKeydown(e) {
if (e.isComposing) {
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
setActive(active + (e.key === 'ArrowDown' ? 1 : -1), true);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const newTab = e.ctrlKey || e.metaKey;
// Typed "TCK-1042" and hit Enter straight away: the results for that
// text are not back yet, so open the top one as soon as they are
if (input.value.trim() !== renderedQuery) {
pendingOpen = { newTab: newTab };
schedule(input.value, true);
return;
}
if (active !== -1 && options[active]) {
go(options[active].getAttribute('href'), newTab);
}
}
}
function isTyping(target) {
if (!target || !(target instanceof Element) || (modalEl && modalEl.contains(target))) {
return false;
}
if (target.isContentEditable || target.closest('.tox, [contenteditable="true"], [contenteditable=""]')) {
return true;
}
if (target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
return true;
}
if (target.tagName === 'INPUT') {
return ['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file'].indexOf(target.type) === -1;
}
return false;
}
function anotherModalOpen() {
return !!document.querySelector('.modal.show:not(#itflowSearch)');
}
document.addEventListener('keydown', function (e) {
if (e.defaultPrevented) {
return;
}
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) {
if (isOpen()) {
e.preventDefault();
input.focus();
input.select();
} else if (!anotherModalOpen()) {
e.preventDefault();
open();
}
return;
}
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !isOpen() && !anotherModalOpen() && !isTyping(e.target)) {
e.preventDefault();
open();
}
});
/* ---------- fetching ---------- */
function setBusy(busy) {
clearTimeout(spinnerTimer);
if (!spinner) {
return;
}
if (busy) {
// Only for a slow answer; a spinner that flashes for 20ms is noise
spinnerTimer = setTimeout(function () {
spinner.hidden = false;
}, 150);
} else {
spinner.hidden = true;
}
}
function schedule(text, immediate) {
clearTimeout(debounceTimer);
if (immediate) {
run(text);
} else {
debounceTimer = setTimeout(function () {
run(text);
}, DEBOUNCE_MS);
}
}
function run(text) {
const query = text.trim();
// One character matches half the database; show the starting view until there is a second
const key = query.length < MIN_LENGTH ? '' : query;
if (cache.has(key)) {
requestSeq++;
if (controller) {
controller.abort();
}
setBusy(false);
render(query, key, cache.get(key));
return;
}
if (controller) {
controller.abort();
}
controller = new AbortController();
const mine = ++requestSeq;
setBusy(true);
fetch(ENDPOINT + '?global_search=1&q=' + encodeURIComponent(key), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
signal: controller.signal
})
.then(function (res) {
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
// An expired session is redirected to the login page, which is HTML
if ((res.headers.get('Content-Type') || '').indexOf('application/json') === -1) {
throw new Error('session');
}
return res.json();
})
.then(function (data) {
if (mine !== requestSeq) {
return;
}
remember(key, data);
render(query, key, data);
})
.catch(function (err) {
if (err.name === 'AbortError' || mine !== requestSeq) {
return;
}
renderMessage(err.message === 'session'
? 'Your session has ended. Reload the page to sign in again.'
: 'Search is not responding. Try again in a moment.', 'exclamation-triangle', query);
})
.finally(function () {
if (mine === requestSeq) {
setBusy(false);
}
});
}
function remember(key, data) {
if (cache.size >= CACHE_SIZE) {
cache.delete(cache.keys().next().value);
}
cache.set(key, data);
}
/* ---------- rendering ---------- */
function finish(query, count) {
options = Array.prototype.slice.call(list.querySelectorAll('.itflow-search-item'));
active = -1;
setActive(0, false);
list.scrollTop = 0;
renderedQuery = query;
if (announcer) {
announcer.textContent = query ? (count ? count + ' results' : 'No results') : '';
}
if (pendingOpen && options.length) {
const newTab = pendingOpen.newTab;
pendingOpen = null;
go(options[0].getAttribute('href'), newTab);
}
pendingOpen = null;
}
function searchEverything(query, hasResults) {
return option(resultsPageUrl(query), 'search', '', {
title: 'Search everything for "' + query + '"',
subtitle: hasResults
? 'See every match, including ticket conversations and document text'
: 'Also searches ticket conversations and document text'
}, 'itflow-search-all');
}
function renderMessage(text, iconName, query) {
list.replaceChildren();
const message = el('div', 'itflow-search-empty');
message.append(icon(iconName), el('div', null, text));
list.append(message);
if (query && query.length >= MIN_LENGTH) {
list.append(searchEverything(query, false));
}
finish(query, 0);
}
function render(query, key, data) {
list.replaceChildren();
let count = 0;
// Nothing typed yet (or a single character): where you have been lately
if (key === '') {
const recent = (data && data.recent) || [];
if (recent.length) {
list.append(heading('Recent clients', 'history'));
recent.forEach(function (item) {
list.append(option(item.url, 'user-friends', '', item));
});
}
const hint = el('div', 'itflow-search-hint',
'Type a name, email, phone number, IP, serial, or a ticket, invoice or quote number.');
list.append(hint);
finish('', recent.length);
renderedQuery = query;
return;
}
const pages = (data && data.pages) || [];
if (pages.length) {
list.append(heading('Go to', 'compass'));
pages.forEach(function (page) {
list.append(option(page.url, 'arrow-right', key, page));
});
}
((data && data.groups) || []).forEach(function (group) {
list.append(heading(group.label, group.icon));
group.items.forEach(function (item) {
list.append(option(item.url, group.icon, key, item));
count++;
});
if (group.more) {
list.append(option(resultsPageUrl(key, group.key), 'ellipsis-h', '', {
title: 'See all matching ' + group.label.toLowerCase()
}, 'itflow-search-more'));
}
});
if (!count && !pages.length) {
const message = el('div', 'itflow-search-empty');
message.append(icon('search'), el('div', null, 'No quick matches for "' + key + '".'));
list.append(message);
}
list.append(searchEverything(key, count > 0));
finish(key, count);
}
/* ---------- the top bar box ---------- */
// The box in the top bar becomes the way in: focusing it opens the window
// with whatever it holds (the current query, on the results page). The form
// still submits to the results page if this script never loads.
function bindNavbar() {
const box = document.getElementById('navbar-search-input');
if (!box) {
return;
}
navbarBox = box;
const form = box.form;
box.addEventListener('mousedown', function (e) {
e.preventDefault();
open(box.value || undefined);
});
box.addEventListener('focus', function () {
box.blur();
open(box.value || undefined);
});
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
open(box.value || undefined);
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindNavbar);
} else {
bindNavbar();
}
window.itflowGlobalSearch = { open: open };
})();