mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
429 lines
18 KiB
JavaScript
429 lines
18 KiB
JavaScript
/*
|
||
* 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);
|
||
})();
|