Rebuild team/group bulk add as a searchable row formset
The old page gave every eligible member a table row, which a club with a hundred-plus members can't use, and its search was a GET round-trip that discarded anything already ticked. Now you add rows: pick a person from a searchable select, pick a position, fill in the details. The position decides the role. Position.staff_position already distinguishes them, so a staff position creates a StaffAssignment and anything else a TeamMembership -- no separate "player or staff?" control that could disagree with the position picked. Someone joining as both is two rows. Jersey number and captaincy exist only on TeamMembership, so a staff row rejects them and the row script greys them out, keyed off the data-staff marker PositionSelect stamps on staff options. All-or-nothing on submit: one bad row re-renders the page with every row still filled in and the offending field flagged, rather than saving the good rows and losing the rest -- a partial save costs far more when the rows were typed by hand. Cross-row checks no single row can see (the same person twice, two rows claiming one jersey) live on the formset's clean(); per-row checks live on the row form. Eligibility is still never trusted from the POST. Captain and alternate captain on one row is refused as self-contradictory, but how many captains a team may have is deliberately left alone: neither the model nor the single-add form constrains it, and inventing the rule in one entry path only would be bypassable by adding players one at a time. A test pins that absence so it reads as a decision rather than an oversight. Two implementation notes worth keeping: rows are cloned from the template's parsed content, not its innerHTML, because assigning "<tr>...</tr>" to a detached <div> silently drops it; and these tables deliberately skip the usual overflow-x-auto wrapper, which would make a scroll container that clips the picker's dropdown for every row but the first couple. Removing a row leaves TOTAL_FORMS alone -- Django reads a form whose fields are absent from the POST as an unchanged extra and skips it, which is safe, unlike re-indexing live inputs. management/tests.py also carries the setUpTestData rationalisation from the previous commit, which couldn't be split cleanly from the new bulk-add tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
15
management/templates/management/_group_bulk_add_row.html
Normal file
15
management/templates/management/_group_bulk_add_row.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% load i18n lucide %}
|
||||
{% comment %}
|
||||
One row of the group bulk-add formset -- see _team_bulk_add_row.html for why
|
||||
this is a partial (rendered both for real rows and into the clone template).
|
||||
{% endcomment %}
|
||||
<tr class="bulk-add-row">
|
||||
<td>
|
||||
{{ form.member }}
|
||||
{% for error in form.member.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
|
||||
{% for error in form.non_field_errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
|
||||
</td>
|
||||
<td class="w-12">
|
||||
<button class="btn btn-ghost btn-sm remove-row" type="button" aria-label="{% trans 'Remove this row' %}" title="{% trans 'Remove this row' %}">{% lucide "x" size=16 %}</button>
|
||||
</td>
|
||||
</tr>
|
||||
32
management/templates/management/_team_bulk_add_row.html
Normal file
32
management/templates/management/_team_bulk_add_row.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% load i18n lucide %}
|
||||
{% comment %}
|
||||
One row of the team bulk-add formset. Rendered both for the rows already in
|
||||
the formset and (with __prefix__ placeholders) into the <template> the "Add
|
||||
row" button clones from -- so the two can never drift apart.
|
||||
{% endcomment %}
|
||||
<tr class="bulk-add-row">
|
||||
<td>
|
||||
{{ form.member }}
|
||||
{% for error in form.member.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
|
||||
{% for error in form.non_field_errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{{ form.position }}
|
||||
{% for error in form.position.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
|
||||
</td>
|
||||
<td class="w-24">
|
||||
{{ form.jersey_number }}
|
||||
{% for error in form.jersey_number.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
|
||||
</td>
|
||||
<td class="w-16 text-center">
|
||||
{{ form.is_captain }}
|
||||
{% for error in form.is_captain.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
|
||||
</td>
|
||||
<td class="w-16 text-center">
|
||||
{{ form.is_alternate_captain }}
|
||||
{% for error in form.is_alternate_captain.errors %}<p class="text-xs text-error mt-1">{{ error }}</p>{% endfor %}
|
||||
</td>
|
||||
<td class="w-12">
|
||||
<button class="btn btn-ghost btn-sm remove-row" type="button" aria-label="{% trans 'Remove this row' %}" title="{% trans 'Remove this row' %}">{% lucide "x" size=16 %}</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1,59 +1,48 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide %}
|
||||
{% load i18n lucide static %}
|
||||
|
||||
{% block heading %}{% trans "Add multiple members" %}{% endblock heading %}
|
||||
{% block subheading %}{{ group.name }}{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
<form method="get" class="mb-4">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<label class="input">
|
||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
|
||||
</label>
|
||||
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ formset.management_form }}
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm opacity-70 font-semibold">
|
||||
{% blocktrans count counter=members|length %}{{ counter }} member{% plural %}{{ counter }} members{% endblocktrans %}
|
||||
</span>
|
||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
|
||||
</div>
|
||||
{% for error in formset.non_form_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans "Member" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if member.already_in_group %}
|
||||
<span class="badge badge-neutral badge-sm">{% trans "In group" %}</span>
|
||||
{% else %}
|
||||
<input type="checkbox" class="checkbox" name="member_{{ member.pk }}">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ member }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="2" class="text-center opacity-60">{% trans "No members match this search." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% comment %}
|
||||
Deliberately not wrapped in the usual overflow-x-auto: that makes a
|
||||
scroll container, which clips the member picker's absolutely
|
||||
positioned dropdown for every row but the first couple.
|
||||
{% endcomment %}
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Member" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
|
||||
{% for form in formset %}
|
||||
{% include "management/_group_bulk_add_row.html" with form=form %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<template id="bulk-add-row-template">
|
||||
{% include "management/_group_bulk_add_row.html" with form=formset.empty_form %}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" id="add-row">{% lucide "plus" size=14 %} {% trans "Add another row" %}</button>
|
||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add to group" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,3 +52,8 @@
|
||||
</div>
|
||||
</form>
|
||||
{% endblock panel %}
|
||||
|
||||
{% block extra_body %}
|
||||
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||
<script src="{% static 'js/bulk-add-rows.js' %}"></script>
|
||||
{% endblock extra_body %}
|
||||
|
||||
@@ -1,98 +1,56 @@
|
||||
{% extends "management/base.html" %}
|
||||
{% load i18n lucide %}
|
||||
{% load i18n lucide static %}
|
||||
|
||||
{% block heading %}{% trans "Add multiple people" %}{% endblock heading %}
|
||||
{% block subheading %}{{ team.name }}{% endblock subheading %}
|
||||
|
||||
{% block panel %}
|
||||
<form method="get" class="mb-4">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<label class="input">
|
||||
<span class="opacity-50">{% lucide "search" size=16 %}</span>
|
||||
<input type="search" name="q" value="{{ search }}" placeholder="{% trans 'Search members ...' %}" class="input input-bordered">
|
||||
</label>
|
||||
<button class="btn btn-outline gap-2" type="submit">{% lucide "filter" size=16 %} {% trans "Filter" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ formset.management_form }}
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm opacity-70 font-semibold">
|
||||
{% blocktrans count counter=members|length %}{{ counter }} eligible member{% plural %}{{ counter }} eligible members{% endblocktrans %}
|
||||
</span>
|
||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add selected" %}</button>
|
||||
</div>
|
||||
<p class="text-sm opacity-70 mb-2">
|
||||
{% blocktrans %}Only members who are active (paid up) for this club this season or next are eligible. Tick "Player" and/or "Staff" for anyone you want to add -- both at once works too (a playing coach, say).{% endblocktrans %}
|
||||
{% blocktrans %}Only members who are active (paid up) for this club this season or next can be added. The position you pick decides the role: a staff position adds them to the staff, any other position adds them to the roster. To add someone as both a player and staff, give them two rows.{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Member" %}</th>
|
||||
<th>{% trans "Player" %}</th>
|
||||
<th>{% trans "Position" %}</th>
|
||||
<th>{% trans "Jersey #" %}</th>
|
||||
<th>{% trans "Staff" %}</th>
|
||||
<th>{% trans "Position" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr>
|
||||
<td>{{ member }}</td>
|
||||
<td>
|
||||
{% if member.already_player %}
|
||||
<span class="badge badge-neutral badge-sm">{% trans "On roster" %}</span>
|
||||
{% else %}
|
||||
<input type="checkbox" class="checkbox player-checkbox" name="player_{{ member.pk }}" data-member="{{ member.pk }}">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not member.already_player %}
|
||||
<select name="player_position_{{ member.pk }}" class="select select-bordered select-sm player-position" data-member="{{ member.pk }}" disabled>
|
||||
<option value="">{% trans "—" %}</option>
|
||||
{% for position in player_positions %}
|
||||
<option value="{{ position.pk }}">{{ position.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not member.already_player %}
|
||||
<input type="number" min="0" name="jersey_{{ member.pk }}" class="input input-bordered input-sm w-20 player-jersey" data-member="{{ member.pk }}" disabled>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if member.already_staff %}
|
||||
<span class="badge badge-neutral badge-sm">{% trans "On staff" %}</span>
|
||||
{% else %}
|
||||
<input type="checkbox" class="checkbox staff-checkbox" name="staff_{{ member.pk }}" data-member="{{ member.pk }}">
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not member.already_staff %}
|
||||
<select name="staff_position_{{ member.pk }}" class="select select-bordered select-sm staff-position" data-member="{{ member.pk }}" disabled>
|
||||
<option value="">{% trans "—" %}</option>
|
||||
{% for position in staff_positions %}
|
||||
<option value="{{ position.pk }}">{{ position.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center opacity-60">{% trans "No eligible members match this search." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% for error in formset.non_form_errors %}
|
||||
<div class="alert alert-error my-2">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% comment %}
|
||||
Deliberately not wrapped in the usual overflow-x-auto: that makes a
|
||||
scroll container, which clips the member picker's absolutely
|
||||
positioned dropdown for every row but the first couple.
|
||||
{% endcomment %}
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Member" %}</th>
|
||||
<th>{% trans "Position" %}</th>
|
||||
<th>{% trans "Jersey #" %}</th>
|
||||
<th class="text-center">{% trans "Captain" %}</th>
|
||||
<th class="text-center">{% trans "Alternate" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bulk-add-rows" data-prefix="{{ formset.prefix }}">
|
||||
{% for form in formset %}
|
||||
{% include "management/_team_bulk_add_row.html" with form=form %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<template id="bulk-add-row-template">
|
||||
{% include "management/_team_bulk_add_row.html" with form=formset.empty_form %}
|
||||
</template>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
<button class="btn btn-outline btn-sm gap-2" type="button" id="add-row">{% lucide "plus" size=14 %} {% trans "Add another row" %}</button>
|
||||
<button class="btn btn-primary btn-sm gap-2" type="submit">{% lucide "users" size=14 %} {% trans "Add to team" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,22 +62,6 @@
|
||||
{% endblock panel %}
|
||||
|
||||
{% block extra_body %}
|
||||
<script>
|
||||
(() => {
|
||||
const pairs = [
|
||||
{checkboxClass: "player-checkbox", fieldClasses: ["player-position", "player-jersey"]},
|
||||
{checkboxClass: "staff-checkbox", fieldClasses: ["staff-position"]},
|
||||
];
|
||||
|
||||
pairs.forEach(({checkboxClass, fieldClasses}) => {
|
||||
document.querySelectorAll(`.${checkboxClass}`).forEach((checkbox) => {
|
||||
const memberId = checkbox.dataset.member;
|
||||
const fields = fieldClasses.flatMap((cls) => Array.from(document.querySelectorAll(`.${cls}[data-member="${memberId}"]`)));
|
||||
checkbox.addEventListener("change", () => {
|
||||
fields.forEach((field) => { field.disabled = !checkbox.checked; });
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="{% static 'js/searchable-select.js' %}"></script>
|
||||
<script src="{% static 'js/bulk-add-rows.js' %}"></script>
|
||||
{% endblock extra_body %}
|
||||
|
||||
Reference in New Issue
Block a user