Files
itflow/agent/js/canned_response_picker.js

429 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* The canned response picker (agent/includes/inc_canned_response_picker.php),
* on a ticket's reply and in Update & Reply.
*
* - The list searches as you type (by name and what it says); Enter picks the
* first one shown, the down arrow goes into the list.
* - Picking one fetches it filled in for the ticket (agent/ajax.php
* get_canned_response), puts it in the reply where the cursor is, notes it on
* the form (canned_response_ids[], for how often each is used) and sets up
* what it sets up - who sees the reply, the status after it and time worked
* added - saying under the picker what it did. The files that go with it
* show under the picker, each with an x to leave it off; the ones left go
* with the reply (canned_response_file_ids[]). Typing / in the reply box
* picks through window.itflowCannedPick (js/app.js).
* - Save your reply as a canned response, at the foot of the list, closes the list as its
* window opens (agent/js/canned_response_save.js).
* - Cancel closes the list and puts you back in the reply. A click into the
* reply closes it too (itflowDismissPopups(), js/itflow_select.js).
* - Sending a reply a response went into, with [something] in square brackets
* still in it, asks first - Fill them in selects the first one in the reply.
* In Update & Reply the placeholders are filled in for each ticket as it is
* sent, so only the others are asked about.
*
* Update & Reply is an ajax window that runs this each time it opens, so the
* listeners are bound once.
*/
(function () {
if (window.itflowCannedPickerBound) {
return;
}
window.itflowCannedPickerBound = true;
// A word in square brackets - TICKET_PLACEHOLDER_NAME, with the editor's non-breaking spaces
const BRACKETS = /\[([A-Za-z][A-Za-z0-9  _\-\/]{0,40})\]/g;
function itemsOf(picker) {
return Array.from(picker.querySelectorAll('[data-canned-id]'));
}
function filter(picker, value) {
const words = value.trim().toLowerCase().split(/\s+/).filter(Boolean);
let shown = 0;
picker.querySelectorAll('[data-canned-group]').forEach(function (group) {
let inGroup = 0;
group.querySelectorAll('[data-canned-id]').forEach(function (item) {
const match = words.every(function (word) {
return item.dataset.cannedSearchText.indexOf(word) !== -1;
});
item.hidden = !match;
inGroup += match ? 1 : 0;
});
group.hidden = inGroup === 0;
shown += inGroup;
});
const none = picker.querySelector('[data-canned-none]');
if (none) {
// With none on offer at all the list says so already
none.hidden = shown > 0 || itemsOf(picker).length === 0;
}
}
function say(picker, text, error) {
const line = picker.querySelector('[data-canned-applied]');
if (!line) {
return;
}
line.textContent = text;
line.classList.toggle('text-danger', !!error);
line.classList.toggle('text-body-secondary', !error);
line.hidden = false;
}
function insert(editorId, html) {
const editor = window.tinymce ? tinymce.get(editorId) : null;
if (editor) {
editor.insertContent(html);
editor.focus();
return;
}
const textarea = document.getElementById(editorId);
if (textarea) {
textarea.value += html;
}
}
function setSelect(select, value) {
if (select.itflowSelect) {
select.itflowSelect.setValue(value);
} else {
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
}
// The files that go with a response, under the picker - each once
function addFiles(picker, files) {
const box = picker.querySelector('[data-canned-files]');
let added = 0;
if (!box || !files || !files.length) {
return added;
}
files.forEach(function (file) {
if (box.querySelector('[data-canned-file-id="' + file.id + '"]')) {
return;
}
const chip = document.createElement('span');
chip.className = 'badge rounded-pill text-bg-light border fw-normal d-inline-flex align-items-center gap-1 itflow-canned-file';
chip.dataset.cannedFileId = file.id;
chip.title = 'Copied onto the ticket, and sent with the reply when it is emailed';
const icon = document.createElement('i');
icon.className = 'fas fa-fw fa-paperclip text-secondary';
const name = document.createElement('span');
name.className = 'text-truncate';
name.textContent = file.name;
const size = document.createElement('span');
size.className = 'text-body-secondary';
size.textContent = file.size;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'btn-close';
remove.title = 'Leave it off';
remove.setAttribute('aria-label', 'Leave ' + file.name + ' off');
remove.setAttribute('data-canned-file-remove', '');
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'canned_response_file_ids[]';
input.value = file.id;
chip.append(icon, name, size, remove, input);
box.appendChild(chip);
added++;
});
box.hidden = !box.querySelector('[data-canned-file-id]');
return added;
}
function pad(n) {
return (n < 10 ? '0' : '') + n;
}
// Set up on the form what the response sets up - what was done, in words
function apply(form, data) {
const done = [];
if (!form) {
return done;
}
// Who sees it: the ticket's reply types, or Update & Reply's internal box
if (data.reply_type !== null) {
const radios = form.querySelectorAll('input[name="public_reply_type"]');
const internal = form.querySelector('input[type="checkbox"][name="bulk_private_reply"]');
if (radios.length) {
let radio = form.querySelector('input[name="public_reply_type"][value="' + data.reply_type + '"]');
let label = data.reply_type_name;
if (!radio && data.reply_type === 2) {
// No email address to send to - a public note is the nearest
radio = form.querySelector('input[name="public_reply_type"][value="1"]');
label = 'Public note, as there is nobody to email';
}
if (radio) {
if (!radio.checked) {
radio.checked = true;
radio.dispatchEvent(new Event('change', { bubbles: true }));
}
done.push('set the reply to ' + label);
}
} else if (internal) {
internal.checked = data.reply_type === 0;
done.push(data.reply_type === 0 ? 'marked it internal' : 'made it a public reply');
}
}
// The status after it
if (data.ticket_status !== null) {
const select = form.querySelector('select[name="status"], select[name="bulk_status"]');
if (select && select.querySelector('option[value="' + data.ticket_status + '"]')) {
setSelect(select, String(data.ticket_status));
done.push('set the status to ' + data.status_name);
} else if (select) {
done.push('left the status - ' + data.status_name + ' isn\'t on offer here');
}
}
// Time worked, added: the reply's hours, minutes and seconds (the timer
// reads them back on change), or Update & Reply's HH:MM:SS
if (data.minutes > 0) {
const hours = form.querySelector('input[name="hours"]');
const minutes = form.querySelector('input[name="minutes"]');
const seconds = form.querySelector('input[name="seconds"]');
const time = form.querySelector('input[name="time"]');
let total = null;
if (hours && minutes && seconds) {
total = (parseInt(hours.value, 10) || 0) * 3600 + (parseInt(minutes.value, 10) || 0) * 60 + (parseInt(seconds.value, 10) || 0) + data.minutes * 60;
hours.value = pad(Math.floor(total / 3600));
minutes.value = pad(Math.floor(total % 3600 / 60));
seconds.value = pad(total % 60);
minutes.dispatchEvent(new Event('change', { bubbles: true }));
} else if (time) {
const parts = time.value.split(':').map(function (part) {
return parseInt(part, 10) || 0;
});
total = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0) + data.minutes * 60;
time.value = pad(Math.min(23, Math.floor(total / 3600))) + ':' + pad(Math.floor(total % 3600 / 60)) + ':' + pad(total % 60);
}
if (total !== null) {
done.push('added ' + data.minutes_text + ' to the time worked');
}
}
return done;
}
function pick(picker, id) {
const toggle = picker.querySelector('[data-canned-toggle]');
if (toggle && window.bootstrap) {
bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
}
return fetch('/agent/ajax.php?get_canned_response=' + encodeURIComponent(id) + '&ticket_id=' + encodeURIComponent(picker.dataset.ticketId || '0'), { credentials: 'same-origin' })
.then(function (response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
})
.then(function (data) {
if (!data.found) {
say(picker, 'That canned response is gone - it may have been archived or deleted.', true);
return;
}
const form = picker.closest('form');
if (form && !form.querySelector('input[name="canned_response_ids[]"][value="' + data.id + '"]')) {
const used = document.createElement('input');
used.type = 'hidden';
used.name = 'canned_response_ids[]';
used.value = data.id;
form.appendChild(used);
}
insert(picker.dataset.editor, data.body);
const done = apply(form, data);
const files = addFiles(picker, data.files);
if (files) {
done.push('put ' + (files === 1 ? 'a file' : files + ' files') + ' with it');
}
say(picker, data.name + ' went in' + (done.length ? ', and ' + done.join(', ') : '') + '.');
})
.catch(function () {
say(picker, 'Could not load that canned response.', true);
});
}
// For the / menu in the reply box (js/app.js)
window.itflowCannedPick = function (pickerId, id) {
const picker = document.getElementById(pickerId);
if (picker) {
pick(picker, id);
}
};
// Search as you type
document.addEventListener('input', function (e) {
const search = e.target.closest('[data-canned-search]');
if (search) {
filter(search.closest('[data-canned-picker]'), search.value);
}
});
// Opened: an empty search, ready to type into
document.addEventListener('shown.bs.dropdown', function (e) {
const picker = e.target.closest ? e.target.closest('[data-canned-picker]') : null;
if (!picker) {
return;
}
const search = picker.querySelector('[data-canned-search]');
search.value = '';
filter(picker, '');
search.focus();
});
// Enter picks the first one shown; the down arrow goes into the list
document.addEventListener('keydown', function (e) {
const search = e.target.closest ? e.target.closest('[data-canned-search]') : null;
if (!search) {
return;
}
const picker = search.closest('[data-canned-picker]');
const first = itemsOf(picker).find(function (item) {
return !item.hidden;
});
if (e.key === 'Enter') {
e.preventDefault();
if (first) {
pick(picker, first.dataset.cannedId);
}
} else if (e.key === 'ArrowDown' && first) {
e.preventDefault();
first.focus();
}
});
// A click on one picks it; a file's x leaves it off; Cancel closes the list; Save your reply as a canned response closes the list as its window opens
document.addEventListener('click', function (e) {
const item = e.target.closest('[data-canned-picker] [data-canned-id]');
if (item) {
e.preventDefault();
pick(item.closest('[data-canned-picker]'), item.dataset.cannedId);
return;
}
const remove = e.target.closest('[data-canned-picker] [data-canned-file-remove]');
if (remove) {
const box = remove.closest('[data-canned-files]');
remove.closest('[data-canned-file-id]').remove();
box.hidden = !box.querySelector('[data-canned-file-id]');
return;
}
const cancel = e.target.closest('[data-canned-picker] [data-canned-close]');
if (cancel) {
const picker = cancel.closest('[data-canned-picker]');
const toggle = picker.querySelector('[data-canned-toggle]');
if (toggle && window.bootstrap) {
bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
}
const editor = window.tinymce ? tinymce.get(picker.dataset.editor) : null;
const textarea = document.getElementById(picker.dataset.editor);
if (editor) {
editor.focus();
} else if (textarea) {
textarea.focus();
}
return;
}
const save = e.target.closest('[data-canned-picker] [data-canned-save-open]');
if (save && window.bootstrap) {
const toggle = save.closest('[data-canned-picker]').querySelector('[data-canned-toggle]');
if (toggle) {
bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
}
}
});
// The first [word] left in the reply, selected for typing over
function selectBracket(editor, name) {
const pattern = new RegExp('\\[' + name.split(/[  ]+/).map(function (word) {
return word.replace(/[.*+?^${}()|[\]\\\/-]/g, '\\$&');
}).join('[ \\u00a0]+') + '\\]');
const walker = editor.getDoc().createTreeWalker(editor.getBody(), NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const match = pattern.exec(node.nodeValue);
if (match) {
const range = editor.dom.createRng();
range.setStart(node, match.index);
range.setEnd(node, match.index + match[0].length);
editor.selection.setRng(range);
editor.selection.scrollIntoView();
break;
}
}
editor.focus();
}
// Sending a reply a response went into with [words] still in it asks first
document.addEventListener('submit', function (e) {
const form = e.target;
const picker = form.querySelector ? form.querySelector('[data-canned-picker]') : null;
if (!picker || !form.querySelector('input[name="canned_response_ids[]"]') || typeof Swal === 'undefined') {
return;
}
if (form.dataset.cannedSendAnyway === '1') {
delete form.dataset.cannedSendAnyway;
return;
}
const editor = window.tinymce ? tinymce.get(picker.dataset.editor) : null;
const textarea = document.getElementById(picker.dataset.editor);
const text = editor ? editor.getContent({ format: 'text' }) : (textarea ? textarea.value : '');
// In Update & Reply these are filled in for each ticket as it is sent
const filledLater = (picker.dataset.ticketId === '0' ? (picker.dataset.cannedFilled || '').split('|') : []).map(function (name) {
return name.toLowerCase();
});
const left = [];
let match;
BRACKETS.lastIndex = 0;
while ((match = BRACKETS.exec(text)) !== null) {
const name = match[1].replace(/[  ]+/g, ' ').trim();
if (filledLater.indexOf(name.toLowerCase()) === -1 && left.indexOf(name) === -1) {
left.push(name);
}
}
if (!left.length) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
const submitter = e.submitter || null;
const list = document.createElement('div');
list.textContent = 'Your reply still has ' + left.map(function (name) {
return '[' + name + ']';
}).join(', ') + ' in it.';
Swal.fire({
theme: document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light',
title: 'Still to fill in',
html: list,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Send anyway',
cancelButtonText: 'Fill them in',
reverseButtons: true,
focusCancel: true,
// Back to the reply box, not the Send button
returnFocus: false,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-primary mx-1',
cancelButton: 'btn btn-secondary mx-1'
}
}).then(function (result) {
if (result.isConfirmed) {
form.dataset.cannedSendAnyway = '1';
if (form.requestSubmit) {
form.requestSubmit(submitter);
} else {
form.submit();
}
} else if (editor) {
setTimeout(function () {
selectBracket(editor, left[0]);
}, 0);
}
});
}, true);
})();