Files
itflow/js/category_form.js

276 lines
12 KiB
JavaScript

// New Category and Edit Category (admin/modals/category/category_form.php).
// The preview shows the category as it will look on records while its name,
// colour and icon change - an asset status as its badge, anything else its
// icon (or a dot) in its colour, as categoryChip() in functions/categories.php
// draws it. "None" is a choice for both. A swatch, the colour picker or a
// typed hex sets the colour; the search narrows the icons, and a whole Font
// Awesome icon name that isn't in the list can be used too. Renaming a kind
// held by name says the records will be renamed with it. The server checks
// it all again.
//
// Opened from the + beside a picker in a form (categoryAddButtonHtml() in
// functions/categories.php - js/app.js notes which picker), New Category
// saves in the background: the new category goes into that picker, picked,
// and the window closes, leaving the form underneath as it was. A refusal
// (a name already taken) shows in the window.
//
// Wrapped in an IIFE because a modal can be opened, closed and opened again
// in one page load, which re-runs this file.
(function () {
// Dark or white text on a #rrggbb colour - tagTextColor() in functions/tags.php
function textColor(value) {
const channel = function (hex) {
const c = parseInt(hex, 16) / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
const luminance = 0.2126 * channel(value.substr(1, 2)) + 0.7152 * channel(value.substr(3, 2)) + 0.0722 * channel(value.substr(5, 2));
return luminance > 0.4 ? '#212529' : '#fff';
}
// Whether Font Awesome draws an icon by this name
function iconExists(probe, name) {
probe.className = 'fas fa-' + name;
const content = window.getComputedStyle(probe, '::before').content;
return content !== '' && content !== 'none' && content !== 'normal';
}
// #rrggbb from what was typed - '' for none, null when it isn't a colour yet
function hexColor(value) {
value = String(value).trim().toLowerCase();
if (value === '') {
return '';
}
if (value.charAt(0) !== '#') {
value = '#' + value;
}
if (/^#[0-9a-f]{3}$/.test(value)) {
value = '#' + value[1] + value[1] + value[2] + value[2] + value[3] + value[3];
}
return /^#[0-9a-f]{6}$/.test(value) ? value : null;
}
document.querySelectorAll('[data-category-form]:not([data-wired])').forEach(function (form) {
form.setAttribute('data-wired', '1');
const preview = form.querySelector('[data-category-preview]');
// An asset status's badge - its built-in tone when it has no colour of its own
const badge = preview.dataset.categoryBadge || '';
const name = form.querySelector('[data-category-name]');
const nameWas = form.dataset.categoryNameWas || '';
const renameNote = form.querySelector('[data-category-rename-note]');
const color = form.querySelector('[data-category-color]');
const pick = form.querySelector('[data-category-color-pick]');
const hex = form.querySelector('[data-category-color-hex]');
const swatches = form.querySelectorAll('[data-category-swatch]');
const icon = form.querySelector('[data-category-icon]');
const search = form.querySelector('[data-category-icon-search]');
const choices = form.querySelectorAll('[data-category-icon-choice]');
const custom = form.querySelector('[data-category-icon-custom]');
const none = form.querySelector('[data-category-icon-none]');
const probe = document.createElement('i');
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
form.appendChild(probe);
// Redraw the preview from the fields
function draw() {
const value = color.value;
const label = name.value.trim() || 'New category';
if (badge) {
const pill = preview.querySelector('.badge');
pill.textContent = label;
pill.className = 'badge rounded-pill fw-normal itflow-category-badge' + (value ? '' : ' text-bg-' + badge);
pill.style.backgroundColor = value;
pill.style.color = value ? textColor(value) : '';
return;
}
// Its icon in its colour, or a dot of it
const old = preview.querySelector('.itflow-category-mark');
const mark = document.createElement(icon.value ? 'i' : 'span');
mark.className = icon.value ? 'fas fa-fw fa-' + icon.value + ' itflow-category-mark' : 'itflow-category-dot itflow-category-mark';
mark.setAttribute('aria-hidden', 'true');
mark.style[icon.value ? 'color' : 'backgroundColor'] = value;
old.replaceWith(mark);
preview.querySelector('.itflow-category-name').textContent = label;
}
function setColor(value, from) {
value = hexColor(value);
if (value === null) {
return;
}
color.value = value;
if (from !== pick && value !== '') {
pick.value = value;
}
if (from !== hex) {
hex.value = value;
}
swatches.forEach(function (swatch) {
const on = swatch.dataset.categorySwatch === value;
swatch.classList.toggle('active', on);
swatch.setAttribute('aria-pressed', on ? 'true' : 'false');
});
draw();
}
function setIcon(value) {
icon.value = value;
choices.forEach(function (choice) {
const on = choice.dataset.categoryIconChoice === value;
choice.classList.toggle('active', on);
choice.setAttribute('aria-pressed', on ? 'true' : 'false');
});
custom.classList.toggle('active', value !== '' && custom.dataset.icon === value);
draw();
}
function filterIcons() {
const term = search.value.trim().toLowerCase().replace(/^(fa[srb]?\s+)?fa-/, '').replace(/\s+/g, '-');
let shown = 0;
let exact = false;
choices.forEach(function (choice) {
const value = choice.dataset.categoryIconChoice;
// "None" stays while nothing is searched
const match = term === '' || (value !== '' && value.indexOf(term) !== -1);
choice.hidden = !match;
shown += match ? 1 : 0;
exact = exact || value === term;
});
const usable = term !== '' && !exact && /^[a-z0-9-]+$/.test(term) && iconExists(probe, term);
custom.hidden = !usable;
if (usable) {
custom.dataset.icon = term;
custom.querySelector('i').className = 'fas fa-fw fa-' + term + ' me-1';
custom.querySelector('span').textContent = 'Use ' + term;
custom.classList.toggle('active', icon.value === term);
}
none.hidden = shown > 0 || usable;
return shown;
}
name.addEventListener('input', function () {
draw();
if (renameNote) {
renameNote.hidden = name.value.trim() === '' || name.value.trim() === nameWas;
}
});
swatches.forEach(function (swatch) {
swatch.addEventListener('click', function () {
setColor(swatch.dataset.categorySwatch);
});
});
pick.addEventListener('input', function () {
setColor(pick.value, pick);
});
hex.addEventListener('input', function () {
setColor(hex.value, hex);
});
hex.addEventListener('change', function () {
hex.value = color.value;
});
// Enter in a colour or search box picks, rather than saving
hex.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
hex.value = color.value;
}
});
choices.forEach(function (choice) {
choice.addEventListener('click', function () {
setIcon(choice.dataset.categoryIconChoice);
});
});
custom.addEventListener('click', function () {
setIcon(custom.dataset.icon);
});
search.addEventListener('input', filterIcons);
search.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') {
return;
}
e.preventDefault();
const visible = Array.prototype.filter.call(choices, function (choice) {
return !choice.hidden && choice.dataset.categoryIconChoice !== '';
});
if (!custom.hidden) {
setIcon(custom.dataset.icon);
} else if (visible.length === 1) {
setIcon(visible[0].dataset.categoryIconChoice);
}
});
// From the + beside a picker - saved in the background, into that picker
const inline = form.closest('form[data-category-inline]');
if (!inline) {
return;
}
const picker = window.itflowCategoryTarget || null;
window.itflowCategoryTarget = null;
const error = inline.querySelector('[data-category-error]');
inline.addEventListener('submit', function (e) {
if (!picker || !picker.isConnected) {
// Nothing to put it in - a plain post, back to the page
const flag = inline.querySelector('input[name="inline"]');
if (flag) {
flag.remove();
}
return;
}
e.preventDefault();
const button = inline.querySelector('button[name="add_category"]');
button.disabled = true;
const data = new FormData(inline);
data.append('add_category', '1');
fetch(inline.getAttribute('action'), { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
.then(function (res) {
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
return res.json();
})
.then(function (result) {
if (!result.ok) {
throw new Error(result.error || 'It was not saved');
}
// Picked in the picker - an option already there for it is used as it is
let option = Array.prototype.find.call(picker.options, function (o) {
return o.value === result.value;
});
if (!option) {
const holder = document.createElement('select');
holder.innerHTML = result.option;
option = holder.options[0];
picker.appendChild(option);
}
if (!picker.multiple) {
Array.prototype.forEach.call(picker.options, function (o) {
o.selected = false;
});
}
option.selected = true;
picker.dispatchEvent(new Event('input', { bubbles: true }));
picker.dispatchEvent(new Event('change', { bubbles: true }));
const modal = inline.closest('.modal');
if (modal && window.bootstrap) {
bootstrap.Modal.getOrCreateInstance(modal).hide();
}
if (typeof itflowToast === 'function') {
itflowToast(result.message, 'success');
}
})
.catch(function (err) {
button.disabled = false;
if (error) {
error.textContent = err.message.indexOf('HTTP ') === 0 ? 'It could not be saved - reload the page and try again.' : err.message;
error.hidden = false;
}
});
});
});
})();