diff --git a/common/components/search_select.py b/common/components/search_select.py index 4addc9c..bc59e00 100644 --- a/common/components/search_select.py +++ b/common/components/search_select.py @@ -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=[ - ("data-ss-search", ""), - ("type", "text"), - ("placeholder", placeholder), - ("autocomplete", "off"), - ("class", _SEARCH_CLASS), - ], - ) + 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 [] diff --git a/games/api.py b/games/api.py index 8b205b6..edd6e10 100644 --- a/games/api.py +++ b/games/api.py @@ -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() diff --git a/games/forms.py b/games/forms.py index 15dfc0d..0101d09 100644 --- a/games/forms.py +++ b/games/forms.py @@ -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( diff --git a/games/views/game.py b/games/views/game.py index fde70ee..06bdada 100644 --- a/games/views/game.py +++ b/games/views/game.py @@ -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 ------------------------------------------- diff --git a/games/views/playevent.py b/games/views/playevent.py index 5cda6fc..d522bc8 100644 --- a/games/views/playevent.py +++ b/games/views/playevent.py @@ -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: