diff --git a/instances/templates/create_flavors.html b/instances/templates/create_flavors.html new file mode 100644 index 00000000..7db98b04 --- /dev/null +++ b/instances/templates/create_flavors.html @@ -0,0 +1,23 @@ +{% load i18n %} +{% load django_bootstrap5 %} + diff --git a/instances/templates/create_flavors_reply.html b/instances/templates/create_flavors_reply.html new file mode 100644 index 00000000..2bcae9a2 --- /dev/null +++ b/instances/templates/create_flavors_reply.html @@ -0,0 +1,3 @@ +{# What the create page's flavor forms get back: the parts of the page they change. #} +{% include 'create_presets.html' %} +{% include 'create_flavors.html' %} diff --git a/instances/templates/create_instance_w2.html b/instances/templates/create_instance_w2.html index 694618dc..7fb54b6d 100644 --- a/instances/templates/create_instance_w2.html +++ b/instances/templates/create_instance_w2.html @@ -58,19 +58,7 @@ {% trans "Manage flavors" %} - {% if flavors %} -
- {% for flavor in flavors %} - - {% endfor %} -
-
{% trans "A preset fills vCPUs, memory and a new disk of its size." %}
- {% else %} -

{% trans "No flavors yet." %}

- {% endif %} + {% include 'create_presets.html' %}
@@ -368,27 +356,7 @@ - + {% include 'create_flavors.html' %} @@ -428,8 +396,10 @@ }); }); show_source(document.querySelector('input[name="disk_source"]:checked').value); - document.querySelectorAll(".preset").forEach(function (button) { - button.addEventListener("click", function () { apply_preset(button); }); + $("#create-vm-form").on("click", ".preset", function () { apply_preset(this); }); + $("#flavors-dialog").on("submit", "form", function (event) { + event.preventDefault(); + $.post(this.action, $(this).serialize(), show_flavors).fail(() => this.submit()); }); ["#vm_vcpu", "#vm_memory", "#vm_hdd_size"].forEach(function (sel) { document.querySelector(sel).addEventListener("input", clear_presets); @@ -479,6 +449,14 @@ }); } + // Adding or deleting a flavor stays on the page: its reply replaces the + // presets and the dialog's list and form, and the dialog keeps the focus. + function show_flavors(reply) { + const parts = new DOMParser().parseFromString(reply, "text/html"); + ["#presets", "#flavors"].forEach(sel => document.querySelector(sel).replaceWith(parts.querySelector(sel))); + document.querySelector("#flavors .is-invalid, #flavors input:not([type=hidden])").focus(); + } + // Disk and network rows: one per chosen volume or network, cloned from // the page's row templates; the fields the server reads are derived // from them in sync_lists. diff --git a/instances/templates/create_presets.html b/instances/templates/create_presets.html new file mode 100644 index 00000000..25f27520 --- /dev/null +++ b/instances/templates/create_presets.html @@ -0,0 +1,16 @@ +{% load i18n %} +
+{% if flavors %} +
+ {% for flavor in flavors %} + + {% endfor %} +
+
{% trans "A preset fills vCPUs, memory and a new disk of its size." %}
+{% else %} +

{% trans "No flavors yet." %}

+{% endif %} +
diff --git a/instances/tests/test_create_form_input.py b/instances/tests/test_create_form_input.py index 9e0a5ff2..adef977d 100644 --- a/instances/tests/test_create_form_input.py +++ b/instances/tests/test_create_form_input.py @@ -201,6 +201,38 @@ class FlavorPresetTests(TestCase): self.assertEqual(actions[-1], reverse("instances:flavor_create")) +class InPageFlavorTests(TestCase): + """The create page's flavor forms are sent from the page and get back its + presets and flavor dialog, so the unfinished VM form stays.""" + + def setUp(self): + self.flavor = Flavor.objects.create(label="wvc-test", memory=512, vcpu=1, disk=10) + render_create_page(self) # logs in a superuser + + def post(self, url, data=None): + response = self.client.post(url, data or {}, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(response.status_code, 200) + self.assertEqual(list(response.context["messages"]), []) + return html.fromstring(response.content) + + def test_an_added_flavor_is_a_preset_and_a_dialog_row(self): + reply = self.post(reverse("instances:flavor_create"), {"label": "big", "memory": 4096, "vcpu": 4, "disk": 40}) + self.assertEqual(reply.xpath("//*[@id='presets']//button/@data-label")[-2:], ["wvc-test", "big"]) + self.assertEqual(len(reply.xpath("//*[@id='flavors']//li")), Flavor.objects.count()) + self.assertEqual(reply.xpath("//*[@id='flavors']//input[@name='label']/@value"), []) + + def test_a_refused_flavor_shows_its_errors_in_the_dialog(self): + reply = self.post(reverse("instances:flavor_create"), {"label": "big", "memory": "", "vcpu": 4, "disk": 40}) + self.assertFalse(Flavor.objects.filter(label="big").exists()) + self.assertEqual(reply.xpath("//*[@id='flavors']//input[@name='label']/@value"), ["big"]) + self.assertTrue(reply.xpath("//*[@id='flavors']//input[@name='memory'][contains(@class, 'is-invalid')]")) + + def test_a_deleted_flavor_leaves_the_presets(self): + reply = self.post(reverse("instances:flavor_delete", args=[self.flavor.id])) + self.assertNotIn("wvc-test", reply.xpath("//*[@id='presets']//button/@data-label")) + self.assertNotIn("wvc-test", reply.xpath("//*[@id='flavors']//li//strong/text()")) + + class SubmittedListsTests(TestCase): """The volumes and networks of a refused form come back with it.""" diff --git a/instances/views.py b/instances/views.py index 3f919cf0..7109f8ea 100755 --- a/instances/views.py +++ b/instances/views.py @@ -2148,14 +2148,28 @@ def create_instance(request, compute_id, arch, machine): return render(request, "create_instance_w2.html", locals()) +def from_create_page(request): + """A flavor form sent from the create page's script, which swaps in the reply.""" + return request.headers.get("X-Requested-With") == "XMLHttpRequest" + + +def flavors_reply(request, flavor_form): + return render(request, "create_flavors_reply.html", + {"flavors": Flavor.objects.order_by("id"), "flavor_form": flavor_form}) + + @superuser_only def flavor_create(request): form = FlavorForm(request.POST or None) if form.is_valid(): with audit(request, None, f"Flavor {form.cleaned_data['label']}", "add"): form.save() + if from_create_page(request): + return flavors_reply(request, FlavorForm()) messages.success(request, _("Flavor Created")) return get_safe_redirect(request, default=reverse("instances:index")) + if from_create_page(request): + return flavors_reply(request, form) return render( request, @@ -2191,6 +2205,8 @@ def flavor_delete(request, pk): if request.method == "POST": with audit(request, None, f"Flavor {flavor.label}", "delete"): flavor.delete() + if from_create_page(request): + return flavors_reply(request, FlavorForm()) messages.success(request, _("Flavor Deleted")) return get_safe_redirect(request, default=reverse("instances:index"))