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…", placeholder: str = "Search…",
id: str = "", id: str = "",
sync_url: bool = False, sync_url: bool = False,
autofocus: bool = False,
) -> SafeText: ) -> SafeText:
"""Render the search-select widget. See module docstring for the contract.""" """Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(o) for o in (selected or [])] 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 box (NO name — the query is never submitted) ──
search = Component( search_attrs: list[HTMLAttribute] = [
tag_name="input",
attributes=[
("data-ss-search", ""), ("data-ss-search", ""),
("type", "text"), ("type", "text"),
("placeholder", placeholder), ("placeholder", placeholder),
("autocomplete", "off"), ("autocomplete", "off"),
("class", _SEARCH_CLASS), ("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) ── # ── 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 [] 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 django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status 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() api = NinjaAPI()
playevent_router = Router() playevent_router = Router()
game_router = Router() game_router = Router()
device_router = Router()
platform_router = Router()
NOW_FACTORY = django_timezone_now NOW_FACTORY = django_timezone_now
@@ -115,8 +117,26 @@ def delete_playevent(request, playevent_id: int):
return Status(204, None) 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("/playevent", playevent_router)
api.add_router("/games", game_router) api.add_router("/games", game_router)
api.add_router("/devices", device_router)
api.add_router("/platforms", platform_router)
session_router = 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): class SearchSelectWidget(forms.Widget):
"""Thin Django adapter that renders a `SearchSelect()` component. """Thin Django adapter that renders a `SearchSelect()` component.
@@ -57,6 +71,7 @@ class SearchSelectWidget(forms.Widget):
self, self,
*, *,
search_url, search_url,
options_resolver,
multi_select=False, multi_select=False,
items_visible=5, items_visible=5,
items_scroll=10, items_scroll=10,
@@ -66,6 +81,7 @@ class SearchSelectWidget(forms.Widget):
): ):
super().__init__(attrs) super().__init__(attrs)
self.search_url = search_url self.search_url = search_url
self.options_resolver = options_resolver
self.multi_select = multi_select self.multi_select = multi_select
self.items_visible = items_visible self.items_visible = items_visible
self.items_scroll = items_scroll self.items_scroll = items_scroll
@@ -81,7 +97,8 @@ class SearchSelectWidget(forms.Widget):
return [value] if value not in (None, "") else [] return [value] if value not in (None, "") else []
def render(self, name, value, attrs=None, renderer=None): 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( return SearchSelect(
name=name, name=name,
selected=selected, selected=selected,
@@ -93,6 +110,7 @@ class SearchSelectWidget(forms.Widget):
always_visible=self.always_visible, always_visible=self.always_visible,
placeholder=self.placeholder, placeholder=self.placeholder,
id=(attrs or {}).get("id", ""), id=(attrs or {}).get("id", ""),
autofocus=autofocus,
) )
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
@@ -109,7 +127,9 @@ class SearchSelectMultiple(SearchSelectWidget):
class SessionForm(forms.ModelForm): class SessionForm(forms.ModelForm):
game = SingleGameChoiceField( game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), 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( duration_manual = forms.DurationField(
@@ -120,7 +140,11 @@ class SessionForm(forms.ModelForm):
label="Manual duration", label="Manual duration",
) )
device = forms.ModelChoiceField( 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( mark_as_played = forms.BooleanField(
@@ -191,9 +215,18 @@ class PurchaseForm(forms.ModelForm):
games = MultipleGameChoiceField( games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), 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( related_purchase = RelatedPurchaseChoiceField(
queryset=related_purchase_queryset(), queryset=related_purchase_queryset(),
required=False, required=False,
@@ -270,7 +303,11 @@ class GameModelChoiceField(forms.ModelChoiceField):
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):
platform = forms.ModelChoiceField( 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: class Meta:
@@ -307,9 +344,13 @@ class DeviceForm(forms.ModelForm):
class PlayEventForm(forms.ModelForm): class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField( game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), 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( mark_as_finished = forms.BooleanField(
+7 -2
View File
@@ -180,7 +180,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
), ),
), ),
title="Add New Game", 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(): if form.is_valid():
form.save() form.save()
return redirect("games:list_sessions") 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 ------------------------------------------- # --- view_game content builders -------------------------------------------
+11 -2
View File
@@ -15,6 +15,7 @@ from common.components import (
Button, Button,
ButtonGroup, ButtonGroup,
Icon, Icon,
ModuleScript,
paginated_table_content, paginated_table_content,
) )
from common.layout import render_page 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 HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
return render_page( 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]) 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: def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse: