mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
420 lines
17 KiB
JavaScript
420 lines
17 KiB
JavaScript
/*
|
|
* The setup wizard (setup/index.php, setup/steps/) - what its forms do in the
|
|
* browser before they are sent. The server checks everything again
|
|
* (setup/setup_forms.php); this says so sooner, beside the field.
|
|
*
|
|
* form[data-setup-form] checked on submit - each field that isn't
|
|
* right says why under it, and the first gets
|
|
* the focus
|
|
* [data-setup-same-as=id] has to match that field (password again)
|
|
* [data-setup-image=kinds] a picture of those kinds, up to
|
|
* data-setup-image-max bytes, shown in
|
|
* [data-setup-preview=its id]
|
|
* [data-setup-browser-timezone] starts on the browser's timezone
|
|
* form[data-setup-region] shows money and a date as they will look
|
|
* [data-setup-reveal=id] a switch that shows that element while on
|
|
* form[data-restore-form] a backup dropped or picked, sent with its progress
|
|
*
|
|
* And every setup form shows it is working once sent, and is sent once.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
function selectValue(select, value) {
|
|
if (select.itflowSelect) {
|
|
select.itflowSelect.setValue(value);
|
|
} else {
|
|
select.value = value;
|
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
// Where a field says what is wrong with it - made the first time, after its input group
|
|
function feedbackFor(field) {
|
|
var anchor = field.closest('.input-group') || field;
|
|
var next = anchor.nextElementSibling;
|
|
if (next && next.matches('[data-setup-feedback]')) {
|
|
return next;
|
|
}
|
|
var feedback = document.createElement('div');
|
|
feedback.className = 'invalid-feedback';
|
|
feedback.setAttribute('data-setup-feedback', field.id || field.name);
|
|
anchor.insertAdjacentElement('afterend', feedback);
|
|
return feedback;
|
|
}
|
|
|
|
// What the server said about a field, under it on the page as it came back
|
|
function serverErrorFor(field) {
|
|
var col = field.closest('[class*="col"]');
|
|
return col ? col.querySelector('[data-field-error="' + field.name + '"]') : null;
|
|
}
|
|
|
|
function showField(field, message) {
|
|
field.classList.toggle('is-invalid', message !== '');
|
|
var feedback = feedbackFor(field);
|
|
feedback.textContent = message;
|
|
feedback.classList.toggle('d-block', message !== '');
|
|
var server = serverErrorFor(field);
|
|
if (server && message === '') {
|
|
server.classList.remove('d-block');
|
|
server.hidden = true;
|
|
}
|
|
}
|
|
|
|
function checkSameAs(field) {
|
|
var other = document.getElementById(field.dataset.setupSameAs);
|
|
field.setCustomValidity(other && field.value !== '' && field.value !== other.value ? 'The two passwords are not the same.' : '');
|
|
}
|
|
|
|
function checkImage(input) {
|
|
var file = input.files && input.files[0];
|
|
var message = '';
|
|
if (file) {
|
|
var accepted = (input.getAttribute('accept') || '').split(',').map(function (type) { return type.trim(); });
|
|
if (accepted.length && accepted.indexOf(file.type) === -1) {
|
|
message = "That isn't a " + input.dataset.setupImage + ' picture.';
|
|
} else if (file.size > Number(input.dataset.setupImageMax || 0)) {
|
|
message = 'That picture is too large - up to ' + Math.round(Number(input.dataset.setupImageMax) / 1048576) + ' MB.';
|
|
}
|
|
}
|
|
input.setCustomValidity(message);
|
|
var preview = document.querySelector('[data-setup-preview="' + input.id + '"]');
|
|
if (preview) {
|
|
if (preview.dataset.url) {
|
|
URL.revokeObjectURL(preview.dataset.url);
|
|
delete preview.dataset.url;
|
|
}
|
|
var img = preview.querySelector('img');
|
|
if (file && !message) {
|
|
if (!img) {
|
|
img = document.createElement('img');
|
|
img.alt = '';
|
|
preview.appendChild(img);
|
|
}
|
|
preview.dataset.url = URL.createObjectURL(file);
|
|
img.src = preview.dataset.url;
|
|
preview.classList.add('has-image');
|
|
} else {
|
|
if (img) {
|
|
img.remove();
|
|
}
|
|
preview.classList.remove('has-image');
|
|
}
|
|
}
|
|
return message;
|
|
}
|
|
|
|
function initForm(form) {
|
|
form.querySelectorAll('[data-setup-same-as]').forEach(function (field) {
|
|
var other = document.getElementById(field.dataset.setupSameAs);
|
|
[field, other].forEach(function (el) {
|
|
if (el) {
|
|
el.addEventListener('input', function () {
|
|
checkSameAs(field);
|
|
if (field.classList.contains('is-invalid')) {
|
|
showField(field, field.validationMessage);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
form.querySelectorAll('[data-setup-image]').forEach(function (input) {
|
|
input.addEventListener('change', function () {
|
|
showField(input, checkImage(input));
|
|
});
|
|
});
|
|
|
|
// A field put right stops saying it is wrong
|
|
form.querySelectorAll('input, select, textarea').forEach(function (field) {
|
|
['input', 'change'].forEach(function (type) {
|
|
field.addEventListener(type, function () {
|
|
if (field.classList.contains('is-invalid') && field.checkValidity()) {
|
|
showField(field, '');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
form.addEventListener('submit', function (e) {
|
|
var first = null;
|
|
form.querySelectorAll('[data-setup-same-as]').forEach(checkSameAs);
|
|
form.querySelectorAll('input, select, textarea').forEach(function (field) {
|
|
if (!field.willValidate || field.type === 'hidden') {
|
|
return;
|
|
}
|
|
var ok = field.checkValidity();
|
|
if (ok && !field.classList.contains('is-invalid')) {
|
|
return;
|
|
}
|
|
showField(field, ok ? '' : field.validationMessage);
|
|
if (!ok && !first) {
|
|
first = field;
|
|
}
|
|
});
|
|
if (first) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
if (first.itflowSelect && first.itflowSelect.control) {
|
|
first.itflowSelect.control.focus();
|
|
} else {
|
|
first.focus();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Sent once, and showing it is on its way - creating the tables or restoring takes a while
|
|
function initBusy(form) {
|
|
form.addEventListener('submit', function (e) {
|
|
if (e.defaultPrevented) {
|
|
return;
|
|
}
|
|
if (form.dataset.setupSent) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
form.dataset.setupSent = '1';
|
|
var button = e.submitter || form.querySelector('[type="submit"]');
|
|
if (button) {
|
|
button.classList.add('disabled');
|
|
button.setAttribute('aria-disabled', 'true');
|
|
button.insertAdjacentHTML('afterbegin', '<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>');
|
|
}
|
|
});
|
|
}
|
|
|
|
function initRegion(form) {
|
|
var locale = form.querySelector('[name="locale"]');
|
|
var currency = form.querySelector('[name="currency_code"]');
|
|
var timezone = form.querySelector('[name="timezone"]');
|
|
var sample = form.querySelector('[data-setup-sample]');
|
|
|
|
if (timezone && timezone.hasAttribute('data-setup-browser-timezone')) {
|
|
var browserZone = '';
|
|
try {
|
|
browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
|
} catch (err) {
|
|
browserZone = '';
|
|
}
|
|
var has = Array.prototype.some.call(timezone.options, function (option) { return option.value === browserZone; });
|
|
if (browserZone && has) {
|
|
selectValue(timezone, browserZone);
|
|
var col = timezone.closest('[class*="col"]');
|
|
var hint = col.querySelector('[data-setup-suggested="timezone"]');
|
|
if (!hint) {
|
|
hint = document.createElement('div');
|
|
hint.className = 'form-text';
|
|
hint.setAttribute('data-setup-suggested', 'timezone');
|
|
col.appendChild(hint);
|
|
}
|
|
hint.textContent = "Your browser's timezone.";
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
var tag = (locale.value || '').replace(/_/g, '-');
|
|
if (!sample || !tag || !currency.value) {
|
|
if (sample) {
|
|
sample.hidden = true;
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
sample.querySelector('[data-setup-sample-money]').textContent = new Intl.NumberFormat(tag, { style: 'currency', currency: currency.value }).format(1234.56);
|
|
sample.querySelector('[data-setup-sample-date]').textContent = new Intl.DateTimeFormat(tag, { dateStyle: 'medium', timeStyle: 'short', timeZone: timezone.value || undefined }).format(new Date());
|
|
sample.hidden = false;
|
|
} catch (err) {
|
|
sample.hidden = true;
|
|
}
|
|
}
|
|
[locale, currency, timezone].forEach(function (select) {
|
|
select.addEventListener('change', draw);
|
|
});
|
|
draw();
|
|
}
|
|
|
|
// A switch that shows what goes with it - the comment sent with sharing
|
|
function initReveal(toggle) {
|
|
var target = document.getElementById(toggle.dataset.setupReveal);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
var sync = function () {
|
|
target.hidden = !toggle.checked;
|
|
};
|
|
toggle.addEventListener('change', sync);
|
|
sync();
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
var units = ['bytes', 'KB', 'MB', 'GB'];
|
|
var i = 0;
|
|
while (bytes >= 1024 && i < units.length - 1) {
|
|
bytes /= 1024;
|
|
i++;
|
|
}
|
|
return (i === 0 ? bytes : bytes.toFixed(bytes < 10 ? 1 : 0)) + ' ' + units[i];
|
|
}
|
|
|
|
/*
|
|
* Restore a backup - dropped or picked, checked against what this server
|
|
* takes, then sent with how far it has got shown. The server answers in
|
|
* JSON (setupGo() in setup/index.php) with where to go next.
|
|
*/
|
|
function initRestore(form) {
|
|
var input = form.querySelector('input[name="backup_zip"]');
|
|
var drop = form.querySelector('[data-setup-drop]');
|
|
var tooBig = form.querySelector('[data-restore-too-big]');
|
|
var progress = form.querySelector('[data-restore-progress]');
|
|
var failed = form.querySelector('[data-restore-failed]');
|
|
if (!input || !drop || !progress) {
|
|
return;
|
|
}
|
|
var name = drop.querySelector('[data-setup-drop-name]');
|
|
var size = drop.querySelector('[data-setup-drop-size]');
|
|
var nameText = name.textContent;
|
|
var sizeText = size.textContent;
|
|
var max = Number(input.dataset.setupMaxBytes || 0);
|
|
var bar = progress.querySelector('.progress-bar');
|
|
var label = progress.querySelector('[data-restore-progress-label]');
|
|
var amount = progress.querySelector('[data-restore-progress-amount]');
|
|
|
|
function chosen() {
|
|
var file = input.files && input.files[0];
|
|
var message = '';
|
|
if (file && !/\.zip$/i.test(file.name)) {
|
|
message = 'Only .zip backup archives can be restored.';
|
|
}
|
|
var big = !!file && message === '' && max > 0 && file.size > max;
|
|
if (big) {
|
|
message = 'Larger than this server takes through a browser - restore it from the command line.';
|
|
}
|
|
input.setCustomValidity(message);
|
|
tooBig.hidden = !big;
|
|
drop.classList.toggle('has-file', !!file);
|
|
name.textContent = file ? file.name : nameText;
|
|
size.textContent = file ? formatBytes(file.size) : sizeText;
|
|
showField(input, message);
|
|
}
|
|
input.addEventListener('change', chosen);
|
|
|
|
['dragenter', 'dragover'].forEach(function (type) {
|
|
drop.addEventListener(type, function (e) {
|
|
e.preventDefault();
|
|
drop.classList.add('is-over');
|
|
});
|
|
});
|
|
['dragleave', 'drop'].forEach(function (type) {
|
|
drop.addEventListener(type, function (e) {
|
|
e.preventDefault();
|
|
drop.classList.remove('is-over');
|
|
});
|
|
});
|
|
drop.addEventListener('drop', function (e) {
|
|
if (e.dataTransfer && e.dataTransfer.files.length) {
|
|
input.files = e.dataTransfer.files;
|
|
chosen();
|
|
}
|
|
});
|
|
|
|
function fail(message) {
|
|
progress.hidden = true;
|
|
failed.textContent = message;
|
|
failed.hidden = false;
|
|
delete form.dataset.setupSent;
|
|
form.querySelectorAll('[aria-disabled="true"]').forEach(function (button) {
|
|
button.classList.remove('disabled');
|
|
button.removeAttribute('aria-disabled');
|
|
var spinner = button.querySelector('.spinner-border');
|
|
if (spinner) {
|
|
spinner.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
form.addEventListener('submit', function (e) {
|
|
if (e.defaultPrevented || !window.FormData || !window.XMLHttpRequest) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
|
|
var data = new FormData(form);
|
|
data.append('restore', '1');
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open('POST', form.getAttribute('action') || window.location.href);
|
|
xhr.setRequestHeader('X-Setup-Upload', '1');
|
|
|
|
failed.hidden = true;
|
|
progress.hidden = false;
|
|
label.textContent = 'Sending the backup';
|
|
bar.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
|
bar.style.width = '0%';
|
|
|
|
xhr.upload.addEventListener('progress', function (ev) {
|
|
if (ev.lengthComputable) {
|
|
var percent = Math.round(ev.loaded * 100 / ev.total);
|
|
bar.style.width = percent + '%';
|
|
bar.parentNode.setAttribute('aria-valuenow', percent);
|
|
amount.textContent = formatBytes(ev.loaded) + ' of ' + formatBytes(ev.total);
|
|
}
|
|
});
|
|
xhr.upload.addEventListener('load', function () {
|
|
label.textContent = 'Restoring - the database, then the files';
|
|
amount.textContent = '';
|
|
bar.style.width = '100%';
|
|
bar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
|
});
|
|
xhr.addEventListener('load', function () {
|
|
var next = '';
|
|
try {
|
|
next = JSON.parse(xhr.responseText).location || '';
|
|
} catch (err) {
|
|
next = '';
|
|
}
|
|
if (next) {
|
|
window.location.href = next;
|
|
return;
|
|
}
|
|
fail('The restore stopped with an error' + (xhr.status ? ' (' + xhr.status + ')' : '') + " - the web server's error log says why. Restoring from the command line has no time or size limit.");
|
|
});
|
|
xhr.addEventListener('error', function () {
|
|
fail('The upload was cut off. Check the connection - or restore from the command line, which has no size limit.');
|
|
});
|
|
xhr.send(data);
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
document.querySelectorAll('[data-setup-reveal]').forEach(initReveal);
|
|
document.querySelectorAll('form[data-setup-form]').forEach(initForm);
|
|
document.querySelectorAll('.itflow-setup form').forEach(initBusy);
|
|
document.querySelectorAll('form[data-setup-region]').forEach(initRegion);
|
|
document.querySelectorAll('form[data-restore-form]').forEach(initRestore);
|
|
}
|
|
|
|
// Back to a page the browser kept as it was left - sent and spinning - ready to send again
|
|
window.addEventListener('pageshow', function (e) {
|
|
if (!e.persisted) {
|
|
return;
|
|
}
|
|
document.querySelectorAll('.itflow-setup form[data-setup-sent]').forEach(function (form) {
|
|
delete form.dataset.setupSent;
|
|
form.querySelectorAll('[aria-disabled="true"]').forEach(function (button) {
|
|
button.classList.remove('disabled');
|
|
button.removeAttribute('aria-disabled');
|
|
var spinner = button.querySelector('.spinner-border');
|
|
if (spinner) {
|
|
spinner.remove();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|