mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-03-11 09:04:54 +00:00
- Currently when HTMX tries to fetch a page and fails, it silently fails and does not perform any further operations. So, the installation page is stuck. This is also leading to functional test case failures. - Handle errors in page response and errors while making requests and reload the entire page. This will result in browser showing appropriate error page. User will understand that the operation has failed. - Also add HTMX event listeners on the body as shown in HTMX documentation. Tests: - Press install. After installation process has started, stop Apache web server. Without the patch, HTMX fails silently and the installation progress is shown indefinitely. - With the patch applied, the connection error page is properly shown. Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
337 lines
12 KiB
JavaScript
337 lines
12 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
/*
|
|
This file is part of FreedomBox.
|
|
|
|
@licstart The following is the entire license notice for the
|
|
JavaScript code in this page.
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
@licend The above is the entire license notice
|
|
for the JavaScript code in this page.
|
|
*/
|
|
|
|
/*
|
|
* Remove the 'no-js' class from the <body> element. CSS utilizing this can
|
|
* create different rules when Javascript is available and when it is not. This
|
|
* functionality was provided by the Modernizr library earlier.
|
|
*/
|
|
document.addEventListener('DOMContentLoaded', function (event) {
|
|
const html = document.querySelector('html');
|
|
html.classList.remove('no-js');
|
|
html.classList.add('js');
|
|
});
|
|
|
|
/*
|
|
* Return all submit buttons on the page
|
|
*/
|
|
function getSubmitButtons() {
|
|
return document.querySelectorAll(
|
|
"form input[type='submit'], form button[type='submit'].toggle-button");
|
|
}
|
|
|
|
/*
|
|
* Disable submit button on click.
|
|
*/
|
|
function onSubmitAddProgress(event) {
|
|
// Using activeElement is not reliable. If the user presses Enter on a text
|
|
// field, activeElement with be that text field. However, we do safety
|
|
// checks and fallback to not disabling/animating the submit button, which
|
|
// is okay.
|
|
button = document.activeElement;
|
|
if (!button.classList.contains('btn') ||
|
|
button.classList.contains('btn-link') ||
|
|
button.classList.contains('no-running-status') ||
|
|
button.classList.contains('pull-right') ||
|
|
button.hasAttribute('disabled')) {
|
|
return;
|
|
}
|
|
|
|
// Don't disable the submit button immediately as that will prevent the
|
|
// button from being sent in the HTTP request. Instead schedule disabling
|
|
// for the next event loop run which will happen after current event is
|
|
// processed.
|
|
window.setTimeout(() => {
|
|
if (button.tagName == "INPUT") {
|
|
// For push buttons
|
|
const beforeElement = document.createElement('div');
|
|
beforeElement.classList.add('running-status-button-before');
|
|
button.classList.forEach(className => {
|
|
if (className.startsWith('btn-')) {
|
|
beforeElement.classList.add(className);
|
|
}
|
|
});
|
|
button.parentNode.insertBefore(beforeElement, button);
|
|
} else if (button.tagName == "BUTTON") {
|
|
// For toggle buttons
|
|
button.classList.toggle('toggle-button--toggled');
|
|
}
|
|
|
|
button.classList.add('running-status-button');
|
|
|
|
// Disable all form submit buttons on the page
|
|
for (const formbutton of getSubmitButtons()) {
|
|
if (!(formbutton.classList.contains('btn-link') ||
|
|
formbutton.classList.contains('no-running-status') ||
|
|
formbutton.hasAttribute('disabled'))) {
|
|
formbutton.classList.add('temporarily-disabled');
|
|
formbutton.setAttribute('disabled', 'disabled');
|
|
}
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function (event) {
|
|
for (const button of getSubmitButtons()) {
|
|
// Don't listen for 'click' event on buttons as they are triggered
|
|
// even when the form is invalid.
|
|
button.form.addEventListener('submit', onSubmitAddProgress);
|
|
}
|
|
});
|
|
|
|
/*
|
|
* Clear button disabling on the page.
|
|
*/
|
|
function clearButtonDisabling(event) {
|
|
for (const button of getSubmitButtons()) {
|
|
button.classList.remove('running-status-button');
|
|
if (button.classList.contains('temporarily-disabled')) {
|
|
button.classList.remove('temporarily-disabled');
|
|
button.removeAttribute('disabled');
|
|
}
|
|
}
|
|
|
|
const beforeSelector = ".running-status-button-before";
|
|
const beforeElements = document.querySelectorAll(beforeSelector);
|
|
for (const element of beforeElements) {
|
|
element.remove();
|
|
}
|
|
};
|
|
|
|
// When using back/forward browser's bfcache is used and pages won't receive
|
|
// 'load' events. Instead a 'pageshow' event is available. When a user does
|
|
// back/forward we want them to be able to submit the forms again. So clear all
|
|
// the button disabling.
|
|
window.addEventListener('pageshow', clearButtonDisabling);
|
|
|
|
/*
|
|
* Select all option for multiple checkboxes.
|
|
*/
|
|
document.addEventListener('DOMContentLoaded', function (event) {
|
|
// Django < 4.0 generates <ul> and <li> where as Django >= 4.0 generates <div>s
|
|
let parents = document.querySelectorAll('ul.has-select-all,div.has-select-all');
|
|
for (const parent of parents) {
|
|
let childElementType = 'div';
|
|
if (parent.tagName.toLowerCase() == 'ul')
|
|
childElementType = 'li';
|
|
|
|
let selectAllItem = document.createElement(childElementType);
|
|
|
|
let label = document.createElement('label');
|
|
label.for = "select_all";
|
|
label.setAttribute('class', 'select-all-label');
|
|
|
|
let checkbox = document.createElement('input');
|
|
checkbox.type = "checkbox";
|
|
checkbox.setAttribute('class', 'select-all');
|
|
|
|
label.appendChild(checkbox);
|
|
selectAllItem.appendChild(label);
|
|
|
|
parent.insertBefore(selectAllItem, parent.childNodes[0]);
|
|
setSelectAllValue(parent);
|
|
|
|
checkbox.addEventListener('change', onSelectAllChanged);
|
|
|
|
options = parent.querySelectorAll('input.has-select-all');
|
|
for (const option of options) {
|
|
option.addEventListener('change', onSelectAllOptionsChanged);
|
|
}
|
|
}
|
|
});
|
|
|
|
// When there is a change on the "select all" checkbox, set the checked property
|
|
// of all the checkboxes to the value of the "select all" checkbox
|
|
function onSelectAllChanged(event) {
|
|
const selectAllCheckbox = event.currentTarget;
|
|
const parent = selectAllCheckbox.parentElement.parentElement.parentElement;
|
|
const options = parent.querySelectorAll('input.has-select-all');
|
|
for (const option of options) {
|
|
option.checked = selectAllCheckbox.checked;
|
|
}
|
|
}
|
|
|
|
// When there is a change on a checkbox controlled by a select all checkbox,
|
|
// update the value of checkbox.
|
|
function onSelectAllOptionsChanged(event) {
|
|
const parent = event.currentTarget.parentElement.parentElement.parentElement;
|
|
setSelectAllValue(parent);
|
|
}
|
|
|
|
// Set/reset the checked property of "select all" checkbox based on whether all
|
|
// checkboxes it controls are checked.
|
|
function setSelectAllValue(parent) {
|
|
const options = parent.querySelectorAll('input.has-select-all');
|
|
let enableSelectAll = true;
|
|
for (const option of options) {
|
|
if (!option.checked) {
|
|
enableSelectAll = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
parent.querySelector('.select-all').checked = enableSelectAll;
|
|
}
|
|
|
|
/*
|
|
* Check whether an app is available on its setup page.
|
|
*/
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
const checkingElement = document.querySelector('.app-checking-availability');
|
|
if (!checkingElement)
|
|
return;
|
|
|
|
// App does not need setup, it likely needs upgrade
|
|
const setupState = checkingElement.getAttribute('data-setup-state');
|
|
if (setupState !== 'needs-setup')
|
|
return;
|
|
|
|
const appId = checkingElement.getAttribute('data-app-id');
|
|
checkingElement.classList.remove('d-none');
|
|
|
|
function setInstallButtonState(enable) {
|
|
const installButton = document.querySelector('.install-button');
|
|
if (enable)
|
|
installButton?.removeAttribute('disabled');
|
|
else
|
|
installButton?.setAttribute('disabled', 'disabled');
|
|
}
|
|
|
|
function error() {
|
|
const element = document.querySelector('.app-checking-availability-error');
|
|
element.classList.remove('d-none');
|
|
checkingElement.classList.add('d-none');
|
|
setInstallButtonState(true); // Allow trying installation
|
|
}
|
|
|
|
try {
|
|
setInstallButtonState(false);
|
|
const response = await fetch(`/plinth/is-available/${appId}/`, {
|
|
timeout: 2 * 60 * 1000 // 2 minutes
|
|
});
|
|
|
|
checkingElement.classList.add('d-none');
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.is_available === true) {
|
|
setInstallButtonState(true);
|
|
} else if (data.is_available === false) {
|
|
document.querySelector('.app-unavailable').classList.remove('d-none');
|
|
setInstallButtonState(false);
|
|
} else {
|
|
error();
|
|
}
|
|
} else {
|
|
error();
|
|
}
|
|
} catch {
|
|
error();
|
|
}
|
|
});
|
|
|
|
/*
|
|
* Text areas showing log lines have special behavior.
|
|
*/
|
|
document.addEventListener('DOMContentLoaded', function (event) {
|
|
const logElements = document.querySelectorAll('textarea.log');
|
|
|
|
// Scroll the textarea to the bottom so that last lines are visible.
|
|
for (const element of logElements) {
|
|
element.scrollTop = element.scrollHeight;
|
|
}
|
|
});
|
|
|
|
/*
|
|
* Close notifications dropdown when clicking outside, like the other dropdowns.
|
|
*/
|
|
document.addEventListener('click', function (event) {
|
|
// Ignore if notifications dropdown is not open
|
|
const notifications = document.querySelector('.notifications');
|
|
if (!notifications?.classList.contains('show')) {
|
|
return;
|
|
}
|
|
|
|
// Ignore if the click happened inside the notifications area or on the toggle button
|
|
const toggles = document.querySelectorAll('[data-bs-target=".notifications"]');
|
|
const clickedInsideToggle = Array.from(toggles).some(toggle => toggle.contains(event.target));
|
|
if (notifications.contains(event.target) || clickedInsideToggle) {
|
|
return;
|
|
}
|
|
|
|
if (typeof bootstrap !== 'undefined' && bootstrap.Collapse) {
|
|
let bsCollapse = bootstrap.Collapse.getInstance(notifications);
|
|
bsCollapse.hide();
|
|
}
|
|
});
|
|
|
|
/*
|
|
* Detect when hx-select element is not found in the response and reload the
|
|
* page. HTMX unfortunately does not seem to provide a JS event when hx-select
|
|
* element is not found in the response. However, in htmx:afterSwap event we can
|
|
* see that the target has no children and choose to refresh the whole page.
|
|
*/
|
|
document.body.addEventListener('htmx:afterSwap', function (event) {
|
|
const target = event.detail.target;
|
|
if (target && target.children.length === 0) {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
|
|
/*
|
|
* If an error occurs during a HTMX request, then reload the page. It won't be a
|
|
* silent failure for the user and will imitate the behavior that was present
|
|
* before HTMX was introduced. For functional tests, this means that a clear
|
|
* browser is shown. Tests can decide to reload the page until the error is
|
|
* resolved.
|
|
*/
|
|
document.body.addEventListener('htmx:responseError', function (event) {
|
|
window.location.reload();
|
|
});
|
|
|
|
document.body.addEventListener("htmx:sendError", function(event) {
|
|
window.location.reload();
|
|
});
|
|
|
|
/*
|
|
* Decrement notification counter badge when a notification is dismissed via
|
|
* HTMX.
|
|
*/
|
|
document.addEventListener('notification-dismissed', function (evt) {
|
|
// There are 2 badges on the page. One for mobile navbar and one for desktop
|
|
// navbar.
|
|
const badges = document.querySelectorAll('.notifications-dropdown .badge');
|
|
|
|
badges.forEach(badge => {
|
|
const count = parseInt(badge.textContent.trim());
|
|
|
|
if (count > 1) {
|
|
badge.textContent = count - 1;
|
|
} else {
|
|
const dropdowns = document.querySelectorAll('.notifications-dropdown');
|
|
dropdowns.forEach(dropdown => dropdown.remove());
|
|
}
|
|
});
|
|
});
|