Files
RosterChief/static/js/bulk-add-rows.js
Bernard Siebens 1d5286fbbf 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>
2026-08-11 13:48:19 +02:00

90 lines
3.8 KiB
JavaScript

/*
* Dynamic rows for the bulk-add formsets (team roster/staff, group members).
*
* Progressive enhancement: the formset's own `extra` rows are already in the
* page and post normally with no JS at all -- this only adds the ability to
* clone more rows from the hidden <template> holding the formset's empty_form,
* and to drop a row you've changed your mind about.
*
* Removing a row deletes it outright and deliberately leaves TOTAL_FORMS alone
* rather than re-indexing everything after it: Django reads a form whose fields
* are simply absent from the POST as an unchanged extra form and skips it, so a
* gap in the indices is harmless, while re-indexing live inputs is not.
*/
(() => {
const rows = document.getElementById("bulk-add-rows");
const template = document.getElementById("bulk-add-row-template");
const addButton = document.getElementById("add-row");
if (!rows || !template || !addButton) return;
const totalForms = document.getElementById(`id_${rows.dataset.prefix}-TOTAL_FORMS`);
if (!totalForms) return;
// Jersey number and captaincy belong to a TeamMembership; a staff position
// becomes a StaffAssignment, which has neither. PositionSelect
// (management/forms.py) marks the staff options so those inputs can grey
// themselves out to match -- and a disabled input isn't submitted, so the
// server sees nothing set either way. The row form rejects them regardless;
// this only saves the user from filling in something that can't apply.
const syncPlayerOnlyFields = (row) => {
const position = row.querySelector(".position-select");
if (!position) return;
const chosen = position.options[position.selectedIndex];
const isStaff = Boolean(chosen && chosen.dataset.staff === "1");
row.querySelectorAll(".player-only").forEach((field) => {
field.disabled = isStaff;
if (!isStaff) return;
if (field.type === "checkbox") {
field.checked = false;
} else {
field.value = "";
}
});
};
const wireRow = (row) => {
row.querySelectorAll("select[data-searchable]").forEach((select) => {
if (window.enhanceSearchableSelect) window.enhanceSearchableSelect(select);
});
const position = row.querySelector(".position-select");
if (position) position.addEventListener("change", () => syncPlayerOnlyFields(row));
syncPlayerOnlyFields(row);
const remove = row.querySelector(".remove-row");
if (remove) remove.addEventListener("click", () => row.remove());
};
rows.querySelectorAll(".bulk-add-row").forEach(wireRow);
// Cloned from the template's parsed content rather than re-parsed from its
// innerHTML: the rows are <tr>s, and assigning "<tr>...</tr>" to some stray
// <div>'s innerHTML drops them on the floor -- a <tr> is only valid inside a
// table, which is exactly the context <template> already parsed it in.
const buildRow = (index) => {
const row = template.content.firstElementChild.cloneNode(true);
for (const element of [row, ...row.querySelectorAll("*")]) {
for (const attribute of Array.from(element.attributes)) {
if (attribute.value.includes("__prefix__")) {
attribute.value = attribute.value.replace(/__prefix__/g, index);
}
}
}
return row;
};
addButton.addEventListener("click", () => {
const index = Number(totalForms.value);
const row = buildRow(index);
rows.appendChild(row);
totalForms.value = index + 1;
wireRow(row);
const firstInput = row.querySelector("input, select");
if (firstInput) firstInput.focus();
});
})();