diff --git a/instances/templates/create_flavors.html b/instances/templates/create_flavors.html index 7db98b04..111e9c86 100644 --- a/instances/templates/create_flavors.html +++ b/instances/templates/create_flavors.html @@ -8,8 +8,7 @@ {{ flavor.label }} · {{ flavor.vcpu }} vCPU · {{ flavor.memory }} MiB · {{ flavor.disk }} GiB
{% csrf_token %} + data-confirm="{% blocktrans with name=flavor.label %}Delete flavor {{ name }}? Only the size template goes; VMs made from it keep their size.{% endblocktrans %}">{% trans "Delete" %}
{% endfor %} diff --git a/instances/templates/instance_actions.html b/instances/templates/instance_actions.html index d837220e..0ad0031f 100644 --- a/instances/templates/instance_actions.html +++ b/instances/templates/instance_actions.html @@ -47,15 +47,13 @@ {% if "force_off" in actions %}
  • {% csrf_token %} + data-confirm="{% blocktrans with name=instance.name %}Force off {{ name }}? It stops at once, as after a power cut; what the guest has not written to disk is lost.{% endblocktrans %}">{% bs_icon 'power' %} {% trans "Force off" %}
  • {% endif %} {% if "powercycle" in actions %}
  • {% csrf_token %} + data-confirm="{% blocktrans with name=instance.name %}Power cycle {{ name }}? It stops at once, as after a power cut, and starts again; what the guest has not written to disk is lost.{% endblocktrans %}">{% bs_icon 'repeat' %} {% trans "Power cycle" %}
  • {% endif %} diff --git a/instances/templates/instances/nic_qos_form.html b/instances/templates/instances/nic_qos_form.html index 6042c700..d0e225c9 100644 --- a/instances/templates/instances/nic_qos_form.html +++ b/instances/templates/instances/nic_qos_form.html @@ -21,6 +21,6 @@
    + data-confirm="{% blocktrans with mac=network.mac %}Remove the {{ direction }} limit of {{ mac }}? A running VM keeps it until it is powered off and on.{% endblocktrans %}">{% trans "Delete limit" %}
    diff --git a/instances/templates/instances/power_tab.html b/instances/templates/instances/power_tab.html index 7dc6854c..3b3a8af5 100644 --- a/instances/templates/instances/power_tab.html +++ b/instances/templates/instances/power_tab.html @@ -32,13 +32,11 @@
    {% if "powercycle" in power_actions %} + data-confirm="{% blocktrans with name=instance.name %}Power cycle {{ name }}? It stops at once, as after a power cut, and starts again; what the guest has not written to disk is lost.{% endblocktrans %}">{% trans "Power Cycle" %} {% endif %} {% if "force_off" in power_actions %} + data-confirm="{% blocktrans with name=instance.name %}Force off {{ name }}? It stops at once, as after a power cut; what the guest has not written to disk is lost.{% endblocktrans %}">{% trans "Force Off" %} {% endif %}

    diff --git a/instances/templates/instances/settings_tab.html b/instances/templates/instances/settings_tab.html index 26c576fb..62daa717 100644 --- a/instances/templates/instances/settings_tab.html +++ b/instances/templates/instances/settings_tab.html @@ -246,7 +246,7 @@ - +

    {% csrf_token %} @@ -254,7 +254,7 @@ - +
    @@ -328,7 +328,7 @@ {% if network.type != "hostdev" %}
    {% csrf_token %} + data-confirm="{% blocktrans with mac=network.mac %}Remove network card {{ mac }}? A running VM loses its connection through it at once.{% endblocktrans %}">{% trans "Delete" %}
    {% endif %} @@ -467,8 +467,7 @@ + data-confirm="{% trans "Remove this USB device from the VM? A running VM loses it at once." %}">{% bs_icon 'trash' %} {% endif %} @@ -501,7 +500,7 @@ + data-confirm="{% trans "Remove this PCI device from the VM? A running VM's guest has to let it go." %}">{% bs_icon 'trash' %} {% endif %} diff --git a/instances/templates/instances/snapshots_tab.html b/instances/templates/instances/snapshots_tab.html index f9a22e1f..38096858 100755 --- a/instances/templates/instances/snapshots_tab.html +++ b/instances/templates/instances/snapshots_tab.html @@ -87,27 +87,27 @@ + {% if busy or held %} disabled{% endif %}>{% trans "Revert" %}
    {% csrf_token %}{% submission_key %} + {% if busy or held %} disabled{% endif %}>{% trans "Delete" %}
    {% else %}
    {% csrf_token %}{% submission_key %}
    {% csrf_token %} + {% if busy or held %} disabled{% endif %}>{% trans "Delete" %}
    {% endif %} diff --git a/instances/tests/test_states.py b/instances/tests/test_states.py index 1a977895..2164fba8 100644 --- a/instances/tests/test_states.py +++ b/instances/tests/test_states.py @@ -98,7 +98,7 @@ class StateTests(TestCase): for action in {"force_off", "powercycle"} & ACTIONS[state]: button = page.xpath(f"//form[@action='{reverse(f'instances:{action}', args=[vm.id])}']//button")[0] with self.subTest(url=url, vm=vm.name, action=action): - self.assertEqual(button.get("onclick"), "return confirm(this.dataset.confirm)") + self.assertIsNone(button.get("onclick")) # the page's dialog asks, not the browser self.assertIn(vm.name, button.get("data-confirm")) self.assertIn("power cut", button.get("data-confirm")) diff --git a/interfaces/templates/interface.html b/interfaces/templates/interface.html index 3bf58ada..bc1e6c6f 100644 --- a/interfaces/templates/interface.html +++ b/interfaces/templates/interface.html @@ -13,8 +13,7 @@
    {% csrf_token %} {% if state %} + data-confirm="{% blocktrans with name=iface %}Stop interface {{ name }}? The host and its VMs lose their connections through it at once; if you reach the host through it, you lose the host.{% endblocktrans %}">{% bs_icon 'stop-fill' %} {% trans "Stop" %} {% else %} {% endif %} @@ -24,8 +23,7 @@
    diff --git a/networks/templates/network.html b/networks/templates/network.html index 664da13c..40a4f012 100644 --- a/networks/templates/network.html +++ b/networks/templates/network.html @@ -13,8 +13,7 @@
    {% csrf_token %} {% if state %} + data-confirm="{% blocktrans with name=pool %}Stop network {{ name }}? Running VMs on it lose their connection through it at once.{% endblocktrans %}">{% bs_icon 'stop-fill' %} {% trans "Stop" %} {% else %} {% endif %} @@ -25,8 +24,7 @@
  • {% csrf_token %} {% if autostart %} + data-confirm="{% blocktrans with name=pool %}Disable autostart of network {{ name }}? After the host restarts it stays stopped, and VMs that use it do not start.{% endblocktrans %}">{% bs_icon 'toggle-off' %} {% trans "Disable autostart" %} {% else %} {% endif %} @@ -35,8 +33,7 @@
  • {% csrf_token %} + data-confirm="{% blocktrans with name=pool %}Delete network {{ name }}? VMs that use it no longer start.{% endblocktrans %}">{% bs_icon 'trash' %} {% trans "Delete network" %}
  • {% endif %} @@ -168,8 +165,7 @@ + data-confirm="{% blocktrans with address=host.ip %}Delete the fixed address {{ address }}? The VM may get another address when its lease ends.{% endblocktrans %}">{% bs_icon 'trash' %} {% trans "Delete" %} @@ -305,8 +301,7 @@
    {% csrf_token %} + data-confirm="{% blocktrans with name=pool %}Remove the {{ direction }} limit of network {{ name }}? It stays in force until the network is stopped and started.{% endblocktrans %}">{% bs_icon 'trash' %}
    diff --git a/nwfilters/templates/nwfilter.html b/nwfilters/templates/nwfilter.html index bd25340b..40c5315a 100644 --- a/nwfilters/templates/nwfilter.html +++ b/nwfilters/templates/nwfilter.html @@ -69,8 +69,7 @@ + data-confirm="{% blocktrans with ref=ref.filter %}Remove the reference to {{ ref }}? Its rules no longer apply through this filter.{% endblocktrans %}">{% bs_icon 'trash' %} @@ -125,8 +124,7 @@ + data-confirm="{% trans "Delete this rule? Interfaces that use the filter no longer get it." %}">{% bs_icon 'trash' %} diff --git a/nwfilters/templates/nwfilters.html b/nwfilters/templates/nwfilters.html index 2a7e0f5a..e6ecaacf 100644 --- a/nwfilters/templates/nwfilters.html +++ b/nwfilters/templates/nwfilters.html @@ -60,8 +60,7 @@ {# a filter in use is not deleted: a VM using it, or a filter referencing it, would not start #} + data-confirm="{% blocktrans with name=nwf.name %}Delete filter {{ name }}?{% endblocktrans %}">{% bs_icon 'trash' %} {% if nwf.vms or nwf.referenced_by %}{% trans "Delete (in use)" %}{% else %}{% trans "Delete filter" %}{% endif %} diff --git a/static/js/confirm_dialog.js b/static/js/confirm_dialog.js new file mode 100644 index 00000000..d24fbf02 --- /dev/null +++ b/static/js/confirm_dialog.js @@ -0,0 +1,46 @@ +// A button with data-confirm asks in the page's confirm dialog before it acts: +// the dialog's title and its confirming button carry the button's own words +// (or its accessible name, for an icon), its body the data-confirm question. +// Confirmed, the button is clicked again, so its form sends its name and value. +// A button in another dialog hides that one while asking (Bootstrap does not +// stack dialogs) and shows it again after; Cancel gives the focus back. +document.addEventListener("click", function (event) { + var button = event.target.closest("button[data-confirm]"); + if (!button || button.dataset.confirmed) return; + event.preventDefault(); + var dialog = document.getElementById("confirm-dialog"); + var parent = button.closest(".modal"); + var action = button.textContent.trim() || button.getAttribute("aria-label") || button.title; + var ok = dialog.querySelector("[data-confirm-ok]"); + var confirmed = false; + dialog.querySelector(".modal-title").textContent = action; + dialog.querySelector(".modal-body").textContent = button.dataset.confirm; + ok.textContent = action; + ok.className = "btn " + (/\b(btn-|btn-outline-|text-)danger\b/.test(button.className) ? "btn-danger" : "btn-primary"); + ok.disabled = false; + ok.onclick = function () { + ok.disabled = true; // once: the dialog is still open while it hides + confirmed = true; + bootstrap.Modal.getInstance(dialog).hide(); + button.dataset.confirmed = "1"; + button.click(); + delete button.dataset.confirmed; + }; + dialog.addEventListener("hidden.bs.modal", function () { + if (parent) { + // a form sent in place (the flavors) keeps its dialog; a page sent away does not care + bootstrap.Modal.getOrCreateInstance(parent).show(); + } else if (!confirmed) { + // a menu's item is hidden with its menu: its toggle takes the focus + var menu = button.closest(".dropdown"); + (menu ? menu.querySelector("[data-bs-toggle=dropdown]") : button).focus(); + } + }, {once: true}); + var ask = function () { bootstrap.Modal.getOrCreateInstance(dialog).show(); }; + if (parent) { + parent.addEventListener("hidden.bs.modal", ask, {once: true}); + bootstrap.Modal.getInstance(parent).hide(); + } else { + ask(); + } +}, true); diff --git a/storages/templates/create_stg_vol_block.html b/storages/templates/create_stg_vol_block.html index af2b1295..9e2790ca 100644 --- a/storages/templates/create_stg_vol_block.html +++ b/storages/templates/create_stg_vol_block.html @@ -24,7 +24,7 @@ {% trans "Starting upload..." %} -
    + @@ -257,20 +253,20 @@ if (uploadInProgress) return; var fileInput = $('#id_file')[0]; + var errorMessage = $('#upload-error-message'); if (fileInput.files.length === 0) { - alert("{% trans 'Please select a file to upload.'|escapejs %}"); + errorMessage.text("{% trans 'Please select a file to upload.'|escapejs %}").removeClass("d-none"); return; } var file = fileInput.files[0]; var fileName = file.name; - var errorMessage = $('#upload-error-message'); if (!/\.iso$/i.test(fileName) || fileName.length <= 4) { errorMessage.text("{% trans 'Only .iso files can be uploaded.'|escapejs %}").removeClass("d-none"); return; } if (file.size === 0) { - alert("{% trans 'The selected file is empty.'|escapejs %}"); + errorMessage.text("{% trans 'The selected file is empty.'|escapejs %}").removeClass("d-none"); return; } var chunkSize = 32 * 1024 * 1024; // Fewer SSH connections per upload diff --git a/templates/base.html b/templates/base.html index 446f6156..a8d040f3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -59,6 +59,23 @@ {% block content %}{% endblock %} + {# the dialog every button with data-confirm asks in (confirm_dialog.js) #} + + @@ -66,6 +83,7 @@ + diff --git a/virtsecrets/templates/secrets.html b/virtsecrets/templates/secrets.html index a06f566f..87c044de 100644 --- a/virtsecrets/templates/secrets.html +++ b/virtsecrets/templates/secrets.html @@ -72,8 +72,7 @@ {# a secret in use is not deleted: the VM or pool using it would not start #} + data-confirm="{% blocktrans with usage=secret.usage|default:secret.uuid %}Delete secret {{ usage }}? Its stored value will be lost. No current or saved definition of a VM or pool names it; snapshots, LUKS volumes and qemu.conf's TLS settings are not checked.{% endblocktrans %}">{% bs_icon 'trash' %} {% if secret.vms or secret.pools %}{% trans "Delete (in use)" %}{% else %}{% trans "Delete secret" %}{% endif %} diff --git a/webvirtcloud/tests/test_confirm_prompts.py b/webvirtcloud/tests/test_confirm_prompts.py index 7595488a..16fef2f3 100644 --- a/webvirtcloud/tests/test_confirm_prompts.py +++ b/webvirtcloud/tests/test_confirm_prompts.py @@ -1,29 +1,32 @@ -"""A confirmation says what its action does: every prompt in a template is the -button's own data-confirm text, shown by return confirm(this.dataset.confirm).""" +"""A confirmation says what its action does, in the page's dialog: a button +asks its own data-confirm question through confirm_dialog.js, never the +browser's confirm().""" +import re from pathlib import Path from django.conf import settings from django.test import SimpleTestCase -ONCLICK = 'onclick="return confirm(this.dataset.confirm)"' - class ConfirmPromptTests(SimpleTestCase): - def test_every_prompt_is_the_buttons_own_text(self): + def test_every_prompt_is_a_buttons_own_text_in_the_pages_dialog(self): base = Path(settings.BASE_DIR) templates = [*base.glob("*/templates/**/*.html"), *base.glob("templates/**/*.html")] self.assertTrue(templates) for path in templates: text = path.read_text() - at = text.find("confirm(") - while at != -1: + with self.subTest(template=str(path.relative_to(base))): + self.assertNotIn("confirm(", text) + for match in re.finditer(r"data-confirm=", text): # the tag around it: prompt texts and template tags hold no < or > - tag = text[text.rfind("<", 0, at):text.find(">", at) + 1] + tag = text[text.rfind("<", 0, match.start()):text.find(">", match.start()) + 1] + end = text.find("", match.start()) + content = re.sub(r"\{%\s*(bs_icon|if|elif|else|endif)\b[^%]*%\}", "", + text[text.find(">", match.start()) + 1:end]).strip() with self.subTest(template=str(path.relative_to(base)), tag=tag[:60]): - self.assertRegex(tag, r"^<(button|input|a)\b") - self.assertIn(ONCLICK, tag) - self.assertIn('data-confirm="', tag) + self.assertRegex(tag, r"^