Files
itflow/js/app.js

1404 lines
56 KiB
JavaScript

/**
* Delegated listener that binds exactly once.
*
* modal_footer.php re-loads this file every time an ajax modal opens, which is
* why the jQuery originals used a namespaced .off().on() - without it the
* handlers stacked up and fired once per modal ever opened. The named flag on
* window is the vanilla equivalent. `this` is the matched element, matching
* jQuery's delegation contract so the handler bodies are unchanged.
*/
/**
* Run one initialiser in isolation.
*
* itflowInit() sets up eight independent libraries in sequence. Without this,
* a throw in any one of them aborts the whole function and every initialiser
* after it silently never runs - which is exactly the kind of failure that
* looks like "everything is broken" while the console shows one error from a
* library you were not looking at.
*/
function itflowStep(name, fn) {
try {
fn();
} catch (e) {
console.error('itflow init [' + name + '] failed:', e);
}
}
/**
* Typing / in a reply box offers the canned responses of the picker above it
* (agent/includes/inc_canned_response_picker.php) - its textarea names the
* picker in data-canned-picker. The list narrows as you type after the /;
* picking one takes the / and what was typed out again and puts the response
* in, as the picker does (agent/js/canned_response_picker.js). Called from the
* reply editors' setup, so it is there before the editor is drawn.
*/
function itflowCannedAutocompleter(editor) {
var element = editor.getElement ? editor.getElement() : null;
var pickerId = element && element.dataset ? element.dataset.cannedPicker : '';
if (!pickerId) {
return;
}
editor.ui.registry.addAutocompleter('itflowCanned', {
trigger: '/',
minChars: 0,
columns: 1,
highlightOn: ['canned_name'],
fetch: function (pattern) {
var picker = document.getElementById(pickerId);
var words = String(pattern || '').toLowerCase().split(/\s+/).filter(Boolean);
var items = picker ? Array.prototype.slice.call(picker.querySelectorAll('[data-canned-id]')) : [];
var seen = {};
return Promise.resolve(items.filter(function (item) {
if (seen[item.dataset.cannedId]) {
return false;
}
seen[item.dataset.cannedId] = true;
return words.every(function (word) {
return item.dataset.cannedSearchText.indexOf(word) !== -1;
});
}).slice(0, 8).map(function (item) {
return {
type: 'cardmenuitem',
value: item.dataset.cannedId,
label: item.dataset.cannedName,
items: [{
type: 'cardcontainer',
direction: 'vertical',
items: [
{ type: 'cardtext', text: item.dataset.cannedName, name: 'canned_name', classes: ['itflow-canned-ac-name'] },
{ type: 'cardtext', text: (item.dataset.cannedPreview || '').slice(0, 90), classes: ['itflow-canned-ac-preview'] }
]
}]
};
}));
},
onAction: function (api, range, value) {
api.hide();
editor.selection.setRng(range);
editor.insertContent('');
if (window.itflowCannedPick) {
window.itflowCannedPick(pickerId, value);
}
}
});
}
/**
* @ in an internal note offers the agents who can open the ticket - its
* textarea carries them in data-mention-agents (ticketMentionAgentsAttr(),
* functions/ticket_mention.php). The list narrows as you type a name or role
* after the @; picking one puts in a mention the editor treats as one piece,
* and saving the note tells them. Only while the note is internal - the form's
* reply type says which - and a public reply holding mentions says they are
* plain text there. Called from the reply editors' setup.
*/
function itflowMentionAutocompleter(editor) {
var element = editor.getElement ? editor.getElement() : null;
var raw = element && element.dataset ? element.dataset.mentionAgents : '';
if (!raw) {
return;
}
var agents = [];
try {
agents = JSON.parse(raw) || [];
} catch (e) {
return;
}
var form = element.form || element.closest('form');
// Internal: the composer's Internal note, or the edit window's Internal - or no choice on the form at all
function internal() {
var picked = form ? form.querySelector('input[name="public_reply_type"]:checked, input[name="ticket_reply_type"]:checked') : null;
return !picked || picked.value === '0' || picked.value === 'Internal';
}
// The hint while it is internal; the warning when a public one holds mentions
function showState() {
if (!form) {
return;
}
var body = editor.getBody ? editor.getBody() : null;
var has = !!(body && body.querySelector('.itflow-mention'));
var hint = form.querySelector('[data-mention-hint]');
var warn = form.querySelector('[data-mention-public]');
if (hint) {
hint.hidden = !internal();
}
if (warn) {
warn.hidden = internal() || !has;
}
}
editor.ui.registry.addAutocompleter('itflowMention', {
trigger: '@',
minChars: 0,
columns: 1,
highlightOn: ['mention_name'],
fetch: function (pattern) {
if (!internal()) {
return Promise.resolve([]);
}
var words = String(pattern || '').toLowerCase().split(/\s+/).filter(Boolean);
return Promise.resolve(agents.filter(function (agent) {
var text = (agent.name + ' ' + (agent.role || '')).toLowerCase();
return words.every(function (word) {
return text.indexOf(word) !== -1;
});
}).slice(0, 8).map(function (agent) {
return {
type: 'cardmenuitem',
value: String(agent.id),
label: agent.name,
items: [{
type: 'cardcontainer',
direction: 'horizontal',
align: 'left',
valign: 'middle',
items: [
agent.avatar
? { type: 'cardimage', src: agent.avatar, alt: '', classes: ['itflow-mention-ac-avatar'] }
: { type: 'cardtext', text: agent.initials || '?', classes: ['itflow-mention-ac-initials'] },
{
type: 'cardcontainer',
direction: 'vertical',
items: [
{ type: 'cardtext', text: agent.name, name: 'mention_name', classes: ['itflow-mention-ac-name'] },
{ type: 'cardtext', text: agent.role || '', classes: ['itflow-mention-ac-role'] }
]
}
]
}]
};
}));
},
onAction: function (api, range, value) {
api.hide();
var agent = agents.filter(function (a) {
return String(a.id) === String(value);
})[0];
if (!agent) {
return;
}
editor.selection.setRng(range);
editor.insertContent('<span class="itflow-mention itflow-mention-u' + parseInt(agent.id, 10) + '" data-mention="' + parseInt(agent.id, 10)
+ '" contenteditable="false">@' + editor.dom.encode(agent.name) + '</span>&nbsp;');
showState();
}
});
editor.on('init SetContent NodeChange input', showState);
if (form && !form.dataset.mentionWatch) {
form.dataset.mentionWatch = '1';
form.addEventListener('change', function (e) {
if (e.target && (e.target.name === 'public_reply_type' || e.target.name === 'ticket_reply_type')) {
showState();
}
});
}
}
/*
* Reword with AI - on an editor only when AI is set up for it and the agent's
* role may use it: window.itflowAi, printed by includes/footer.php from
* aiClientConfig() (functions/ai.php). The ticket editor asks the Tickets
* model. The button rewords; its menu has the other tones (shorter, more
* formal, friendlier, plain English, organized, just spelling and grammar)
* and Translate, which asks which language - the last one picked comes first
* next time. The answer replaces the text in one step, so Undo puts it back.
* A textarea's data-ai-client-id says the client it is for, for AI Usage.
*/
function itflowAiOn(ticket) {
var ai = window.itflowAi || null;
return !!(ai && (ticket ? ai.rewordTicket : ai.reword));
}
function itflowAiReword(editor, ticket) {
if (!itflowAiOn(ticket)) {
return;
}
var ai = window.itflowAi;
var buttonApi = null;
var say = function (text, type) {
editor.notificationManager.open({ text: text, type: type, timeout: type === 'success' ? 4000 : 8000 });
};
var done = {
reword: 'Reworded', shorter: 'Shortened', formal: 'Made more formal', friendly: 'Made friendlier', plain: 'Put in plain English',
structure: 'Organized', proofread: 'Spelling and grammar fixed', translate: 'Translated'
};
function run(tone, language) {
if (editor.getContent({ format: 'text' }).trim() === '') {
say('Write something first - Reword works on what is there.', 'info');
return;
}
var element = editor.getElement();
var clientId = element ? parseInt(element.getAttribute('data-ai-client-id') || '0', 10) : 0;
buttonApi.setEnabled(false);
editor.setProgressState(true);
fetch(ai.endpoint + '?ai_reword' + (ticket ? '&use_case=Tickets' : ''), {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: editor.getContent(), client_id: clientId || 0, tone: tone, language: language || '' })
})
.then(function (response) {
return response.json().catch(function () {
throw new Error('HTTP ' + response.status);
});
})
.then(function (data) {
// Leave the user's text alone if it failed
if (data.error || !data.rewordedText) {
say(data.error || 'Could not reword the text.', 'error');
return;
}
editor.undoManager.add();
editor.undoManager.transact(function () {
editor.setContent(data.rewordedText);
});
say((done[tone] || 'Reworded') + (tone === 'translate' ? ' into ' + language : '') + ' - Undo puts your words back.', 'success');
})
.catch(function () {
say('Could not reach ITFlow to reword it - try again.', 'error');
})
.finally(function () {
editor.setProgressState(false);
buttonApi.setEnabled(true);
});
}
function translate() {
var languages = ai.languages || ['English'];
var last = '';
try {
last = localStorage.getItem('itflowAiLanguage') || '';
} catch (e) {
last = '';
}
editor.windowManager.open({
title: 'Translate with AI',
body: {
type: 'panel',
items: [{ type: 'selectbox', name: 'language', label: 'Into', items: languages.map(function (language) { return { text: language, value: language }; }) }]
},
initialData: { language: languages.indexOf(last) >= 0 ? last : languages[0] },
buttons: [{ type: 'cancel', text: 'Cancel' }, { type: 'submit', text: 'Translate', primary: true }],
onSubmit: function (dialog) {
var language = dialog.getData().language;
dialog.close();
try {
localStorage.setItem('itflowAiLanguage', language);
} catch (e) {
// Remembering it is only a convenience
}
run('translate', language);
}
});
}
editor.ui.registry.addSplitButton('reword', {
icon: 'ai',
tooltip: 'Reword with AI',
onAction: function () {
run('reword');
},
onItemAction: function (api, value) {
if (value === 'translate') {
translate();
} else {
run(value);
}
},
fetch: function (callback) {
var tones = ai.tones || { reword: 'Reword' };
callback(Object.keys(tones).map(function (tone) {
return { type: 'choiceitem', text: tones[tone], value: tone };
}));
},
onSetup: function (api) {
buttonApi = api;
return function () {};
}
});
// Draft a reply - on the ticket reply editor, whose textarea says the ticket
var element = editor.getElement();
var ticketId = element ? parseInt(element.getAttribute('data-ai-ticket-id') || '0', 10) : 0;
if (ticket && ticketId && ai.draft) {
itflowAiDraft(editor, ticketId, say);
}
}
/*
* Draft with AI - the next reply on the ticket, from what the agent has typed
* so far (notes, a line or nothing), for who will read it: the reply type
* picked in its form - an internal note, a public note or an email. It
* replaces the editor's text in one step, so Undo puts it back.
*/
function itflowAiDraft(editor, ticketId, say) {
var buttonApi = null;
var types = { '0': 'internal', '1': 'public', '2': 'email' };
editor.ui.registry.addButton('aidraft', {
icon: 'ai-prompt',
text: 'Draft',
tooltip: 'Draft the reply with AI - from your notes, if you have typed some',
onAction: function () {
var form = editor.getElement().closest('form');
var picked = form ? form.querySelector('input[name="public_reply_type"]:checked') : null;
var notes = editor.getContent({ format: 'text' }).trim() === '' ? '' : editor.getContent();
buttonApi.setEnabled(false);
editor.setProgressState(true);
fetch(window.itflowAi.endpoint + '?ai_ticket_draft', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket_id: ticketId, notes: notes, type: types[picked ? picked.value : '2'] || 'email' })
})
.then(function (response) {
return response.json().catch(function () {
throw new Error('HTTP ' + response.status);
});
})
.then(function (data) {
if (data.error || !data.html) {
say(data.error || 'Could not draft it.', 'error');
return;
}
editor.undoManager.add();
editor.undoManager.transact(function () {
editor.setContent(data.html);
});
say('Drafted - read it through before it goes. Undo puts back what was there.', 'success');
})
.catch(function () {
say('Could not reach ITFlow to draft it - try again.', 'error');
})
.finally(function () {
editor.setProgressState(false);
buttonApi.setEnabled(true);
});
},
onSetup: function (api) {
buttonApi = api;
return function () {};
}
});
}
/**
* TinyMCE skin options for the current colour mode.
*
* TinyMCE renders its chrome in its own DOM and its content inside an iframe,
* so it inherits nothing from the page - a dark page still got a white editor.
* Both the oxide-dark UI skin and the dark content stylesheet are already
* vendored under libs/tinymce/skins, so this only has to point at them.
*/
function itflowTinyMceSkin() {
var dark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
var root = getComputedStyle(document.documentElement);
var body = getComputedStyle(document.body);
function v(name, fallback) {
var value = root.getPropertyValue(name).trim();
return value || fallback;
}
// The editing surface lives in an iframe, so no page CSS reaches it and
// custom properties do not cross the boundary either - the values have to
// be read here and passed through as literal text. Without this the editor
// keeps TinyMCE's own blue-slate dark (#222f3e) next to the app's neutral
// grey (#212529), which is close enough to look like a mistake.
var contentStyle =
'body{' +
'background-color:' + v('--bs-body-bg', '#fff') + ';' +
'color:' + v('--bs-body-color', '#212529') + ';' +
'font-family:' + body.fontFamily + ';' +
'font-size:' + body.fontSize + ';' +
'}' +
'a{color:' + v('--itflow-accent', '#007bff') + ';}' +
'table td,table th{border-color:' + v('--bs-border-color', '#dee2e6') + ';}' +
// An @mention (functions/ticket_mention.php) - as it shows on the ticket
'.itflow-mention{color:' + v('--itflow-accent', '#007bff') + ';background-color:color-mix(in srgb,' + v('--itflow-accent', '#007bff') + ' 12%,transparent);' +
'border-radius:.25rem;padding:0 .25em;font-weight:500;white-space:nowrap;}';
return {
skin: dark ? 'oxide-dark' : 'oxide',
content_css: dark ? 'dark' : 'default',
content_style: contentStyle
};
}
/**
* Chart.js defaults for the current colour mode.
*
* Charts are drawn to a <canvas>, so no CSS reaches them - every colour has to
* be handed to Chart.js as a literal. The pages used to hardcode
* Chart.defaults.color = '#292b2c', which was fine while the cards were
* effectively light but is invisible on a real dark background.
*
* Returns the axis/grid colour too, since the charts set that per-scale.
*/
function itflowChartDefaults() {
var root = getComputedStyle(document.documentElement);
var body = getComputedStyle(document.body);
function v(name, fallback) {
return (root.getPropertyValue(name) || '').trim() || fallback;
}
var text = v('--bs-body-color', '#292b2c');
var grid = v('--bs-border-color', 'rgba(0, 0, 0, .125)');
if (window.Chart) {
Chart.defaults.font.family = body.fontFamily;
Chart.defaults.color = text;
Chart.defaults.borderColor = grid;
}
return { text: text, grid: grid };
}
function itflowBindOnce(name, type, selector, handler) {
window.itflowBound = window.itflowBound || {};
if (window.itflowBound[name]) {
return;
}
window.itflowBound[name] = true;
document.addEventListener(type, function (e) {
const match = e.target.closest(selector);
if (match) {
handler.call(match, e);
}
});
}
/**
* Run fn once the DOM is ready, the way jQuery's .ready() did.
*
* js/ajax_modal.js injects a modal payload's <script> tags long after
* DOMContentLoaded has fired, so a bare
* document.addEventListener('DOMContentLoaded', ...) inside one of them
* registers for an event that will never come again and the body silently
* never runs. jQuery's .ready() invoked the callback immediately when the
* document was already parsed, which is why that pattern worked before the
* vanilla conversion. Modal payload scripts must use this instead.
*/
function itflowReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else {
fn();
}
}
function itflowInit() {
// Prevents resubmit on forms
if (window.history.replaceState) {
window.history.replaceState(null, null, window.location.href);
}
// Fade the legacy #alert box out after 5s, then collapse it
(function () {
const alertEl = document.getElementById('alert');
if (!alertEl) {
return;
}
setTimeout(function () {
alertEl.style.transition = 'opacity .5s linear';
alertEl.style.opacity = '0';
setTimeout(function () {
alertEl.style.overflow = 'hidden';
alertEl.style.transition = 'height .5s ease, margin .5s ease, padding .5s ease';
alertEl.style.height = alertEl.offsetHeight + 'px';
void alertEl.offsetHeight;
alertEl.style.height = '0px';
alertEl.style.marginTop = '0';
alertEl.style.marginBottom = '0';
alertEl.style.paddingTop = '0';
alertEl.style.paddingBottom = '0';
setTimeout(function () {
alertEl.style.display = 'none';
}, 500);
}, 500);
}, 5000);
})();
// Any select2 field not picked up yet. js/itflow_select.js has normally
// done them all already and enhances new ones as they arrive, so this is
// a belt-and-braces pass that finds nothing to do.
itflowStep('select', function () {
itflowSelectAll(document);
});
// The editor isn't loaded everywhere this file is - setup/index.php has none -
// and a bare tinymce there threw before the password eyes and copy buttons below
// were wired up. Where it is absent the four inits have nothing to do.
const itflowTinyMce = typeof tinymce !== 'undefined' ? tinymce : { init: function () {} };
// Initialize TinyMCE
itflowTinyMce.init({
...itflowTinyMceSkin(),
selector: '.tinymce-simple',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 300,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: [
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'table', items: ['table'] },
{ name: 'extra', items: ['code', 'fullscreen'] }
],
mobile: {
menubar: false,
plugins: 'autosave lists autolink',
toolbar: 'bold italic styles'
},
convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize',
setup: function (editor) {
editor.on('init', function() {
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
},
license_key: 'gpl'
});
// Initialize TinyMCE with AI
itflowTinyMce.init({
...itflowTinyMceSkin(),
selector: '.tinymce',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 300,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
// Reword first, where it is there - in a narrow editor the end of the toolbar folds away
toolbar: [
...(itflowAiOn(false) ? [{ name: 'ai', items: ['reword'] }] : []),
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'alignment', items: ['alignleft', 'aligncenter', 'alignright', 'alignjustify'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'table', items: ['table'] },
{ name: 'extra', items: ['code', 'fullscreen'] },
{ name: 'history', items: ['undo', 'redo'] }
],
mobile: {
menubar: false,
plugins: 'autosave lists autolink',
toolbar: 'bold italic styles'
},
convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize',
license_key: 'gpl',
setup: function(editor) {
itflowCannedAutocompleter(editor);
itflowMentionAutocompleter(editor);
editor.on('init', function() {
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
itflowAiReword(editor, false);
}
});
// Initialize TinyMCE AI for Tickets
itflowTinyMce.init({
...itflowTinyMceSkin(),
selector: '.tinymceTicket',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 200,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: [
...(itflowAiOn(true) ? [{ name: 'ai', items: ['aidraft', 'reword'] }] : []),
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'history', items: ['undo', 'redo'] },
{ name: 'custom', items: ['redactButton'] },
{ name: 'code', items: ['code'] },
],
mobile: {
menubar: false,
toolbar: [
...(itflowAiOn(true) ? [{ name: 'ai', items: ['aidraft', 'reword'] }] : []),
{ name: 'styles', items: ['styles'] },
{ name: 'formatting', items: ['bold', 'italic', 'forecolor'] },
{ name: 'link', items: ['link'] },
{ name: 'lists', items: ['bullist', 'numlist'] },
{ name: 'indentation', items: ['outdent', 'indent'] },
{ name: 'history', items: ['undo', 'redo'] },
{ name: 'custom', items: ['redactButton'] },
{ name: 'code', items: ['code'] },
],
},
convert_urls: false,
plugins: 'link image lists table code codesample fullscreen autoresize code',
license_key: 'gpl',
setup: function(editor) {
itflowCannedAutocompleter(editor);
itflowMentionAutocompleter(editor);
editor.on('init', function() {
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
itflowAiReword(editor, true);
editor.ui.registry.addButton('redactButton', {
icon: 'permanent-pen',
tooltip: 'Redact Text',
onAction: function() {
var selectedText = editor.selection.getContent({ format: 'text' });
if (selectedText) {
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
editor.selection.setContent(newContent);
} else {
alert('Please select a word to redact');
}
}
});
}
});
// Initialize TinyMCE Redact-only
itflowTinyMce.init({
...itflowTinyMceSkin(),
selector: '.tinymceRedact',
browser_spellcheck: true,
contextmenu: false,
resize: true,
min_height: 300,
max_height: 600,
promotion: false,
branding: false,
menubar: false,
statusbar: false,
toolbar: 'redactButton',
mobile: {
menubar: false,
plugins: 'autosave lists autolink',
toolbar: 'redactButton'
},
convert_urls: false,
plugins: 'link image lists table code fullscreen autoresize',
license_key: 'gpl',
setup: function(editor) {
editor.on('init', function() {
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
modal.addEventListener('hidden.bs.modal', () => {
editor.undoManager.clear();
editor.setDirty(false);
});
}
});
editor.on('keydown', function(e) {
e.preventDefault();
});
editor.ui.registry.addButton('redactButton', {
icon: 'permanent-pen',
tooltip: 'Redact',
text: 'REDACT',
onAction: function() {
var selectedText = editor.selection.getContent({ format: 'text' });
if (selectedText) {
var newContent = '<span style="font-weight: bold; color: red;">[REDACTED]</span>';
editor.selection.setContent(newContent);
} else {
alert('Please select a word to redact');
}
}
});
}
});
// DateTime
document.querySelectorAll('.datetimepicker').forEach(function (el) {
if (el._flatpickr) {
return;
}
flatpickr(el, {
enableTime: true,
time_24hr: true,
dateFormat: 'Y-m-d H:i',
allowInput: true
});
});
// Data Input Mask
// IMask replaces jquery.inputmask. Only two aliases were ever used.
document.querySelectorAll('[data-mask]').forEach(function (el) {
if (el.dataset.imaskReady) {
return;
}
el.dataset.imaskReady = '1';
var spec = el.getAttribute('data-inputmask') || '';
if (spec.indexOf('ip') !== -1) {
// A pattern mask built from MaskedRange blocks will not advance past the dot
// until the block reaches its maxLength, so 10.0.0.1 had to be entered as
// 010.000.000.001. A regex mask has no per-block completeness rule - it tests
// the whole value on every keystroke, so partial input like "10.0." is valid on
// its own. Octets are still bounded to 0-255 and leading zeros are still
// accepted, matching what jquery.inputmask's 'ip' alias allowed.
//
// interface_ip / asset_ip are varchar(200) and hold free text as well - 'DHCP'
// is written there by the checkbox on these same modals. Masking a value like
// that would strip it to nothing the moment the modal opened, so anything not
// made of digits and dots is left unmasked instead.
if (el.value && !/^[\d.]*$/.test(el.value)) {
return;
}
var octet = '(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)';
var octet4 = octet + '?';
var octet3 = '(' + octet + '(\\.' + octet4 + ')?)?';
var octet2 = '(' + octet + '(\\.' + octet3 + ')?)?';
IMask(el, {
mask: new RegExp('^(' + octet + '(\\.' + octet2 + ')?)?$')
});
} else if (spec.indexOf('mac') !== -1) {
IMask(el, {
mask: 'HH:HH:HH:HH:HH:HH',
definitions: { H: /[0-9a-fA-F]/ },
prepare: function (s) { return s.toUpperCase(); },
lazy: true
});
}
});
// Password reveal. Replaces Show-Hide-Passwords-Bootstrap-4, which has no Bootstrap 5
// release. The data-toggle="password" contract stays on the INPUT, which is where all
// 15 call sites write it and where the old plugin expected it - but the plugin built
// its own toggle button, and the click has to land on the eye SPAN beside the field.
// Binding it to the input instead meant the eye did nothing at all, and clicking into
// the field to type your password switched it to type="text" while you typed it.
itflowStep('password-reveal', function () {
document.querySelectorAll('input[data-toggle="password"]').forEach(function (input) {
var group = input.closest('.input-group');
var icon = group && group.querySelector('.fa-eye, .fa-eye-slash');
var toggle = icon && icon.closest('span, button, a');
// includes/modal_footer.php re-executes this file on every ajax modal open, so
// without a marker anything already on the page collects a second handler and
// the next click toggles twice, which looks exactly like nothing happening.
if (!toggle || toggle.dataset.itflowPasswordToggle) {
return;
}
toggle.dataset.itflowPasswordToggle = '1';
// The eye is a plain <span>, so it needs the role to get a pointer cursor and
// the tabindex to be reachable at all without a mouse.
toggle.setAttribute('role', 'button');
toggle.setAttribute('tabindex', '0');
toggle.setAttribute('aria-pressed', 'false');
toggle.setAttribute('aria-label', 'Show password');
function toggleReveal() {
var hidden = input.type === 'password';
input.type = hidden ? 'text' : 'password';
icon.classList.toggle('fa-eye', !hidden);
icon.classList.toggle('fa-eye-slash', hidden);
toggle.setAttribute('aria-pressed', hidden ? 'true' : 'false');
toggle.setAttribute('aria-label', hidden ? 'Hide password' : 'Show password');
}
toggle.addEventListener('click', toggleReveal);
toggle.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleReveal();
}
});
});
});
// Copy buttons: any element with class="clipboardjs" and its text in
// data-clipboard-text, or the field to copy from in data-clipboard-target.
// That is ClipboardJS's markup contract, kept so none of the call sites
// change - the library itself is no longer used here. Beside a value they
// are the small .itflow-copy icon that shows when its row is pointed at
// (css/itflow_custom.css); beside an input, an input-group button.
//
// ClipboardJS copies by selecting a hidden textarea. Under Bootstrap 5 that
// textarea has to be inside the open modal or the focus trap pulls focus
// back out before the copy runs, so e5d1fe2 pinned ClipboardJS's `container`
// to the last .modal on the page. A container is fixed for the life of the
// instance, though, and the last .modal was usually the wrong one: a HIDDEN
// modal on any page that includes one up front (invoice, credentials, asset,
// contact), and a DETACHED one everywhere else once any ajax modal had been
// opened and closed - the notifications bell included. A hidden or detached
// textarea holds no selection, so nothing was copied, and since ClipboardJS
// reports success from the text rather than from the copy itself, all of
// those clicks still flashed "Copied!".
//
// So this resolves everything at click time. Bound once on document, as this
// file re-runs on every ajax modal open.
itflowStep('clipboard', function () {
itflowBindOnce('itflowCopy', 'click', '.clipboardjs', function (e) {
// Copy Guest URL (invoice, quote) is an <a href="#"> - without this
// every copy also jumped the page back to the top.
e.preventDefault();
const trigger = this;
const target = itflowCopyFeedbackTarget(trigger);
// data-clipboard-target="input[name=uri]" copies what is in that
// field now - in its own input group first - rather than the value
// the page was drawn with, so an edited field copies the edit
const source = trigger.getAttribute('data-clipboard-target');
const field = source ? ((trigger.closest('.input-group') || document).querySelector(source) || document.querySelector(source)) : null;
const text = field ? (field.value || '') : (trigger.getAttribute('data-clipboard-text') || '');
// An empty field would otherwise wipe whatever is on the clipboard
if (text === '') {
flashTooltip(target, 'Nothing to copy');
return;
}
itflowCopyText(text, trigger).then(
function () { flashTooltip(target, 'Copied!'); },
function () { flashTooltip(target, 'Failed!'); }
);
});
});
// Enable Popovers
itflowStep('popovers', function () {
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(function (el) {
bootstrap.Popover.getOrCreateInstance(el);
});
});
// Data Tables - only loaded on agent and admin pages (includes/footer.php);
// setup and Get started load this file without it
itflowStep('datatables', function () {
if (typeof DataTable !== 'undefined') {
new DataTable('.dataTables');
}
});
// Phone inputs
// js/phone_inputs.js is only loaded where phone fields exist - the agent
// footer and the portal profile page. Referencing the bare identifier here
// would throw a ReferenceError anywhere it is absent (setup/index.php loads
// app.js on its own), and that throw happens while evaluating the argument,
// before itflowStep's try/catch can contain it - taking every step after
// this one down with it.
itflowStep('phone-inputs', function () {
if (typeof initPhoneInputs === 'function') {
initPhoneInputs();
}
});
}
// modal_footer.php re-loads this file on every ajax modal open, so run now if
// the document is already parsed, otherwise wait for it.
itflowReady(itflowInit);
/*
* Calendar event modals - the All day switch shows or hides the time row.
*
* The form uses four separate fields (date from / date to / time from / time to),
* so nothing here rewrites a value or changes an input type. An earlier version
* flipped a single datetime-local input to type="date", which a browser answers by
* silently discarding a value it now considers invalid - a fragile arrangement that
* left the fields empty whenever this handler had not run.
*
* required tracks visibility: a hidden required field blocks form submission with an
* unfocusable-element error that the user cannot act on.
*
* Delegated on document because the edit event modal is injected by ajax. The
* namespaced .off() keeps this to a single handler - modal_footer.php re-loads this
* file on every ajax modal open.
*/
/**
* Initial focus for modals with no autofocus target.
*
* Bootstrap's focus trap sends focus to the FIRST focusable child whenever it
* lands outside the trap, and in ITFlow's modals that is the header close
* button - so opening any of the ~230 modals without an autofocus field parked
* a focus ring on the X. Putting focus on the dialog itself is what Bootstrap
* intends, keeps the trap and Escape working, and leaves screen readers
* announcing the dialog rather than "Close".
*
* Deliberately does NOT auto-focus the first input: that would pop the
* keyboard on mobile for every modal, which is a bigger change than the bug.
*/
itflowBindOnce('itflowModalFocus', 'shown.bs.modal', '.modal', function () {
if (this.querySelector('[autofocus]')) {
return;
}
this.focus();
});
/**
* A window opened over another - the + beside a category picker opens the
* category window over the form - goes above it with its backdrop, so the
* form underneath is dimmed rather than drawn over the new window's shade.
* Bootstrap gives every modal the same z-index. Closing the top one takes the
* page's modal-open class off, so it is put back while one is still open.
*/
itflowBindOnce('itflowModalStack', 'show.bs.modal', '.modal', function () {
const below = document.querySelectorAll('.modal.show').length;
if (!below) {
return;
}
const z = 1055 + 20 * below;
this.style.zIndex = z;
// Bootstrap adds the backdrop straight after this event
setTimeout(function () {
const backdrops = document.querySelectorAll('.modal-backdrop');
if (backdrops.length) {
backdrops[backdrops.length - 1].style.zIndex = z - 5;
}
});
});
itflowBindOnce('itflowModalStackHidden', 'hidden.bs.modal', '.modal', function () {
if (document.querySelector('.modal.show')) {
document.body.classList.add('modal-open');
}
});
/**
* A required field left empty on a tab that isn't showing - the Ticket tab of
* a template while Sets up is open, a fill-in on Details while Tasks is -
* would stop the form going with nothing to see: the browser can't point at
* a field it can't show. Its tab is opened, so the browser's message lands on
* it. 'invalid' doesn't bubble, hence the capture.
*/
if (!window.itflowInvalidTab) {
window.itflowInvalidTab = true;
document.addEventListener('invalid', function (e) {
const pane = e.target.closest ? e.target.closest('.tab-pane') : null;
if (!pane || pane.classList.contains('active') || !pane.id || !window.bootstrap) {
return;
}
const scope = pane.closest('form, .modal, .tab-content') || document;
const root = scope.parentElement || document;
const trigger = root.querySelector('[data-bs-toggle][href="#' + pane.id + '"], [data-bs-toggle][data-bs-target="#' + pane.id + '"]');
if (trigger) {
bootstrap.Tab.getOrCreateInstance(trigger).show();
}
}, true);
}
/**
* The + beside a category picker (categoryAddButtonHtml()): note which
* picker it is beside, for js/category_form.js to put the new category in.
*/
itflowBindOnce('itflowCategoryAdd', 'click', '[data-category-add]', function () {
const group = this.closest('.input-group') || this.parentElement;
window.itflowCategoryTarget = group ? group.querySelector('select') : null;
});
/**
* Warn before leaving the page with unsaved editor content.
*
* Each TinyMCE config used to assign window.onbeforeunload for its own editor.
* That is a single slot, so whichever editor initialised last owned it. Opening
* any ajax modal with an editor (Edit Ticket, Edit Reply) handed the slot to the
* modal's editor, and once that modal closed nothing guarded the page's own
* editor - a half-written ticket reply was lost on the next link click with no
* prompt. One listener now checks every live editor.
*
* Editors inside an open modal are skipped, as before: the modal is where that
* work gets submitted or discarded. Editors from a closed ajax modal are removed
* by js/ajax_modal.js, so tinymce.get() no longer returns them.
*
* Flagged on window because modal_footer.php re-loads this file on every ajax
* modal open.
*/
if (!window.itflowEditorUnloadGuard) {
window.itflowEditorUnloadGuard = true;
window.addEventListener('beforeunload', function (e) {
if (typeof tinymce === 'undefined') {
return;
}
const unsaved = tinymce.get().some(function (editor) {
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
return !inVisibleModal && editor.isDirty();
});
if (unsaved) {
e.preventDefault();
e.returnValue = '';
}
});
}
/**
* Let TinyMCE's popups take focus inside a modal.
*
* TinyMCE renders its "..." overflow drawer, menus, colour picker and dialogs
* (link, source code, table) in .tox-tinymce-aux, appended to <body> - outside
* the modal. Bootstrap 5's focus trap pulls any focus outside the open modal
* back to its first focusable element (the header X), so the drawer closed as
* soon as it opened and the link dialog would not take typing. Bootstrap 4 was
* fine only because this file used to no-op _enforceFocus.
*
* Order matters: this is bound once at first load, and Bootstrap adds its own
* focusin listener each time a modal opens, so this one always runs first and
* stopImmediatePropagation keeps Bootstrap's from firing. The trap still holds
* for everything else.
*/
itflowBindOnce('itflowTinyMceFocus', 'focusin', '.tox-tinymce-aux', function (e) {
e.stopImmediatePropagation();
});
// A PIN or code shown as dots (.itflow-secret) - the eye beside it shows it, and again hides it
itflowBindOnce('itflowSecret', 'click', '.itflow-secret-toggle', function (e) {
e.preventDefault();
const secret = this.closest('.itflow-secret');
if (!secret) {
return;
}
const shown = secret.classList.toggle('is-shown');
const icon = this.querySelector('i');
if (icon) {
icon.classList.toggle('fa-eye', !shown);
icon.classList.toggle('fa-eye-slash', shown);
}
this.setAttribute('title', shown ? 'Hide' : 'Show');
this.setAttribute('aria-pressed', shown ? 'true' : 'false');
});
/*
* Keep the end date at or after the start date, without shortening a longer span
* the user has already chosen.
*/
itflowBindOnce('itflowEventDate', 'change', '.event-start-date', function () {
const endField = document.getElementById(this.id.replace(/_start_date$/, '_end_date'));
if (!endField || !this.value) {
return;
}
if (!endField.value || endField.value < this.value) {
endField.value = this.value;
}
});
/*
* Default the end time to an hour after the start, leaving a longer span alone.
* A start late in the evening rolls the end onto the following day rather than
* wrapping round to an end that precedes the start.
*/
itflowBindOnce('itflowEventTime', 'change', '.event-start-time', function () {
const prefix = this.id.replace(/_start_time$/, '');
const endField = document.getElementById(prefix + '_end_time');
if (!endField || !this.value) {
return;
}
if (endField.value && endField.value > this.value) {
return;
}
const parts = this.value.split(':');
const end = new Date(2000, 0, 1, Number(parts[0]), Number(parts[1]));
end.setHours(end.getHours() + 1);
const pad = (n) => String(n).padStart(2, '0');
endField.value = pad(end.getHours()) + ':' + pad(end.getMinutes());
// Crossed midnight - carry the end date forward so the event still ends after
// it starts
if (end.getDate() !== 1) {
const startDate = document.getElementById(prefix + '_start_date');
const endDate = document.getElementById(prefix + '_end_date');
if (startDate && endDate && startDate.value && endDate.value <= startDate.value) {
const next = new Date(startDate.value + 'T00:00:00');
next.setDate(next.getDate() + 1);
endDate.value = next.getFullYear() + '-' + pad(next.getMonth() + 1) + '-' + pad(next.getDate());
}
}
});
/*
* Bootstrap tooltips are only used for the brief "Copied!" flash on a copy
* button. Everything else relies on the browser's own title attribute.
*
* This used to be `$('button').tooltip({trigger: 'click'})`, which attached a
* click-toggled tooltip to EVERY button in the app. A click-triggered tooltip
* is only dismissed by clicking the same button again - clicking anywhere else
* leaves it on screen - and if the button was then removed from the DOM by a
* modal closing or an ajax refresh, its tooltip was orphaned and stayed up
* until the next page load.
*/
function flashTooltip(button, message) {
const el = button instanceof Element ? button : document.querySelector(button);
if (!el) {
return;
}
bootstrap.Tooltip.getInstance(el)?.dispose();
const tip = new bootstrap.Tooltip(el, {
trigger: 'manual',
placement: 'bottom',
title: message
});
tip.show();
setTimeout(function () {
tip.dispose();
}, 1000);
}
/**
* Copy text to the clipboard. Resolves once it is really there and rejects
* when it could not be written, so callers can flash "Copied!" or "Failed!"
* honestly.
*
* The async Clipboard API involves no selection and no focus, so no modal's
* focus trap can get in its way. Browsers only offer it in a secure context
* (HTTPS or localhost), so a plain-HTTP install falls back to a textarea and
* execCommand('copy') - placed inside the trigger's own modal, worked out now
* rather than in advance.
*/
function itflowCopyText(text, trigger) {
if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return itflowCopyTextLegacy(text, trigger)
? Promise.resolve()
: Promise.reject(new Error('Copy failed'));
}
function itflowCopyTextLegacy(text, trigger) {
// The textarea has to be rendered and focusable to hold a selection - a
// hidden or detached one is exactly what broke - and inside the open modal,
// or Bootstrap's focus trap takes focus back before the copy runs.
const host = (trigger && trigger.closest('.modal')) || document.body;
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', ''); // no on-screen keyboard on touch devices
area.style.position = 'fixed';
area.style.top = '0';
area.style.left = '-9999px';
host.appendChild(area);
area.focus({ preventScroll: true });
area.select();
area.setSelectionRange(0, text.length); // iOS ignores select() alone
let copied = false;
try {
copied = document.execCommand('copy');
} catch (err) {
copied = false;
}
area.remove();
// Removing the focused textarea leaves focus on <body> - hand it back.
if (trigger && trigger.isConnected) {
trigger.focus({ preventScroll: true });
}
return copied;
}
/**
* Where to flash a copy button's "Copied!". Bootstrap closes a dropdown on the
* same click, so a dropdown item like Copy Guest URL is hidden by the time the
* tooltip is positioned - and a tooltip on a hidden element is drawn in the
* top-left corner of the window. Use the dropdown's wrapper instead, which on
* invoice and quote is shrink-wrapped around the toggle. Not the toggle itself:
* it already holds the Dropdown instance, and Bootstrap refuses a second
* instance on one element.
*/
function itflowCopyFeedbackTarget(trigger) {
const menu = trigger.closest('.dropdown-menu');
return (menu && menu.parentElement) || trigger;
}
/**
* Show a Bootstrap toast from JavaScript.
*
* The PHP side renders its own toast markup in includes/inc_alert_feedback.php
* so that user-supplied text never reaches a JS string literal. This helper is
* for toasts raised by client-side code, where the caller controls the text.
*
* Replaces toastr.success() / .error() / .warning() / .info().
*/
function itflowToast(message, type) {
var styles = {
success: 'text-bg-success',
info: 'text-bg-info',
warning: 'text-bg-warning',
alert: 'text-bg-warning',
error: 'text-bg-danger',
danger: 'text-bg-danger'
};
var style = styles[type] || styles.success;
var darkText = type === 'warning' || type === 'alert' || type === 'info';
var container = document.querySelector('.toast-container.itflow-toast-js');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container itflow-toast-js position-fixed top-0 end-0 p-3';
container.style.zIndex = '1090';
document.body.appendChild(container);
}
var toast = document.createElement('div');
toast.className = 'toast fade align-items-center ' + style + ' border-0';
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
var flex = document.createElement('div');
flex.className = 'd-flex';
var body = document.createElement('div');
body.className = 'toast-body';
body.textContent = message;
var close = document.createElement('button');
close.type = 'button';
close.className = 'btn-close ' + (darkText ? '' : 'btn-close-white') + ' me-2 m-auto';
close.setAttribute('data-bs-dismiss', 'toast');
close.setAttribute('aria-label', 'Close');
flex.appendChild(body);
flex.appendChild(close);
toast.appendChild(flex);
container.appendChild(toast);
toast.addEventListener('hidden.bs.toast', function () {
toast.remove();
});
bootstrap.Toast.getOrCreateInstance(toast, {
animation: true,
autohide: true,
delay: 5000
}).show();
}
/*
* Live IP check for the network IP add/edit modals (agent/modals/network/).
*
* Calls the same checkIpForNetwork() the POST handler uses, via
* ajax.php?network_ip_check, so the field reports exactly the verdict the save
* will reach. Advisory only - the handler re-checks on submit, and this never
* blocks submission.
*
* Lives here rather than in the modal because includes/modal_footer.php
* re-executes this file on every ajax modal open, so the function is already
* defined by the time the modal's inline call runs.
*/
function itflowWatchNetworkIp(networkId, ipId) {
var input = document.getElementById('networkIpAddress');
var feedback = document.getElementById('networkIpFeedback');
if (!input || !feedback) {
return;
}
var timer = null;
var lastChecked = null;
function render(cls, html) {
feedback.className = 'small mt-1 ' + cls;
feedback.innerHTML = html;
}
function check() {
var value = input.value.trim();
if (value === '') {
render('', '');
input.classList.remove('is-valid', 'is-invalid');
lastChecked = null;
return;
}
if (value === lastChecked) {
return;
}
lastChecked = value;
itflowGet(
'ajax.php',
{network_ip_check: 'true', network_id: networkId, ip_id: ipId, ip: value},
function (data) {
var result;
try {
result = JSON.parse(data);
} catch (e) {
return;
}
// A slow earlier response must not paint over a newer one
if (input.value.trim() !== lastChecked) {
return;
}
render(result.ok ? 'text-success' : 'text-danger', result.message);
input.classList.toggle('is-valid', result.ok);
input.classList.toggle('is-invalid', !result.ok);
}
);
}
input.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(check, 350);
});
input.addEventListener('blur', check);
}