/* * 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 = ''; 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 }; })();