/* * Quick pickers - a ticket's status, priority, assignee and billable, a * project's manager, a recurring ticket's assignee, an asset's status, * location and contact, and a contact's location, changed where you see them * (ticket list and page, projects and project page, recurring tickets, the * assets and contacts lists and their pages and quick looks) instead of in a modal. * * Click the value and a small menu opens under it (a sheet from the bottom * on a phone): statuses and priorities with their colours, agents and * contacts with their pictures (contacts' initials on their own colour, as * contactAvatar() draws them) and a search box, or Billable / Not billable. * Pick one and it saves straight * away through agent/ajax.php, which runs the same code as the old modals * (functions/ticket_fields.php - history, audit log, SLA, notifications) and * sends back the redrawn value. Assigning a New ticket opens it, so the * ticket's status redraws too, wherever it is marked with * data-ticket-status-for="". * * Where the choices depend on the client (an asset's location and contact) * the button carries data-quick-scope="", and the choices are * fetched and kept per client. Anything else on the page that shows the * value comes back as "related" HTML for [data-quick-related=""] - the * asset page's contact details under its Assigned picker. * * Cancel, at the top, Escape or a click anywhere else closes it without * changing anything - a click into a reply box too (itflowDismissPopups(), * js/itflow_select.js). * * Markup comes from quickPick() in functions/ticket_fields.php. Styled * with the select picker's panel classes, like the tag picker. */ (function () { 'use strict'; if (window.itflowQuickPick) { return; // re-run by an ajax modal - the listeners are already in place } var sheetMedia = window.matchMedia('(max-width: 575.98px)'); var finePointer = window.matchMedia('(pointer: fine)'); var titles = { priority: 'Priority', assignee: 'Assign to', billable: 'Billing', status: 'Status', project_manager: 'Project manager', recurring_assignee: 'Assign to', asset_status: 'Status', asset_location: 'Location', asset_contact: 'Assign to', contact_location: 'Location' }; // People and places get a wider menu var wideFields = ['assignee', 'project_manager', 'recurring_assignee', 'asset_location', 'asset_contact', 'contact_location']; var cache = {}; var open = null; var tones = ['primary', 'success', 'info', 'warning', 'secondary']; function make(tag, className, text) { var node = document.createElement(tag); if (className) { node.className = className; } if (text) { node.textContent = text; } return node; } function fold(text) { return String(text || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); } function lead(option) { var node; if (option.image) { node = make('img', 'itflow-select-avatar'); node.src = option.image; node.alt = ''; } else if (option.initials) { // A contact carries their own colour, on its soft shade like their avatar node = make('span', 'itflow-select-avatar ' + (tones.indexOf(option.tone) !== -1 ? 'bg-' + option.tone + '-subtle text-' + option.tone + '-emphasis' : 'text-bg-secondary'), option.initials); } else if (option.color) { node = make('span', 'itflow-select-dot'); node.style.setProperty('--itflow-select-color', option.color); } else if (option.icon) { node = make('span', 'itflow-select-icon'); node.appendChild(make('i', option.icon)); } else { return null; } node.setAttribute('aria-hidden', 'true'); return node; } function options(field, scope, done, failed) { var key = field + '|' + scope; if (cache[key]) { done(cache[key]); return; } var query = { quick_pick_options: 1, field: field }; if (scope) { query.scope = scope; } itflowGet('ajax.php', query, function (text) { var list; try { list = JSON.parse(text).options; } catch (e) { list = null; } if (!Array.isArray(list)) { failed(); return; } cache[key] = list; done(list); }, failed); } function Picker(button) { this.button = button; this.field = button.dataset.quickPick; this.ids = [button.dataset.quickId]; this.current = button.dataset.quickCurrent; this.scope = button.dataset.quickScope || ''; this.csrf = button.dataset.csrf || ''; this.query = ''; this.rows = []; this.active = -1; } Picker.prototype.show = function () { var self = this; open = this; var panel = this.panel = make('div', 'itflow-select-panel itflow-quick-panel'); panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', titles[this.field] || 'Change'); var head = make('div', 'itflow-select-sheet-head'); head.appendChild(make('span', 'itflow-select-sheet-title', titles[this.field] || 'Change')); var done = make('button', 'btn btn-sm btn-link itflow-select-done', 'Cancel'); done.type = 'button'; done.title = 'Close without changing it (Esc)'; done.addEventListener('click', function () { self.close(true); }); head.appendChild(done); panel.appendChild(head); var search = this.searchRow = make('div', 'itflow-select-search'); search.hidden = true; search.appendChild(make('i', 'fas fa-search')); var input = this.input = make('input'); input.type = 'text'; input.autocomplete = 'off'; input.spellcheck = false; input.placeholder = 'Search'; input.setAttribute('aria-label', 'Search'); input.addEventListener('input', function () { self.query = input.value; self.draw(); }); input.addEventListener('keydown', function (e) { self.key(e); }); search.appendChild(input); panel.appendChild(search); var list = this.list = make('div', 'itflow-select-list'); list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', titles[this.field] || 'Choices'); list.appendChild(make('div', 'itflow-select-empty', 'Loading...')); list.addEventListener('click', function (e) { var node = e.target.closest('[data-row]'); if (node) { self.pick(parseInt(node.getAttribute('data-row'), 10)); } }); list.addEventListener('mousemove', function (e) { var node = e.target.closest('[data-row]'); if (node) { self.setActive(parseInt(node.getAttribute('data-row'), 10), false); } }); panel.appendChild(list); panel.addEventListener('mousedown', function (e) { if (e.target !== input && !e.target.closest('a, button')) { e.preventDefault(); } }); // With no search box, the keys work on the button that opened it this.onButtonKey = function (e) { self.key(e); }; this.button.addEventListener('keydown', this.onButtonKey); this.sheet = sheetMedia.matches; panel.classList.toggle('is-sheet', this.sheet); this.backdrop = make('div', 'itflow-select-backdrop'); var host = this.button.closest('.modal, .offcanvas') || document.body; if (this.sheet) { host.appendChild(this.backdrop); } host.appendChild(panel); this.button.setAttribute('aria-expanded', 'true'); this.place(); options(this.field, this.scope, function (list) { if (open !== self) { return; } self.options = list; self.searchRow.hidden = list.length < 8; self.draw(); if (!self.searchRow.hidden && finePointer.matches) { self.input.focus({ preventScroll: true }); } }, function () { self.fail('Could not load the choices.'); self.close(false); }); }; Picker.prototype.close = function (refocus) { if (open !== this) { return; } open = null; this.panel.remove(); this.backdrop.remove(); this.button.removeEventListener('keydown', this.onButtonKey); this.button.setAttribute('aria-expanded', 'false'); if (refocus && this.button.isConnected) { this.button.focus({ preventScroll: true }); } }; Picker.prototype.fail = function (message) { if (typeof itflowToast === 'function') { itflowToast(message, 'error'); } }; Picker.prototype.place = function () { if (this.sheet || open !== this) { return; } if (!this.button.isConnected) { this.close(false); return; } var rect = this.button.getBoundingClientRect(); var viewWidth = document.documentElement.clientWidth; var viewHeight = window.innerHeight; var width = Math.min(wideFields.indexOf(this.field) !== -1 ? 264 : 224, viewWidth - 16); var panel = this.panel; panel.style.width = width + 'px'; panel.style.left = Math.min(Math.max(8, rect.left), viewWidth - width - 8) + 'px'; this.list.style.maxHeight = ''; var height = panel.offsetHeight; var below = viewHeight - rect.bottom - 12; var above = rect.top - 12; var up = height > below && above > below; var room = up ? above : below; if (height > room) { this.list.style.maxHeight = Math.max(96, this.list.offsetHeight - (height - room)) + 'px'; height = panel.offsetHeight; } panel.style.top = (up ? rect.top - 4 - height : rect.bottom + 4) + 'px'; panel.classList.toggle('is-above', up); }; Picker.prototype.draw = function () { var self = this; var terms = fold(this.query.trim()).split(/\s+/).filter(Boolean); var list = this.list; list.textContent = ''; this.rows = []; this.options.forEach(function (option) { var label = fold(option.label); if (terms.length && !terms.every(function (t) { return label.indexOf(t) !== -1; })) { return; } var selected = String(option.value) === String(self.current); var index = self.rows.length; var node = make('div', 'itflow-select-option' + (selected ? ' is-selected' : '') + (option.none ? ' is-none' : '')); node.id = 'itflow-quick-option-' + index; node.setAttribute('role', 'option'); node.setAttribute('data-row', index); node.setAttribute('aria-selected', selected ? 'true' : 'false'); // What it is for, on hover - a ticket status's description if (option.title) { node.title = option.title; } var visual = lead(option); if (visual) { node.appendChild(visual); } var text = make('span', 'itflow-select-text'); text.appendChild(make('span', 'itflow-select-label', option.label)); if (option.description) { text.appendChild(make('span', 'itflow-select-description', option.description)); } node.appendChild(text); node.appendChild(make('i', 'fas fa-check itflow-select-tick')); list.appendChild(node); self.rows.push({ node: node, option: option }); }); if (!this.rows.length) { list.appendChild(make('div', 'itflow-select-empty', 'No matches')); } var active = 0; this.rows.forEach(function (row, i) { if (!terms.length && String(row.option.value) === String(self.current)) { active = i; } }); this.setActive(this.rows.length ? active : -1, true); this.place(); }; Picker.prototype.setActive = function (index, scroll) { var old = this.rows[this.active]; if (old) { old.node.classList.remove('is-active'); } this.active = index; var row = this.rows[index]; var owner = this.searchRow.hidden ? this.button : this.input; if (!row) { owner.removeAttribute('aria-activedescendant'); return; } row.node.classList.add('is-active'); owner.setAttribute('aria-activedescendant', row.node.id); if (scroll) { row.node.scrollIntoView({ block: 'nearest' }); } }; Picker.prototype.key = function (e) { switch (e.key) { case 'ArrowDown': e.preventDefault(); this.setActive(Math.min(this.active + 1, this.rows.length - 1), true); break; case 'ArrowUp': e.preventDefault(); this.setActive(Math.max(this.active - 1, 0), true); break; case 'Enter': case ' ': if (e.key === ' ' && e.target === this.input) { return; } e.preventDefault(); this.pick(this.active); break; case 'Escape': // Stop here, or a modal behind the picker closes too e.preventDefault(); e.stopPropagation(); this.close(true); break; case 'Tab': this.close(true); break; } }; /* Save the pick, close, and redraw the value from what the server says. */ Picker.prototype.pick = function (index) { var self = this; var row = this.rows[index]; if (!row) { return; } var value = String(row.option.value); this.close(true); if (value === String(this.current)) { return; } var button = this.button; var field = this.field; button.classList.add('is-saving'); itflowPost('ajax.php', { quick_pick_set: 1, csrf_token: this.csrf, field: field, value: value, ids: this.ids }, function (text) { button.classList.remove('is-saving'); var result; try { result = JSON.parse(text); } catch (e) { result = null; } if (!result || !result.records) { self.fail((result && result.error) || 'That change was not saved - try again.'); return; } Object.keys(result.records).forEach(function (id) { var record = result.records[id]; document.querySelectorAll('[data-quick-pick="' + field + '"][data-quick-id="' + id + '"]').forEach(function (spot) { var style = spot.dataset.quickStyle === 'detail' ? 'detail' : 'list'; spot.dataset.quickCurrent = record.current; var holder = spot.querySelector('.itflow-quick-pick-value'); if (holder) { holder.innerHTML = record.html[style]; } spot.classList.add('is-saved'); setTimeout(function () { spot.classList.remove('is-saved'); }, 900); }); Object.keys(record.related || {}).forEach(function (key) { document.querySelectorAll('[data-quick-related="' + key + '"]').forEach(function (spot) { spot.innerHTML = record.related[key]; }); }); if (record.status_id === undefined) { return; // not a ticket } // The status shows elsewhere too (assigning a New ticket opens it). // Where the status is its own picker, redraw inside the picker document.querySelectorAll('[data-ticket-status-for="' + id + '"]').forEach(function (spot) { var style = spot.dataset.quickStyle === 'detail' ? 'detail' : 'list'; if (spot.dataset.statusId === String(record.status_id)) { return; } spot.dataset.statusId = record.status_id; var statusPick = spot.querySelector('[data-quick-pick="status"]'); if (statusPick) { statusPick.dataset.quickCurrent = record.status_id; statusPick.querySelector('.itflow-quick-pick-value').innerHTML = record.status[style]; } else { spot.innerHTML = record.status[style]; } }); }); // On the ticket page billing decides the Quote / Invoice buttons, and // assigning a New ticket opens it - both show elsewhere on the page, // so load it again rather than leave those out of date var statusChanged = Object.keys(result.records).some(function (id) { return String(result.records[id].status_id) !== String(button.dataset.statusId || result.records[id].status_id); }); if (button.dataset.quickStyle === 'detail' && (field === 'billable' || (field === 'assignee' && statusChanged))) { window.location.reload(); return; } if (typeof itflowToast === 'function' && result.message) { itflowToast(result.message, 'success'); } }, function () { button.classList.remove('is-saving'); self.fail('That change was not saved - try again.'); }); }; /* ---------- wiring ---------- */ document.addEventListener('click', function (e) { var button = e.target.closest('[data-quick-pick]'); if (!button) { return; } e.preventDefault(); e.stopPropagation(); var wasOpen = open && open.button === button; if (open) { open.close(false); } if (!wasOpen) { new Picker(button).show(); } }); // Clicking off the menu closes it - and that click does nothing else var swallow = false; function outside(e) { if (open && !open.panel.contains(e.target) && !e.target.closest('[data-quick-pick]')) { open.close(false); swallow = true; } } document.addEventListener('mousedown', outside, true); document.addEventListener('touchstart', outside, { capture: true, passive: true }); document.addEventListener('click', function (e) { if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); } }, true); document.addEventListener('pointerdown', function (e) { if (!open && !e.target.closest('[data-quick-pick]')) { swallow = false; } }, true); // A click into a reply box, which this page never hears (js/itflow_select.js) document.addEventListener('itflow:dismiss', function () { if (open) { open.close(false); } }); var placing = false; function replace() { if (!open || placing) { return; } placing = true; requestAnimationFrame(function () { placing = false; if (open) { open.place(); } }); } window.addEventListener('resize', replace); document.addEventListener('scroll', function (e) { if (open && open.panel.contains(e.target)) { return; } replace(); }, true); window.itflowQuickPick = true; })();