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:
@@ -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
@@ -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
@@ -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
@@ -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 -------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user