Adopt SearchSelect for device, platform, and play event game fields

- Parameterize SearchSelectWidget with a required options_resolver so
  each widget explicitly names its resolver instead of implicitly using
  _game_options
- Add autofocus support: SearchSelect forwards it to the search input,
  and SearchSelectWidget extracts it from Django's attrs dict
- Add _device_options and _platform_options resolvers (single pk__in
  queries, same pattern as _game_options)
- Add /api/devices/search and /api/platforms/search endpoints
- Switch PlayEventForm.game from plain Select to SearchSelectWidget
  (preserving autofocus), and use SingleGameChoiceField for correct labels
- Switch SessionForm.device to SearchSelectWidget
- Switch PurchaseForm.platform and GameForm.platform to SearchSelectWidget
- Wire ModuleScript("search_select.js") into add/edit playevent and
  add/edit game views

https://claude.ai/code/session_013fpJD54HxRgxRv2xzwXGNo
This commit is contained in:
Claude
2026-06-07 18:09:26 +00:00
committed by Lukáš Kucharczyk
parent d3b29ff1d4
commit 7c2c08501e
5 changed files with 99 additions and 23 deletions
+6 -5
View File
@@ -93,6 +93,7 @@ def SearchSelect(
placeholder: str = "Search…",
id: str = "",
sync_url: bool = False,
autofocus: bool = False,
) -> SafeText:
"""Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(o) for o in (selected or [])]
@@ -118,16 +119,16 @@ def SearchSelect(
)
# ── Search box (NO name — the query is never submitted) ──
search = Component(
tag_name="input",
attributes=[
search_attrs: list[HTMLAttribute] = [
("data-ss-search", ""),
("type", "text"),
("placeholder", placeholder),
("autocomplete", "off"),
("class", _SEARCH_CLASS),
],
)
]
if autofocus:
search_attrs.append(("autofocus", ""))
search = Component(tag_name="input", attributes=search_attrs)
# ── Options panel (pre-rendered only when there is no search_url) ──
option_rows = [_option_row(o) for o in options] if not search_url else []
+21 -1
View File
@@ -7,11 +7,13 @@ from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
from games.models import Game, PlayEvent, Session
from games.models import Device, Game, Platform, PlayEvent, Session
api = NinjaAPI()
playevent_router = Router()
game_router = Router()
device_router = Router()
platform_router = Router()
NOW_FACTORY = django_timezone_now
@@ -115,8 +117,26 @@ def delete_playevent(request, playevent_id: int):
return Status(204, None)
@device_router.get("/search", response=list[GameOption])
def search_devices(request, q: str = "", limit: int = 10):
qs = Device.objects.order_by("name")
if q:
qs = qs.filter(name__icontains=q)
return [{"value": d.id, "label": d.name, "data": {}} for d in qs[:limit]]
@platform_router.get("/search", response=list[GameOption])
def search_platforms(request, q: str = "", limit: int = 10):
qs = Platform.objects.order_by("name")
if q:
qs = qs.filter(name__icontains=q)
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
api.add_router("/playevent", playevent_router)
api.add_router("/games", game_router)
api.add_router("/devices", device_router)
api.add_router("/platforms", platform_router)
session_router = Router()
+49 -8
View File
@@ -46,6 +46,20 @@ def _game_options(values) -> list[SearchSelectOption]:
]
def _device_options(values) -> list[SearchSelectOption]:
return [
{"value": d.id, "label": d.name, "data": {}}
for d in Device.objects.filter(pk__in=values)
]
def _platform_options(values) -> list[SearchSelectOption]:
return [
{"value": p.id, "label": p.name, "data": {}}
for p in Platform.objects.filter(pk__in=values)
]
class SearchSelectWidget(forms.Widget):
"""Thin Django adapter that renders a `SearchSelect()` component.
@@ -57,6 +71,7 @@ class SearchSelectWidget(forms.Widget):
self,
*,
search_url,
options_resolver,
multi_select=False,
items_visible=5,
items_scroll=10,
@@ -66,6 +81,7 @@ class SearchSelectWidget(forms.Widget):
):
super().__init__(attrs)
self.search_url = search_url
self.options_resolver = options_resolver
self.multi_select = multi_select
self.items_visible = items_visible
self.items_scroll = items_scroll
@@ -81,7 +97,8 @@ class SearchSelectWidget(forms.Widget):
return [value] if value not in (None, "") else []
def render(self, name, value, attrs=None, renderer=None):
selected = searchselect_selected(self._values(value), _game_options)
selected = searchselect_selected(self._values(value), self.options_resolver)
autofocus = bool((attrs or {}).get("autofocus"))
return SearchSelect(
name=name,
selected=selected,
@@ -93,6 +110,7 @@ class SearchSelectWidget(forms.Widget):
always_visible=self.always_visible,
placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
autofocus=autofocus,
)
def value_from_datadict(self, data, files, name):
@@ -109,7 +127,9 @@ class SearchSelectMultiple(SearchSelectWidget):
class SessionForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget(search_url="/api/games/search"),
widget=SearchSelectWidget(
search_url="/api/games/search", options_resolver=_game_options
),
)
duration_manual = forms.DurationField(
@@ -120,7 +140,11 @@ class SessionForm(forms.ModelForm):
label="Manual duration",
)
device = forms.ModelChoiceField(
queryset=Device.objects.order_by("name"), required=False
queryset=Device.objects.order_by("name"),
required=False,
widget=SearchSelectWidget(
search_url="/api/devices/search", options_resolver=_device_options
),
)
mark_as_played = forms.BooleanField(
@@ -191,9 +215,18 @@ class PurchaseForm(forms.ModelForm):
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectMultiple(search_url="/api/games/search", multi_select=True),
widget=SearchSelectMultiple(
search_url="/api/games/search",
options_resolver=_game_options,
multi_select=True,
),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"),
widget=SearchSelectWidget(
search_url="/api/platforms/search", options_resolver=_platform_options
),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = RelatedPurchaseChoiceField(
queryset=related_purchase_queryset(),
required=False,
@@ -270,7 +303,11 @@ class GameModelChoiceField(forms.ModelChoiceField):
class GameForm(forms.ModelForm):
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
queryset=Platform.objects.order_by("name"),
required=False,
widget=SearchSelectWidget(
search_url="/api/platforms/search", options_resolver=_platform_options
),
)
class Meta:
@@ -307,9 +344,13 @@ class DeviceForm(forms.ModelForm):
class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField(
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
widget=SearchSelectWidget(
search_url="/api/games/search",
options_resolver=_game_options,
attrs={"autofocus": "autofocus"},
),
)
mark_as_finished = forms.BooleanField(
+7 -2
View File
@@ -180,7 +180,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
),
),
title="Add New Game",
scripts=ModuleScript("add_game.js"),
scripts=ModuleScript("search_select.js") + ModuleScript("add_game.js"),
)
@@ -332,7 +332,12 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
if form.is_valid():
form.save()
return redirect("games:list_sessions")
return render_page(request, AddForm(form, request=request), title="Edit Game")
return render_page(
request,
AddForm(form, request=request),
title="Edit Game",
scripts=ModuleScript("search_select.js"),
)
# --- view_game content builders -------------------------------------------
+11 -2
View File
@@ -15,6 +15,7 @@ from common.components import (
Button,
ButtonGroup,
Icon,
ModuleScript,
paginated_table_content,
)
from common.layout import render_page
@@ -193,7 +194,10 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
return render_page(
request, AddForm(form, request=request), title="Add new playthrough"
request,
AddForm(form, request=request),
title="Add new playthrough",
scripts=ModuleScript("search_select.js"),
)
@@ -206,7 +210,12 @@ def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
reverse("games:view_game", args=[playevent.game.id])
)
return render_page(request, AddForm(form, request=request), title="Edit Play Event")
return render_page(
request,
AddForm(form, request=request),
title="Edit Play Event",
scripts=ModuleScript("search_select.js"),
)
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse: