1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00

refactor(templates): the shared page scripts load from static files

A step towards a Content-Security-Policy without inline scripts. The
scripts every page or many pages share now load from static files: the
error focus of base.html, the please-wait dialog, the sign-in forms'
password toggle and busy button, the jobs pages' reload while a job
runs, and the start of the job panel and of the VM list's state poll,
whose values come as json_script data. Cancel links go back through a
data-history-back attribute instead of a javascript: URL. The IE8
html5shiv/respond.js block, X-UA-Compatible and shrink-to-fit are gone:
no supported browser reads them.
This commit is contained in:
catborise 2026-11-02 08:47:58 +03:00
parent ab15fafe9c
commit 393aecb8f1
23 changed files with 112 additions and 137 deletions

View file

@ -21,7 +21,7 @@
</div>
<div class="card-footer">
<div class="float-end">
<a class="btn btn-primary" href="javascript:history.back()">{% bs_icon 'x' %}
<a class="btn btn-primary" href="#" data-history-back>{% bs_icon 'x' %}
{% trans "Cancel" %}</a>
<button type="submit" form="password-change" class="btn btn-success">
{% bs_icon 'check2' %} {% trans "Change" %}

View file

@ -22,7 +22,7 @@
</div>
<div class="card-footer">
<div class="mb-0 float-end">
<a class="btn btn-primary" href="javascript:history.back()">{% bs_icon 'arrow-left' %} {% trans "Cancel" %}</a>
<a class="btn btn-primary" href="#" data-history-back>{% bs_icon 'arrow-left' %} {% trans "Cancel" %}</a>
<button type="submit" form="create-update" class="btn btn-success">
{% bs_icon 'envelope-open' %} {% trans "Send" %}
</button>

View file

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% load django_bootstrap5 %}
@ -55,7 +56,7 @@
<div class="form-text">{% trans "Leave empty if you have not set up two-factor authentication." %}</div>
</div>
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit">
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit" data-busy-text="{% trans "Verifying..." %}">
<i class="bi bi-shield-lock-fill me-1"></i>
<span id="btn-text">{% trans "Verify & Sign In" %}</span>
</button>
@ -72,31 +73,5 @@
{% endblock %}
{% block script %}
<script>
$(document).ready(function() {
// Toggle password visibility
$('#btn-toggle-pwd').on('click', function(e) {
e.preventDefault();
var $pwd = $('#id_password');
var $icon = $('#toggle-icon');
if ($pwd.attr('type') === 'password') {
$pwd.attr('type', 'text');
$icon.removeClass('bi-eye').addClass('bi-eye-slash');
} else {
$pwd.attr('type', 'password');
$icon.removeClass('bi-eye-slash').addClass('bi-eye');
}
});
// Submit loading state
$('#form-signin').on('submit', function() {
var $btn = $('#btn-signin');
$btn.prop('disabled', true);
$btn.html(
'<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>' +
'{% trans "Verifying..." %}'
);
});
});
</script>
<script src="{% static 'js/login_form.js' %}"></script>
{% endblock script %}

View file

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "WebVirtCloud" %} - {% trans "Sign In" %}{% endblock title %}
@ -44,7 +45,7 @@
<input type="hidden" name="next" value="{{ next }}">
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit">
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit" data-busy-text="{% trans "Signing in..." %}">
<i class="bi bi-box-arrow-in-right me-1"></i>
<span id="btn-text">{% trans "Sign In" %}</span>
</button>
@ -61,31 +62,5 @@
{% endblock %}
{% block script %}
<script>
$(document).ready(function() {
// Toggle password visibility
$('#btn-toggle-pwd').on('click', function(e) {
e.preventDefault();
var $pwd = $('#id_password');
var $icon = $('#toggle-icon');
if ($pwd.attr('type') === 'password') {
$pwd.attr('type', 'text');
$icon.removeClass('bi-eye').addClass('bi-eye-slash');
} else {
$pwd.attr('type', 'password');
$icon.removeClass('bi-eye-slash').addClass('bi-eye');
}
});
// Submit loading state
$('#form-signin').on('submit', function() {
var $btn = $('#btn-signin');
$btn.prop('disabled', true);
$btn.html(
'<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>' +
'{% trans "Signing in..." %}'
);
});
});
</script>
<script src="{% static 'js/login_form.js' %}"></script>
{% endblock script %}

View file

@ -2,4 +2,4 @@
{# the VM list reloads when its VMs change: vm_states.js #}
{{ states_url|json_script:"vm-states-url" }}
<script src="{% static 'js/vm_states.js' %}"></script>
<script>followVmStates(JSON.parse(document.getElementById("vm-states-url").textContent));</script>
<script src="{% static 'js/vm_states_start.js' %}"></script>

View file

@ -116,7 +116,7 @@ class PageControlsTestCase(PageControls, TestCase):
viewer = self.user("ctl_js_viewer", perms=("view_instances",))
for user in (superuser, viewer):
self.controls(user)
scripts = lxml_html.fromstring(self.last_page).xpath("//script[not(@src)]/text()")
scripts = lxml_html.fromstring(self.last_page).xpath("//script[not(@src) and not(@type='application/json')]/text()")
for number, script in enumerate(scripts):
with self.subTest(user=user.username, script=number):
result = subprocess.run(

View file

@ -134,8 +134,6 @@ class TemplateHtmlConcatenationTestCase(TestCase):
# .val() / DOM APIs for server data instead).
HTML_SINK = re.compile(r"""\.html\(|innerHTML\s*=|append\(\s*['"]<""")
ALLOWED_HTML_SINKS = {
("accounts/templates/login.html", "$btn.html("): "static spinner markup",
("accounts/templates/accounts/otp_login.html", "$btn.html("): "static spinner markup",
("instances/templates/instance.html", "hostDevices.innerHTML = html;"): "the panel's own fragment, host_devices_form.html, escaped by Django",
("instances/templates/instance.html", "migratePlan.innerHTML = html;"): "the panel's own fragment, migrate_plan.html, escaped by Django",
}

View file

@ -0,0 +1,6 @@
// Reloads the page while it shows an active job, so the page follows the job to its end.
setInterval(function () {
if (!document.hidden && !document.querySelector("form :focus")) {
window.location.reload();
}
}, 5000);

View file

@ -0,0 +1,9 @@
// Starts the job panel of a resource page from its settings (job_panel_script tag).
(function () {
var settings = JSON.parse(document.getElementById("job-panel-settings").textContent);
window.resourceJobPanel = jobPanel(document.getElementById("job-panel"), {
resource: settings.resource,
job: function (id) { return settings.jobs + id + "/"; },
cancel: function (id) { return settings.jobs + id + "/cancel/"; },
}, settings.csrf_token, settings.remembered);
})();

View file

@ -1,8 +1,3 @@
{% load static %}
{# Reloads the page while it shows an active job, so the page follows the job to its end. #}
<script id="follow-active-jobs">
setInterval(function () {
if (!document.hidden && !document.querySelector("form :focus")) {
window.location.reload();
}
}, 5000);
</script>
<script id="follow-active-jobs" src="{% static 'operations/follow_active.js' %}"></script>

View file

@ -1,9 +1,4 @@
{% load static %}
{{ settings|json_script:"job-panel-settings" }}
<script src="{% static 'operations/job_panel.js' %}"></script>
<script>
window.resourceJobPanel = jobPanel(document.getElementById("job-panel"), {
resource: "{% url 'operation-resource' %}?key={{ resource|urlencode:'' }}",
job: function (id) { return "{% url 'operation-list' %}" + id + "/"; },
cancel: function (id) { return "{% url 'operation-list' %}" + id + "/cancel/"; },
}, "{{ csrf_token }}", {{ remembered|safe }});
</script>
<script src="{% static 'operations/job_panel_start.js' %}"></script>

View file

@ -1,7 +1,9 @@
import json
import uuid
from urllib.parse import quote
from django import template
from django.middleware.csrf import get_token
from django.urls import reverse
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from django.utils.timesince import timesince
@ -71,5 +73,9 @@ def wrap_at_slash(label):
@register.inclusion_tag("operations/job_panel_script.html", takes_context=True)
def job_panel_script(context, resource):
"""The job panel's script for the page of resource."""
return {"resource": resource, "csrf_token": context.get("csrf_token"),
"remembered": json.dumps(take_remembered(context["request"], resource))}
return {"settings": {
"resource": reverse("operation-resource") + "?key=" + quote(resource, safe=""),
"jobs": reverse("operation-list"),
"csrf_token": get_token(context["request"]),
"remembered": take_remembered(context["request"], resource),
}}

7
static/js/back_links.js Normal file
View file

@ -0,0 +1,7 @@
// A link marked data-history-back goes back to the previous page.
document.addEventListener("click", function (event) {
if (event.target.closest("[data-history-back]")) {
event.preventDefault();
history.back();
}
});

20
static/js/error_focus.js Normal file
View file

@ -0,0 +1,20 @@
// After a refused submission, start at the error. The places an error
// can be, most specific first: a refused field, its form's summary, an
// open dialog's copy of the messages (the page's are behind it), the
// page's messages. A page that sets itself up first has
// data-error-focus="manual" and calls this when it is ready.
const ERROR_PLACES = [".is-invalid", ".form-errors", ".modal.show .modal-messages .alert-danger",
"#messages .alert-danger"];
function focusFirstError() {
for (const selector of ERROR_PLACES) {
const target = [...document.querySelectorAll(selector)].find(e => e.offsetParent !== null);
if (target) {
if (!target.matches("input, select, textarea")) target.tabIndex = -1;
target.focus();
return;
}
}
}
window.focusFirstError = focusFirstError;
if (document.body.dataset.errorFocus !== "manual") focusFirstError();

19
static/js/login_form.js Normal file
View file

@ -0,0 +1,19 @@
// The sign-in forms (login, OTP login): the eye button shows or hides the
// password; while the form is sent, its button is disabled and says so.
document.getElementById("btn-toggle-pwd").addEventListener("click", function () {
var password = document.getElementById("id_password");
var shown = password.type === "password";
password.type = shown ? "text" : "password";
var icon = document.getElementById("toggle-icon");
icon.classList.toggle("bi-eye", !shown);
icon.classList.toggle("bi-eye-slash", shown);
});
document.getElementById("form-signin").addEventListener("submit", function () {
var button = document.getElementById("btn-signin");
button.disabled = true;
var spinner = document.createElement("span");
spinner.className = "spinner-border spinner-border-sm me-2";
spinner.setAttribute("aria-hidden", "true");
button.replaceChildren(spinner, button.dataset.busyText);
});

15
static/js/please_wait.js Normal file
View file

@ -0,0 +1,15 @@
// The please-wait dialog (pleasewaitdialog.html) of a page that submits a long request.
function showPleaseWaitDialog() {
var pleaseModal = new bootstrap.Modal(document.getElementById('pleaseWaitDialog'), {
keyboard: false
});
pleaseModal.show();
backgroundJobRunning = true;
}
function hidePleaseWaitDialog() {
var pleaseModal = new bootstrap.Modal(document.getElementById('pleaseWaitDialog'), {
keyboard: false
})
pleaseModal.hide();
backgroundJobRunning = false;
}

View file

@ -0,0 +1,2 @@
// Starts following the VM list's states (vm_states_poll.html).
followVmStates(JSON.parse(document.getElementById("vm-states-url").textContent));

View file

@ -5,8 +5,7 @@
{% get_current_language as LANGUAGE_CODE %}<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="WebVirtCloud panel for manage virtual machine">
<meta name="author" content="anatoliy.guskov@gmail.com">
@ -25,12 +24,6 @@
<!-- Custom Fonts -->
<link href="{% static "css/bootstrap-icons.min.css" %}" rel="stylesheet" type="text/css">
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
</head>
@ -90,28 +83,8 @@
<script src="{% static 'js/tablesort.min.js' %}"></script>
<script src="{% static 'js/tablesort.number.min.js' %}"></script>
<script src="{% static 'js/sort_tables.js' %}"></script>
<script>
// After a refused submission, start at the error. The places an error
// can be, most specific first: a refused field, its form's summary, an
// open dialog's copy of the messages (the page's are behind it), the
// page's messages. A page that sets itself up first has
// data-error-focus="manual" and calls this when it is ready.
const ERROR_PLACES = [".is-invalid", ".form-errors", ".modal.show .modal-messages .alert-danger",
"#messages .alert-danger"];
function focusFirstError() {
for (const selector of ERROR_PLACES) {
const target = [...document.querySelectorAll(selector)].find(e => e.offsetParent !== null);
if (target) {
if (!target.matches("input, select, textarea")) target.tabIndex = -1;
target.focus();
return;
}
}
}
window.focusFirstError = focusFirstError;
if (document.body.dataset.errorFocus !== "manual") focusFirstError();
</script>
<script src="{% static 'js/error_focus.js' %}"></script>
<script src="{% static 'js/back_links.js' %}"></script>
{% block script %}{% endblock %}
</body>
</html>

View file

@ -22,7 +22,7 @@
</div>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="javascript:history.back()">{% trans "Cancel" %}</a>
<a class="btn btn-outline-secondary" href="#" data-history-back>{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-danger">{% trans "Delete" %}</button>
</div>
</form>

View file

@ -19,7 +19,7 @@
<div class="card-body">{% bootstrap_form form %}</div>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="javascript:history.back()">{% trans "Cancel" %}</a>
<a class="btn btn-outline-secondary" href="#" data-history-back>{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
</div>
</form>

View file

@ -23,7 +23,7 @@
</div>
<div class="d-grid d-sm-flex gap-2 mt-4">
<a class="btn btn-primary" href="javascript:history.back()">
<a class="btn btn-primary" href="#" data-history-back>
<i class="bi bi-arrow-left me-1" aria-hidden="true"></i>
{% trans "Back" %}
</a>

View file

@ -1,4 +1,4 @@
{% load i18n %}
{% load i18n static %}
<!-- Please wait dialog -->
<div class="modal fade" id="pleaseWaitDialog" tabindex="-1" aria-labelledby="pleaseWaitDialogLabel" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-sm">
@ -15,19 +15,4 @@
</div>
</div>
</div>
<script>
function showPleaseWaitDialog() {
var pleaseModal = new bootstrap.Modal(document.getElementById('pleaseWaitDialog'), {
keyboard: false
});
pleaseModal.show();
backgroundJobRunning = true;
}
function hidePleaseWaitDialog() {
var pleaseModal = new bootstrap.Modal(document.getElementById('pleaseWaitDialog'), {
keyboard: false
})
pleaseModal.hide();
backgroundJobRunning = false;
}
</script>
<script src="{% static 'js/please_wait.js' %}"></script>

View file

@ -1,9 +1,8 @@
"""After a refused submission the page starts at the error: base.html's
script focuses the first visible invalid field, else the error message.
"""After a refused submission the page starts at the error: error_focus.js
focuses the first visible invalid field, else the error message.
The script runs in Node against a stand-in document."""
import json
import re
import shutil
import subprocess
import unittest
@ -11,11 +10,7 @@ from pathlib import Path
from django.conf import settings
SCRIPT = re.search(
r"<script>\s*(// After a refused submission.*?)</script>",
Path(settings.BASE_DIR, "templates/base.html").read_text(),
re.S,
).group(1)
SCRIPT = Path(settings.BASE_DIR, "static/js/error_focus.js").read_text()
HARNESS = """
const els = %s;