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

@@ -3572,6 +3572,12 @@
.w-20 {
width: calc(var(--spacing) * 20);
}
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-28 {
width: calc(var(--spacing) * 28);
}
.w-60 {
width: calc(var(--spacing) * 60);
}
@@ -4528,6 +4534,31 @@
padding-inline: calc(var(--spacing) * 6);
}
}
.md\:col-span-2 {
@media (width >= 48rem) {
grid-column: span 2 / span 2;
}
}
.md\:col-span-4 {
@media (width >= 48rem) {
grid-column: span 4 / span 4;
}
}
.md\:mb-2 {
@media (width >= 48rem) {
margin-bottom: calc(var(--spacing) * 2);
}
}
.md\:grid {
@media (width >= 48rem) {
display: grid;
}
}
.md\:hidden {
@media (width >= 48rem) {
display: none;
}
}
.md\:grid-cols-2 {
@media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -4548,6 +4579,26 @@
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
.md\:grid-cols-\[minmax\(0\,1fr\)_2\.5rem\] {
@media (width >= 48rem) {
grid-template-columns: minmax(0,1fr) 2.5rem;
}
}
.md\:grid-cols-\[minmax\(0\,2fr\)_minmax\(0\,2fr\)_7rem_2\.5rem\] {
@media (width >= 48rem) {
grid-template-columns: minmax(0,2fr) minmax(0,2fr) 7rem 2.5rem;
}
}
.md\:items-start {
@media (width >= 48rem) {
align-items: flex-start;
}
}
.md\:justify-center {
@media (width >= 48rem) {
justify-content: center;
}
}
.lg\:block {
@media (width >= 64rem) {
display: block;