/*
* ITFlow - the search palette.
*
* Press Ctrl+K (Cmd+K on a Mac) 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 AVATAR_TONES = ['primary', 'success', 'info', 'warning', 'secondary'];
const FILE_TONES = ['pdf', 'word', 'excel', 'slides', 'image', 'media', 'archive', 'text', 'code', 'drawing', 'cert', 'other', 'document'];
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;
}
// A contact's picture, or their initials on their colour - contactAvatar() in
// functions/contact.php draws the same thing server side
function avatar(spec) {
if (!spec) {
return null;
}
let node;
if (spec.image) {
node = el('img', 'itflow-contact-avatar itflow-contact-avatar-xs');
node.src = safeUrl(spec.image);
node.alt = '';
} else if (spec.initials) {
const tone = AVATAR_TONES.indexOf(spec.tone) !== -1 ? spec.tone : 'secondary';
node = el('span', 'itflow-contact-avatar itflow-contact-avatar-xs bg-' + tone + '-subtle text-' + tone + '-emphasis', spec.initials);
} else {
return null;
}
node.setAttribute('aria-hidden', 'true');
return node;
}
// A file's type tile, or a document's - fileTypeTile() draws the same server side
function tile(spec) {
if (!spec || FILE_TONES.indexOf(spec.tone) === -1 || !/^[a-z0-9-]+$/.test(spec.icon || '')) {
return null;
}
const node = el('span', 'itflow-file-icon itflow-file-icon-xs itflow-file-tone-' + spec.tone);
node.title = spec.label || '';
node.setAttribute('aria-hidden', 'true');
node.appendChild(el('i', 'fas fa-fw fa-' + spec.icon));
return node;
}
// A tag as a pill - colours and icon checked, the name as text only
function tagNode(spec, className) {
if (!spec || !spec.name) {
return null;
}
const node = el('span', className);
if (/^#[0-9a-f]{6}$/i.test(spec.color || '')) {
node.style.setProperty('--itflow-tag-color', spec.color);
}
if (/^#[0-9a-f]{3,6}$/i.test(spec.text || '')) {
node.style.setProperty('--itflow-tag-text', spec.text);
}
node.appendChild(icon(spec.icon));
return node;
}
function tagPills(specs) {
if (!Array.isArray(specs) || !specs.length) {
return null;
}
const wrap = el('span', 'itflow-search-item-tags');
specs.forEach(function (spec) {
const pill = tagNode(spec, 'itflow-tag');
if (pill) {
pill.appendChild(document.createTextNode(spec.name));
wrap.appendChild(pill);
}
});
return wrap;
}
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');
// Found by a tag - the tags that matched, kept in view after a long name
const pills = tagPills(parts.tags);
const text = pills ? el('span', 'itflow-search-item-text') : title;
if (parts.ref) {
const ref = el('span', 'itflow-search-ref');
ref.append(highlighted(parts.ref, query));
text.append(ref);
}
text.append(highlighted(parts.title, query));
if (pills) {
title.classList.add('has-tags');
title.append(text, pills);
}
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(avatar(parts.avatar) || tile(parts.tile) || tagNode(parts.tag, 'itflow-search-tag-icon') || 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 =
'
' +
'
' +
'
' +
'' +
'' +
'' +
'Esc' +
'' +
'
' +
'' +
'' +
'' +
'
' +
'
';
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 () {
// Hand focus back to where it was rather than leave it on the now-hidden
// box - 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 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();
}
}
});
/* ---------- 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));
});
}
// Admins: the settings that match, each saying where it is
const settings = (data && data.settings) || [];
if (settings.length) {
list.append(heading('Settings', 'sliders-h'));
settings.forEach(function (setting) {
list.append(option(setting.url, 'sliders-h', key, setting));
});
}
((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 && !settings.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;
// The shortcut hint in the box: the markup says Ctrl K, a Mac gets Cmd
const hint = form ? form.querySelector('.navbar-search-kbd') : null;
if (hint) {
hint.textContent = MOD_KEY + ' K';
}
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 };
})();