Add a name search box to Add players, explain what "Suggested" means

"Suggested" is whoever was on this team's roster last season -- now spelled
out in a caption under the chips (same for "No team"), not just left for a
coach to guess at. The search box (?q=, ANDed with whichever filter chip is
active) narrows the eligible pool by first/last name, useful once a club's
pool of eligible members outgrows a single screenful.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:44:45 +02:00
parent cc9daabcfd
commit 6e3de13fd8
5 changed files with 59 additions and 12 deletions

View File

@@ -9,7 +9,7 @@ import datetime
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Case, F, When
from django.db.models import Case, F, Q, When
from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
@@ -438,10 +438,10 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
candidate rather than management.forms.TeamMembershipForm's one-member-
at-a-time shape, which doesn't fit a "tap a few names, add them" flow
anyway (the mock itself shows plain checkboxes, no inline position
picker). A coach sets jersey number/position afterward on the desktop --
same as any roster spot added blank via the Sign-up page today
(TeamMembership.position's own help_text already documents this as a
normal, expected state, not a shortcut this screen invents).
picker). A player added here starts with no jersey number/position set --
the coach picks those afterward from the player's own row on the Squad
screen (CoachRosterMemberView), same "blank is a normal, expected state"
TeamMembership.position's own help_text already documents.
The pool is teams.services.eligible_roster_members(club) minus whoever's
already on this team+season -- the same two rules TeamMembershipForm
@@ -449,7 +449,10 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"Suggested" (on this team last season) is real, computed data. "Age
eligible" from the mock isn't built -- neither Club nor Team carries an
age-group field to compare a birth date against, so faking that filter
would just mean it silently matched nothing.
would just mean it silently matched nothing. A plain first/last-name
search (?q=) narrows the pool further, ANDed with whichever filter chip
is active -- useful once a club's eligible-member pool outgrows a single
screenful.
"""
template_name = "mobile/coach/add_player.html"
@@ -477,6 +480,7 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
filter_param = self.request.GET.get("filter")
if filter_param not in self.FILTERS:
filter_param = ""
search_query = self.request.GET.get("q", "").strip()
if self.active_team is not None and season is not None:
squad_count = TeamMembership.objects.filter(team=self.active_team, season=season).count()
@@ -488,12 +492,16 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
previous_season = Season.before(self.request.club, season)
pool = pool.filter(team_memberships__team=self.active_team, team_memberships__season=previous_season) if previous_season is not None else pool.none()
if search_query:
pool = pool.filter(Q(first_name__icontains=search_query) | Q(last_name__icontains=search_query))
candidates = list(pool.distinct().order_by("last_name", "first_name"))
return super().get_context_data(
candidates=candidates,
squad_count=squad_count,
filter_param=filter_param,
search_query=search_query,
**kwargs,
)

View File

@@ -1,5 +1,5 @@
{% extends "mobile/coach/base.html" %}
{% load i18n %}
{% load i18n lucide %}
{% comment %}
C6 -- design_handoff_rosterchief_platform/README.md's C6 section: a
@@ -16,18 +16,30 @@
{% endblock header_extra %}
{% block content %}
<form method="get" class="relative">
{% if filter_param %}<input type="hidden" name="filter" value="{{ filter_param }}">{% endif %}
<span class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-dim">{% lucide "search" size=16 %}</span>
<input type="search" name="q" value="{{ search_query }}" placeholder="{% trans "Search by name" %}" class="h-11 w-full rounded-lg border border-stroke bg-paper pr-3 pl-9 text-[15px] text-ink placeholder:text-dim focus:border-ink focus:outline-none">
</form>
<div class="flex gap-2">
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if not filter_param %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?">
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if not filter_param %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?{% if search_query %}q={{ search_query|urlencode }}{% endif %}">
{% trans "All" %}
</a>
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if filter_param == "suggested" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?filter=suggested">
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if filter_param == "suggested" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?filter=suggested{% if search_query %}&q={{ search_query|urlencode }}{% endif %}">
{% trans "Suggested" %}
</a>
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if filter_param == "no_team" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?filter=no_team">
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if filter_param == "no_team" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?filter=no_team{% if search_query %}&q={{ search_query|urlencode }}{% endif %}">
{% trans "No team" %}
</a>
</div>
{% if filter_param == "suggested" %}
<p class="text-xs text-muted">{% trans "Was on this team's roster last season." %}</p>
{% elif filter_param == "no_team" %}
<p class="text-xs text-muted">{% trans "Not on any team's roster yet this season." %}</p>
{% endif %}
<form method="post" action="{% url "mobile:coach_add_player" %}" x-data="{ count: 0 }" hx-boost="false">
{% csrf_token %}
<div class="m-card flex flex-col overflow-hidden">
@@ -38,7 +50,7 @@
<input class="h-5 w-5 shrink-0 accent-ink" type="checkbox" name="member" value="{{ candidate.pk }}" @change="count = $el.form.querySelectorAll('input[name=member]:checked').length">
</label>
{% empty %}
<div class="px-4 py-6 text-center text-sm text-muted">{% trans "No one matches this filter." %}</div>
<div class="px-4 py-6 text-center text-sm text-muted">{% trans "No one matches." %}</div>
{% endfor %}
</div>

View File

@@ -3536,6 +3536,30 @@ class CoachAddPlayerViewTests(TestCase):
self.assertEqual(list(candidates), [returning])
self.assertNotIn(new_signup, candidates)
def test_search_matches_first_or_last_name(self):
match = self.make_eligible_member(first_name="Zara", last_name="Zenith")
other = self.make_eligible_member(first_name="Not", last_name="Matching")
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_add_player") + "?q=zar", HTTP_HOST="ajax-united.rosterchief.app")
candidates = response.context["candidates"]
self.assertIn(match, candidates)
self.assertNotIn(other, candidates)
def test_search_combines_with_the_active_filter(self):
previous_season = Season.objects.create(club=self.club, start_date=self.season.start_date - datetime.timedelta(days=365), end_date=self.season.start_date - datetime.timedelta(days=1))
returning_match = self.make_eligible_member(first_name="Zara", last_name="Returning")
TeamMembership.objects.create(team=self.team, member=returning_match, season=previous_season)
returning_no_match = self.make_eligible_member(first_name="Other", last_name="Returning")
TeamMembership.objects.create(team=self.team, member=returning_no_match, season=previous_season)
self.client.force_login(self.user)
response = self.client.get(reverse("mobile:coach_add_player") + "?filter=suggested&q=zar", HTTP_HOST="ajax-united.rosterchief.app")
candidates = response.context["candidates"]
self.assertEqual(list(candidates), [returning_match])
def test_post_adds_selected_members_to_the_roster(self):
first = self.make_eligible_member(first_name="First", last_name="Pick")
second = self.make_eligible_member(first_name="Second", last_name="Pick")

View File

@@ -4907,6 +4907,9 @@
.pt-4 {
padding-top: calc(var(--spacing) * 4);
}
.pr-3 {
padding-right: calc(var(--spacing) * 3);
}
.pb-0 {
padding-bottom: 0;
}

File diff suppressed because one or more lines are too long