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:
2026-08-11 13:48:19 +02:00
parent ffe8a3d301
commit 1d5286fbbf
11 changed files with 1268 additions and 667 deletions

View 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>

View 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>

View File

@@ -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 %}

View File

@@ -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 %}