mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
fix(instances): adding or deleting a flavor keeps the unfinished VM form
The create page's flavor dialog posted its forms the normal way, and the server sent a fresh page back: the VM's name, sizes, disk choices and MAC edits were lost, and a refused flavor left the page for a bare form. The dialog's forms are now posted from the page. The flavor views answer such a post with the presets and the dialog body, rendered from the same includes as the page, and the script swaps them in: a refused flavor shows its errors in the dialog, and the focus stays in it. A post without the script, or one whose request fails, still goes the normal way. Fixes #173 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ec665cbca
commit
575077c8ac
6 changed files with 104 additions and 36 deletions
23
instances/templates/create_flavors.html
Normal file
23
instances/templates/create_flavors.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{% load i18n %}
|
||||
{% load django_bootstrap5 %}
|
||||
<div id="flavors" class="modal-body">
|
||||
{% if flavors %}
|
||||
<ul class="list-group mb-3">
|
||||
{% for flavor in flavors %}
|
||||
<li class="list-group-item d-flex flex-wrap align-items-center gap-2">
|
||||
<span><strong>{{ flavor.label }}</strong> · {{ flavor.vcpu }} vCPU · {{ flavor.memory }} MiB · {{ flavor.disk }} GiB</span>
|
||||
<form class="ms-auto" action="{% url 'instances:flavor_delete' flavor.id %}" method="post">{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-secondary"
|
||||
data-confirm="{% blocktrans with name=flavor.label %}Delete flavor {{ name }}? Only the size template goes; VMs made from it keep their size.{% endblocktrans %}"
|
||||
onclick="return confirm(this.dataset.confirm)">{% trans "Delete" %}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<h3 class="h6">{% trans "Add flavor" %}</h3>
|
||||
<form method="post" action="{% url 'instances:flavor_create' %}">{% csrf_token %}
|
||||
{% bootstrap_form flavor_form %}
|
||||
<button type="submit" class="btn btn-primary">{% trans "Add" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
3
instances/templates/create_flavors_reply.html
Normal file
3
instances/templates/create_flavors_reply.html
Normal file
|
|
@ -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' %}
|
||||
|
|
@ -58,19 +58,7 @@
|
|||
{% trans "Manage flavors" %}
|
||||
</button>
|
||||
</div>
|
||||
{% if flavors %}
|
||||
<div class="d-flex flex-wrap gap-2" role="group" aria-labelledby="presets-label" aria-describedby="presets_help">
|
||||
{% for flavor in flavors %}
|
||||
<button type="button" class="btn btn-sm border text-body focus-ring preset" aria-pressed="false"
|
||||
data-label="{{ flavor.label }}" data-vcpu="{{ flavor.vcpu }}" data-memory="{{ flavor.memory }}" data-disk="{{ flavor.disk }}">
|
||||
<strong>{{ flavor.label }}</strong> · {{ flavor.vcpu }} vCPU · {{ flavor.memory }} MiB · {{ flavor.disk }} GiB
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="presets_help" class="form-text">{% trans "A preset fills vCPUs, memory and a new disk of its size." %}</div>
|
||||
{% else %}
|
||||
<p class="form-text mb-0">{% trans "No flavors yet." %}</p>
|
||||
{% endif %}
|
||||
{% include 'create_presets.html' %}
|
||||
<div id="preset-status" class="visually-hidden" role="status"></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -368,27 +356,7 @@
|
|||
<h2 class="modal-title h5" id="flavors-dialog-title">{% trans "Flavors" %}</h2>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% if flavors %}
|
||||
<ul class="list-group mb-3">
|
||||
{% for flavor in flavors %}
|
||||
<li class="list-group-item d-flex flex-wrap align-items-center gap-2">
|
||||
<span><strong>{{ flavor.label }}</strong> · {{ flavor.vcpu }} vCPU · {{ flavor.memory }} MiB · {{ flavor.disk }} GiB</span>
|
||||
<form class="ms-auto" action="{% url 'instances:flavor_delete' flavor.id %}" method="post">{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-secondary"
|
||||
data-confirm="{% blocktrans with name=flavor.label %}Delete flavor {{ name }}? Only the size template goes; VMs made from it keep their size.{% endblocktrans %}"
|
||||
onclick="return confirm(this.dataset.confirm)">{% trans "Delete" %}</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<h3 class="h6">{% trans "Add flavor" %}</h3>
|
||||
<form method="post" action="{% url 'instances:flavor_create' %}">{% csrf_token %}
|
||||
{% bootstrap_form flavor_form %}
|
||||
<button type="submit" class="btn btn-primary">{% trans "Add" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% include 'create_flavors.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
16
instances/templates/create_presets.html
Normal file
16
instances/templates/create_presets.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{% load i18n %}
|
||||
<div id="presets">
|
||||
{% if flavors %}
|
||||
<div class="d-flex flex-wrap gap-2" role="group" aria-labelledby="presets-label" aria-describedby="presets_help">
|
||||
{% for flavor in flavors %}
|
||||
<button type="button" class="btn btn-sm border text-body focus-ring preset" aria-pressed="false"
|
||||
data-label="{{ flavor.label }}" data-vcpu="{{ flavor.vcpu }}" data-memory="{{ flavor.memory }}" data-disk="{{ flavor.disk }}">
|
||||
<strong>{{ flavor.label }}</strong> · {{ flavor.vcpu }} vCPU · {{ flavor.memory }} MiB · {{ flavor.disk }} GiB
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="presets_help" class="form-text">{% trans "A preset fills vCPUs, memory and a new disk of its size." %}</div>
|
||||
{% else %}
|
||||
<p class="form-text mb-0">{% trans "No flavors yet." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue