1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-10 04:06:47 +00:00

style(admin): the group form has the user form's layout and a members search

The group form put every user as a checkbox in one unsorted list, the
members among them, with no way to find one: past a few dozen users a
wall of names. Its name field ran the width of the page.

It now has the user form's sections (Group, Permissions, Members), each
with what it is for beside its fields. Members come first, then everyone
else, each by username with their full name; a search box filters them
by username, name or e-mail (table_filter.js). A form's search, marked
data-forget, starts empty: the list pages remember theirs per page title,
which here is the same for every group. Hidden users stay in the form,
so saving while filtered keeps them. The button says Create group or
Save changes.
This commit is contained in:
catborise 2026-11-04 22:41:00 +03:00
parent 6523fd0955
commit 61979be731
9 changed files with 223 additions and 124 deletions

View file

@ -39,7 +39,7 @@ class GroupForm(forms.ModelForm):
users = forms.ModelMultipleChoiceField(
widget=forms.CheckboxSelectMultiple,
queryset=User.objects.all(),
queryset=User.objects.order_by("username"),
required=False,
)

View file

@ -1,50 +1,89 @@
{% extends "base.html" %}
{% load django_bootstrap5 %}
{% load i18n %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block page_heading %}{{ title }}{% endblock page_heading %}
{% block style %}
<style>
/* as the user form: what a section is about on the left, its fields on the right */
.group-form-section { border-top: 1px solid var(--bs-border-color); padding: 1.5rem 0; }
.group-form-section:first-of-type { border-top: 0; padding-top: .5rem; }
.group-form-fields { max-width: 52rem; }
</style>
{% endblock style %}
{% block content %}
{# as the user form: what the members may do first, then who they are #}
<form id="create-update" method="post">
{% csrf_token %}
{% bootstrap_form_errors form type="non_fields" %}
{% bootstrap_field form.name %}
<h4 class="h5 mb-1">{% trans "What can its members do?" %}</h4>
<p class="small text-body-secondary mb-2">{% trans "Each member has these permissions besides their own." %}</p>
<div class="border-top mb-4">
{% for box, help in form.permission_rows %}
<div class="form-check form-switch border-bottom py-2 ps-5">
<input id="{{ box.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="permissions"
value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
<label class="form-check-label" for="{{ box.id_for_label }}">
<span class="d-block">{{ box.data.value.instance.name }}</span>
<span class="small text-body-secondary">{{ help }}</span>
</label>
<section class="group-form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Group" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "Users who share a set of permissions." %}</p>
</div>
<div class="col-lg-9 group-form-fields">
<div class="row"><div class="col-sm-6">{% bootstrap_field form.name %}</div></div>
</div>
{% endfor %}
{% for error in form.permissions.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
<fieldset class="mb-3">
<legend class="fs-6 fw-semibold">{% trans "Members" %}</legend>
<div class="d-flex flex-wrap gap-2">
{% for box in form.users %}
<label class="border rounded px-2 py-1 text-break">
<input id="{{ box.id_for_label }}" class="form-check-input me-1" type="checkbox" name="users" value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
{{ box.choice_label }}
</label>
{% endfor %}
</div>
{% for error in form.users.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</fieldset>
</section>
<div class="d-flex justify-content-end gap-2 mt-3">
<section class="group-form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Permissions" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "Each member has these permissions besides their own." %}</p>
</div>
<div class="col-lg-9 group-form-fields">
<div class="border-top">
{% for box, help in form.permission_rows %}
<div class="form-check form-switch border-bottom py-2 ps-5">
<input id="{{ box.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="permissions"
value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
<label class="form-check-label" for="{{ box.id_for_label }}">
<span class="d-block">{{ box.data.value.instance.name }}</span>
<span class="small text-body-secondary">{{ help }}</span>
</label>
</div>
{% endfor %}
{% for error in form.permissions.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
</div>
</div>
</section>
<section class="group-form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Members" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "Who gets these permissions." %}</p>
</div>
<fieldset class="col-lg-9 group-form-fields">
<legend class="visually-hidden">{% trans "Members" %}</legend>
<input id="filter" class="form-control form-control-sm mb-2" style="max-width: 20rem" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search users" %}" data-forget>
{# the members first, then everyone else; each by username #}
<div class="d-flex flex-wrap gap-2 searchable">
{% for box in form.users %}{% if box.data.selected %}{% include "admin/group_member.html" %}{% endif %}{% endfor %}
{% for box in form.users %}{% if not box.data.selected %}{% include "admin/group_member.html" %}{% endif %}{% endfor %}
</div>
<p id="no-match" class="small text-body-secondary mt-2 mb-0" hidden>{% trans "No user matches the search." %}</p>
{% for error in form.users.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</fieldset>
</div>
</section>
<div class="group-form-section d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="{% url 'admin:group_list' %}">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
<button type="submit" class="btn btn-primary">{% if group %}{% trans "Save changes" %}{% else %}{% trans "Create group" %}{% endif %}</button>
</div>
</form>
{% endblock content %}
{% block script %}
<script src="{% static 'js/table_filter.js' %}"></script>
{% endblock script %}

View file

@ -0,0 +1,7 @@
{# one user of the group form's Members: box is a checkbox of GroupForm.users #}
{% with user=box.data.value.instance %}
<label class="border rounded px-2 py-1 text-break" data-search="{{ user.username }} {{ user.get_full_name }} {{ user.email }}">
<input id="{{ box.id_for_label }}" class="form-check-input me-1" type="checkbox" name="users" value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
{{ user.username }}{% if user.get_full_name %} <span class="small text-body-secondary">{{ user.get_full_name }}</span>{% endif %}
</label>
{% endwith %}

View file

@ -53,6 +53,23 @@ class AdminTestCase(TestCase):
with self.assertRaises(ObjectDoesNotExist):
Group.objects.get(id=group_id)
def test_group_form_lists_its_members_first(self):
for name in ("zed", "bob", "amy"):
User.objects.create_user(name)
User.objects.filter(username="amy").update(first_name="Amy", last_name="Adams")
group = Group.objects.create(name="Ops")
group.user_set.add(User.objects.get(username="zed"))
page = html.fromstring(self.client.get(reverse("admin:group_update", args=[group.id])).content)
boxes = page.xpath("//div[contains(@class, 'searchable')]/label")
names = [box.text_content().split()[0] for box in boxes]
self.assertEqual(names, ["zed", "admin", "amy", "bob"])
self.assertIn("Amy Adams", boxes[2].get("data-search"))
self.assertEqual(page.xpath("//input[@id='filter']/@data-forget"), [""])
self.assertEqual(page.xpath("//form[@id='create-update']//button[@type='submit']/text()"), ["Save changes"])
page = html.fromstring(self.client.get(reverse("admin:group_create")).content)
self.assertEqual(page.xpath("//form[@id='create-update']//button[@type='submit']/text()"), ["Create group"])
def test_user_list(self):
response = self.client.get(reverse("admin:user_list"))
self.assertEqual(response.status_code, 200)