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." %}
+ {% 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"))