mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
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:
@@ -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
|
||||
|
||||
@@ -1365,7 +1365,6 @@ if (isset($_GET['asset_id'])) {
|
||||
syncAuthForm(select);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Include scripts to fetch TOTP codes and passwords via the credential ID -->
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
602
functions/search.php
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
640
js/global_search.js
Normal 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>↑</kbd> <kbd>↓</kbd> to move</span>' +
|
||||
'<span><kbd>↵</kbd> to open</span>' +
|
||||
'<span><kbd class="itflow-search-mod"></kbd> + <kbd>↵</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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user