mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
578 lines
21 KiB
JavaScript
578 lines
21 KiB
JavaScript
/*
|
|
* Tag picker - the "+" at the end of a record's tags, and "Assign Tags" in
|
|
* a list's bulk Action menu. Replaces the Assign Tags modals.
|
|
*
|
|
* Opens a panel under the button (a sheet from the bottom on a phone) with
|
|
* every tag this kind of record can have, ticked if it has it. Ticking or
|
|
* unticking saves straight away through agent/ajax.php and the pills on the
|
|
* page redraw from what the server sends back, so the list never reloads and
|
|
* you can tick several in a row. Admins can type a new tag name and make it
|
|
* on the spot; "Manage tags" goes to Admin > Tags.
|
|
*
|
|
* In bulk, a tag some of the selected records have shows a dash: ticking it
|
|
* gives it to all of them, unticking takes it off all of them.
|
|
*
|
|
* Markup comes from tagPills() in functions/tags.php:
|
|
* <button data-tag-picker data-tag-entity="contact" data-tag-ids="12" data-csrf="...">
|
|
* or for bulk, data-tag-bulk instead of data-tag-ids (the ticked .bulk-select
|
|
* boxes on the page are the records - or only those named in data-tag-bulk,
|
|
* such as "document_ids[]" on a list that mixes documents and uploads).
|
|
*
|
|
* Styled with the select picker's panel classes (css: "Select picker"), so
|
|
* the two look and behave alike.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (window.itflowTagPicker) {
|
|
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 open = null; // the one open picker
|
|
|
|
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 csrfFor(button) {
|
|
if (button.dataset.csrf) {
|
|
return button.dataset.csrf;
|
|
}
|
|
var input = document.querySelector('input[name="csrf_token"]');
|
|
return input ? input.value : '';
|
|
}
|
|
|
|
function bulkIds(name) {
|
|
return Array.prototype.filter.call(document.querySelectorAll('input.bulk-select:checked'), function (box) {
|
|
return !name || box.name === name;
|
|
}).map(function (box) {
|
|
return box.value;
|
|
});
|
|
}
|
|
|
|
/* Swap in the pills the server sent, wherever that record is on the page */
|
|
function redraw(entity, pills) {
|
|
Object.keys(pills).forEach(function (id) {
|
|
document.querySelectorAll('.itflow-tags[data-tag-entity="' + entity + '"][data-tag-record="' + id + '"]').forEach(function (old) {
|
|
var holder = document.createElement('div');
|
|
holder.innerHTML = pills[id];
|
|
var fresh = holder.firstElementChild;
|
|
if (fresh) {
|
|
// Keep this spot's own filter links
|
|
fresh.setAttribute('data-tag-filter', old.getAttribute('data-tag-filter') || fresh.getAttribute('data-tag-filter'));
|
|
// The open picker hangs off the + in the pills being replaced -
|
|
// move it to the new +, or the next redraw finds it detached
|
|
// and closes the panel
|
|
if (open && old.contains(open.anchor)) {
|
|
var plus = fresh.querySelector('[data-tag-picker]');
|
|
if (plus) {
|
|
plus.setAttribute('aria-expanded', 'true');
|
|
open.anchor = plus;
|
|
open.button = plus;
|
|
}
|
|
}
|
|
old.replaceWith(fresh);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function filterFor(entity, ids) {
|
|
var spot = document.querySelector('.itflow-tags[data-tag-entity="' + entity + '"][data-tag-record="' + ids[0] + '"]');
|
|
return spot ? spot.getAttribute('data-tag-filter') || '' : '';
|
|
}
|
|
|
|
function Picker(button) {
|
|
this.button = button;
|
|
this.entity = button.dataset.tagEntity;
|
|
this.bulk = button.hasAttribute('data-tag-bulk');
|
|
this.ids = this.bulk ? bulkIds(button.dataset.tagBulk || '') : String(button.dataset.tagIds || '').split(',').filter(Boolean);
|
|
this.csrf = csrfFor(button);
|
|
// Bulk opens from a menu item that closes - hang the panel off the Action button
|
|
this.anchor = this.bulk ? (document.querySelector('#bulkActionButton > button') || button) : button;
|
|
this.query = '';
|
|
this.rows = [];
|
|
this.active = -1;
|
|
this.busy = 0;
|
|
}
|
|
|
|
Picker.prototype.build = function () {
|
|
var self = this;
|
|
var panel = this.panel = make('div', 'itflow-select-panel is-multiple itflow-tag-panel');
|
|
panel.setAttribute('role', 'dialog');
|
|
panel.setAttribute('aria-label', 'Tags');
|
|
|
|
var head = make('div', 'itflow-select-sheet-head');
|
|
head.appendChild(make('span', 'itflow-select-sheet-title', 'Tags'));
|
|
var done = make('button', 'btn btn-sm btn-link itflow-select-done', 'Done');
|
|
done.type = 'button';
|
|
done.addEventListener('click', function () {
|
|
self.close(false);
|
|
});
|
|
head.appendChild(done);
|
|
panel.appendChild(head);
|
|
|
|
if (this.bulk) {
|
|
panel.appendChild(make('div', 'itflow-tag-panel-note', this.ids.length + ' selected - ticked tags go on all of them, unticked come off'));
|
|
}
|
|
|
|
var search = make('div', 'itflow-select-search');
|
|
search.appendChild(make('i', 'fas fa-search'));
|
|
var input = this.input = make('input');
|
|
input.type = 'text';
|
|
input.autocomplete = 'off';
|
|
input.spellcheck = false;
|
|
input.setAttribute('aria-label', 'Search tags');
|
|
input.addEventListener('input', function () {
|
|
self.query = input.value;
|
|
self.draw(false);
|
|
});
|
|
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-multiselectable', 'true');
|
|
list.setAttribute('aria-label', 'Tags');
|
|
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);
|
|
|
|
var footer = this.footer = make('div', 'itflow-select-footer');
|
|
panel.appendChild(footer);
|
|
|
|
panel.addEventListener('mousedown', function (e) {
|
|
if (e.target !== input && !e.target.closest('a, button')) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
this.backdrop = make('div', 'itflow-select-backdrop');
|
|
};
|
|
|
|
Picker.prototype.show = function () {
|
|
var self = this;
|
|
if (!this.entity || !this.ids.length) {
|
|
return;
|
|
}
|
|
this.build();
|
|
open = this;
|
|
this.sheet = sheetMedia.matches;
|
|
this.panel.classList.toggle('is-sheet', this.sheet);
|
|
this.input.placeholder = 'Loading...';
|
|
this.list.appendChild(make('div', 'itflow-select-empty', 'Loading tags...'));
|
|
|
|
var host = this.anchor.closest('.modal, .offcanvas') || document.body;
|
|
if (this.sheet) {
|
|
host.appendChild(this.backdrop);
|
|
}
|
|
host.appendChild(this.panel);
|
|
this.anchor.setAttribute('aria-expanded', 'true');
|
|
this.place();
|
|
|
|
itflowGet('ajax.php', { tag_picker: 1, entity: this.entity, ids: this.ids }, function (text) {
|
|
var data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch (e) {
|
|
data = null;
|
|
}
|
|
if (open !== self) {
|
|
return;
|
|
}
|
|
if (!data || !data.tags) {
|
|
self.fail('Could not load the tags.');
|
|
self.close(false);
|
|
return;
|
|
}
|
|
self.data = data;
|
|
self.input.placeholder = data.create ? 'Search or make a tag' : 'Search tags';
|
|
self.draw(false);
|
|
if (finePointer.matches) {
|
|
self.input.focus({ preventScroll: true });
|
|
}
|
|
}, function () {
|
|
self.fail('Could not load the tags.');
|
|
self.close(false);
|
|
});
|
|
};
|
|
|
|
Picker.prototype.close = function (refocus) {
|
|
if (open !== this) {
|
|
return;
|
|
}
|
|
open = null;
|
|
this.panel.remove();
|
|
this.backdrop.remove();
|
|
this.anchor.setAttribute('aria-expanded', 'false');
|
|
if (refocus && this.anchor.isConnected) {
|
|
this.anchor.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;
|
|
}
|
|
var anchor = this.anchor.isConnected ? this.anchor : null;
|
|
if (!anchor) {
|
|
this.close(false);
|
|
return;
|
|
}
|
|
var rect = anchor.getBoundingClientRect();
|
|
var viewWidth = document.documentElement.clientWidth;
|
|
var viewHeight = window.innerHeight;
|
|
var width = Math.min(288, 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);
|
|
};
|
|
|
|
/* The checklist, filtered by the search, and the "make it" row. */
|
|
Picker.prototype.draw = function (keep) {
|
|
var self = this;
|
|
var data = this.data;
|
|
if (!data) {
|
|
return;
|
|
}
|
|
var list = this.list;
|
|
var scroll = list.scrollTop;
|
|
var activeId = keep && this.rows[this.active] ? this.rows[this.active].id : null;
|
|
var terms = fold(this.query.trim()).split(/\s+/).filter(Boolean);
|
|
var exact = false;
|
|
|
|
list.textContent = '';
|
|
this.rows = [];
|
|
|
|
data.tags.forEach(function (tag) {
|
|
var name = fold(tag.name);
|
|
var haystack = name + ' ' + fold(tag.description);
|
|
if (terms.length && !terms.every(function (t) { return haystack.indexOf(t) !== -1; })) {
|
|
return;
|
|
}
|
|
if (name === fold(self.query.trim())) {
|
|
exact = true;
|
|
}
|
|
var all = tag.chosen >= data.records;
|
|
var some = tag.chosen > 0 && !all;
|
|
var index = self.rows.length;
|
|
var node = make('div', 'itflow-select-option' + (all ? ' is-selected' : '') + (some ? ' is-mixed' : ''));
|
|
node.setAttribute('role', 'option');
|
|
node.setAttribute('data-row', index);
|
|
node.setAttribute('aria-selected', all ? 'true' : 'false');
|
|
node.id = 'itflow-tag-option-' + tag.id;
|
|
|
|
var box = make('span', 'itflow-select-checkbox');
|
|
box.appendChild(make('i', some ? 'fas fa-minus' : 'fas fa-check'));
|
|
node.appendChild(box);
|
|
|
|
// The icon on a wash of the tag's colour, as its pill looks
|
|
var badge = make('span', 'itflow-select-badge is-soft');
|
|
badge.style.setProperty('--itflow-select-color', tag.color || 'var(--bs-secondary)');
|
|
badge.appendChild(make('i', 'fas fa-' + tag.icon));
|
|
badge.setAttribute('aria-hidden', 'true');
|
|
node.appendChild(badge);
|
|
|
|
var text = make('span', 'itflow-select-text');
|
|
text.appendChild(make('span', 'itflow-select-label', tag.name));
|
|
var about = [tag.description || '', some ? tag.chosen + ' of ' + data.records : ''].filter(Boolean).join(' · ');
|
|
if (about) {
|
|
text.appendChild(make('span', 'itflow-select-description', about));
|
|
}
|
|
node.appendChild(text);
|
|
list.appendChild(node);
|
|
self.rows.push({ node: node, id: tag.id, tag: tag });
|
|
});
|
|
|
|
var typed = this.query.trim();
|
|
if (data.create && typed && !exact) {
|
|
var index = this.rows.length;
|
|
var create = make('div', 'itflow-select-option itflow-select-create');
|
|
create.setAttribute('role', 'option');
|
|
create.setAttribute('data-row', index);
|
|
create.id = 'itflow-tag-option-new';
|
|
var plus = make('span', 'itflow-select-icon');
|
|
plus.appendChild(make('i', 'fas fa-plus'));
|
|
create.appendChild(plus);
|
|
var createText = make('span', 'itflow-select-text');
|
|
createText.appendChild(make('span', 'itflow-select-label', 'Make tag "' + typed + '"'));
|
|
create.appendChild(createText);
|
|
list.appendChild(create);
|
|
this.rows.push({ node: create, create: typed });
|
|
}
|
|
|
|
if (!this.rows.length) {
|
|
list.appendChild(make('div', 'itflow-select-empty', typed ? 'No tags match' : 'No tags yet' + (data.create ? ' - type a name to make one' : '')));
|
|
}
|
|
|
|
// Footer: how many the record has, and where tags are managed
|
|
this.footer.textContent = '';
|
|
var chosen = data.tags.filter(function (tag) { return tag.chosen >= data.records; }).length;
|
|
this.footer.appendChild(make('span', 'me-auto', chosen ? chosen + ' tagged' : 'No tags'));
|
|
if (data.manage) {
|
|
var manage = make('a', 'btn btn-sm btn-link', 'Manage tags');
|
|
manage.href = data.manage;
|
|
this.footer.appendChild(manage);
|
|
}
|
|
// Saving happens on every tick, so this only closes - no reaching
|
|
// past the panel to click off it
|
|
var done = make('button', 'btn btn-sm btn-primary itflow-tag-done', 'Done');
|
|
done.type = 'button';
|
|
done.addEventListener('click', function () {
|
|
self.close(true);
|
|
});
|
|
this.footer.appendChild(done);
|
|
|
|
var active = -1;
|
|
if (activeId !== null) {
|
|
this.rows.forEach(function (row, i) {
|
|
if (row.id === activeId) {
|
|
active = i;
|
|
}
|
|
});
|
|
}
|
|
if (keep) {
|
|
list.scrollTop = scroll;
|
|
}
|
|
this.setActive(active === -1 ? (this.rows.length ? 0 : -1) : active, false);
|
|
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];
|
|
if (!row) {
|
|
this.input.removeAttribute('aria-activedescendant');
|
|
return;
|
|
}
|
|
row.node.classList.add('is-active');
|
|
this.input.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':
|
|
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;
|
|
}
|
|
};
|
|
|
|
/* Tick or untick a tag - or make the typed one - and save it. */
|
|
Picker.prototype.pick = function (index) {
|
|
var self = this;
|
|
var row = this.rows[index];
|
|
if (!row) {
|
|
return;
|
|
}
|
|
var data = this.data;
|
|
var post = { csrf_token: this.csrf, entity: this.entity, ids: this.ids, filter: filterFor(this.entity, this.ids) };
|
|
var before = null;
|
|
|
|
if (row.create) {
|
|
post.tag_picker_create = 1;
|
|
post.name = row.create;
|
|
} else {
|
|
// A mixed tag goes on everywhere; a full one comes off everywhere
|
|
var on = row.tag.chosen < data.records;
|
|
post.tag_picker_set = 1;
|
|
post.tag_id = row.id;
|
|
post.on = on ? 1 : 0;
|
|
before = row.tag.chosen;
|
|
row.tag.chosen = on ? data.records : 0;
|
|
this.draw(true);
|
|
}
|
|
|
|
this.busy++;
|
|
itflowPost('ajax.php', post, function (text) {
|
|
self.busy--;
|
|
var result;
|
|
try {
|
|
result = JSON.parse(text);
|
|
} catch (e) {
|
|
result = null;
|
|
}
|
|
if (!result || !result.pills) {
|
|
failed();
|
|
return;
|
|
}
|
|
redraw(self.entity, result.pills);
|
|
if (row.create && open === self) {
|
|
// Load the list again so the new tag sits in its place, ticked
|
|
self.query = '';
|
|
self.input.value = '';
|
|
itflowGet('ajax.php', { tag_picker: 1, entity: self.entity, ids: self.ids }, function (fresh) {
|
|
try {
|
|
self.data = JSON.parse(fresh);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
if (open === self) {
|
|
self.draw(false);
|
|
}
|
|
});
|
|
}
|
|
}, failed);
|
|
|
|
function failed() {
|
|
self.busy--;
|
|
if (before !== null) {
|
|
row.tag.chosen = before;
|
|
if (open === self) {
|
|
self.draw(true);
|
|
}
|
|
}
|
|
self.fail('The tag change was not saved - try again.');
|
|
}
|
|
};
|
|
|
|
/* ---------- wiring ---------- */
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var button = e.target.closest('[data-tag-picker]');
|
|
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 panel closes it - and that click does nothing else.
|
|
* Otherwise closing the picker by clicking beside it could land on a
|
|
* row link, a pill or a button and take you somewhere.
|
|
*/
|
|
var swallow = false;
|
|
function outside(e) {
|
|
if (open && !open.panel.contains(e.target) && !e.target.closest('[data-tag-picker]')) {
|
|
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) {
|
|
// A fresh press that did not close a picker clears any leftover flag
|
|
// (a mousedown whose click never came, e.g. a drag off the page)
|
|
if (!open && !e.target.closest('[data-tag-picker]')) {
|
|
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);
|
|
document.addEventListener('hide.bs.modal', function (e) {
|
|
if (open && e.target.contains(open.anchor)) {
|
|
open.close(false);
|
|
}
|
|
});
|
|
|
|
window.itflowTagPicker = true;
|
|
})();
|