diff --git a/common/components/__init__.py b/common/components/__init__.py index 49ecdbd..a542f7d 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -25,15 +25,23 @@ from common.components.primitives import ( Input, Modal, ModuleScript, + Pill, Popover, PopoverTruncated, SearchField, SimpleTable, + Span, + Label, TableHeader, TableRow, TableTd, paginated_table_content, ) +from common.components.search_select import ( + SearchSelect, + SearchSelectOption, + searchselect_selected, +) from common.components.domain import ( GameLink, GameStatus, @@ -70,10 +78,16 @@ __all__ = [ "Input", "Modal", "ModuleScript", + "Pill", "Popover", "PopoverTruncated", "SearchField", + "SearchSelect", + "SearchSelectOption", + "searchselect_selected", "SimpleTable", + "Span", + "Label", "TableHeader", "TableRow", "TableTd", diff --git a/common/components/domain.py b/common/components/domain.py index 4df626b..ee4deda 100644 --- a/common/components/domain.py +++ b/common/components/domain.py @@ -13,6 +13,7 @@ from common.components.primitives import ( Icon, Popover, PopoverTruncated, + Span, ) from games.models import Game, Purchase, Session @@ -29,8 +30,7 @@ def GameLink( display = children if children else [name] link = reverse("games:view_game", args=[game_id]) - return Component( - tag_name="span", + return Span( attributes=[("class", "truncate-container")], children=[ Component( @@ -70,14 +70,12 @@ def GameStatus( outer_class += f" {class_}" dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"]) - dot = Component( - tag_name="span", + dot = Span( attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")], children=["\xa0"], ) - return Component( - tag_name="span", + return Span( attributes=[("class", outer_class)], children=[dot] + (children if isinstance(children, list) else [children]), ) @@ -88,8 +86,7 @@ def PriceConverted( ) -> SafeText: """Wrap content in a span that indicates the price was converted.""" children = children or [] - return Component( - tag_name="span", + return Span( attributes=[ ("title", "Price is a result of conversion and rounding."), ("class", "decoration-dotted underline"), diff --git a/common/components/filters.py b/common/components/filters.py index 6003c57..3ea1ef7 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -7,6 +7,7 @@ from django.utils.html import escape from django.utils.safestring import SafeText, mark_safe from common.components.core import Component +from common.components.primitives import Label, Span class FilterChoice(NamedTuple): @@ -115,8 +116,7 @@ def _filter_field(label: str, widget) -> SafeText: tag_name="div", attributes=[("class", "flex flex-col gap-1")], children=[ - Component( - tag_name="label", + Label( attributes=[("class", _FILTER_LABEL_CLASS)], children=[label], ), @@ -143,8 +143,7 @@ def _filter_number(label, name, value="", placeholder="") -> SafeText: def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: - return Component( - tag_name="label", + return Label( attributes=[("class", "flex items-center gap-2 text-sm text-heading")], children=[ Component( @@ -216,8 +215,7 @@ def RangeSlider( tag_name="div", attributes=[("class", "flex items-center gap-2 mb-1")], children=[ - Component( - tag_name="label", + Label( attributes=[ ("class", _FILTER_LABEL_CLASS), ("for", min_input_id), @@ -239,8 +237,7 @@ def RangeSlider( ), ], ), - Component( - tag_name="span", + Span( attributes=[ ( "class", @@ -280,8 +277,7 @@ def RangeSlider( ), ], children=[ - Component( - tag_name="span", + Span( attributes=[ ( "class", @@ -291,8 +287,7 @@ def RangeSlider( ], children=[mark_safe(_RANGE_ICON_SVG)], ), - Component( - tag_name="span", + Span( attributes=[ ( "class", @@ -444,8 +439,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: ], children=["Clear"], ), - Component( - tag_name="span", + Span( attributes=[ ("class", "flex gap-2 items-center"), ("id", "save-preset-area"), @@ -510,8 +504,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText: ("data-preset-list-url", preset_list_url), ], children=[ - Component( - tag_name="span", + Span( attributes=[("class", "text-sm text-body")], children=["Loading presets..."], ), @@ -684,16 +677,14 @@ def _selectable_filter_tag( """A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter.""" checkmark = "\u2717" if excluded else "\u2713" css = "sf-tag sf-excluded" if excluded else "sf-tag" - return Component( - tag_name="span", + return Span( attributes=[ ("class", css), ("data-value", value), ("data-type", "exclude" if excluded else "include"), ], children=[ - Component( - tag_name="span", + Span( attributes=[("class", "sf-tag-text")], children=[f"{checkmark} {label}"], ), @@ -712,8 +703,7 @@ def _selectable_filter_tag( def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText: """An active modifier pill ((Any) / (None)) in the SelectableFilter.""" - return Component( - tag_name="span", + return Span( attributes=[ ("class", "sf-modifier-tag active"), ("data-modifier", modifier), @@ -732,8 +722,7 @@ def _selectable_filter_modifier_option(modifier: str, label: str) -> SafeText: ("data-label", label), ], children=[ - Component( - tag_name="span", + Span( attributes=[("class", "sf-option-label")], children=[label], ), @@ -751,13 +740,11 @@ def _selectable_filter_option(value: str, label: str) -> SafeText: ("data-label", label), ], children=[ - Component( - tag_name="span", + Span( attributes=[("class", "sf-option-label")], children=[label], ), - Component( - tag_name="span", + Span( attributes=[("class", "sf-option-buttons")], children=[ Component( diff --git a/common/components/primitives.py b/common/components/primitives.py index d50f292..c6efbc8 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -42,8 +42,7 @@ def _popover_html( """ display_content = wrapped_content if wrapped_content else slot - span = Component( - tag_name="span", + span = Span( attributes=[ ("data-popover-target", id), ("class", wrapped_classes), @@ -77,8 +76,7 @@ def _popover_html( "" ), - Component( - tag_name="span", + Span( attributes=[("class", "hidden decoration-dotted")], ), ], @@ -353,6 +351,74 @@ def Input( ) +def Span( + attributes: list[HTMLAttribute] | None = None, + children: list[HTMLTag] | HTMLTag | None = None, +) -> SafeText: + attributes = attributes or [] + children = children or [] + return Component(tag_name="span", attributes=attributes, children=children) + + +def Label( + attributes: list[HTMLAttribute] | None = None, + children: list[HTMLTag] | HTMLTag | None = None, +) -> SafeText: + attributes = attributes or [] + children = children or [] + return Component(tag_name="label", attributes=attributes, children=children) + + +# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in +# input.css, written inline so styling stays encapsulated in the component). The +# JS that builds pills client-side (search_select.js) MUST emit these exact class +# strings byte-for-byte so Tailwind generates them and server/JS pills match. +_PILL_CLASS = ( + "inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " + "bg-brand/15 text-heading" +) +_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer" + + +def Pill( + label: str, + *, + value: str = "", + removable: bool = False, + extra_class: str = "", + attributes: list[HTMLAttribute] | None = None, +) -> SafeText: + """A small label pill, optionally removable (× button). + + Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove`` + are JS hooks only (no CSS attached). ``value`` (when set) becomes + ``data-value``; extra ``attributes`` are appended to the outer span. + """ + attributes = attributes or [] + pill_class = f"{_PILL_CLASS} {extra_class}".strip() + pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")] + if value != "": + pill_attrs.append(("data-value", str(value))) + pill_attrs.extend(attributes) + + children: list[HTMLTag] = [label] + if removable: + children.append( + Component( + tag_name="button", + attributes=[ + ("type", "button"), + ("data-pill-remove", ""), + ("class", _PILL_REMOVE_CLASS), + ("aria-label", "Remove"), + ], + children=["×"], + ) + ) + + return Component(tag_name="span", attributes=pill_attrs, children=children) + + def CsrfInput(request) -> SafeText: """Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.""" return mark_safe( @@ -421,8 +487,7 @@ def SearchField( tag_name="form", attributes=[("class", "max-w-md")], children=[ - Component( - tag_name="label", + Label( attributes=[ ("for", "search"), ("class", "block mb-2.5 text-sm font-medium text-heading sr-only"), @@ -491,8 +556,7 @@ def H1( if badge: heading_class = "flex items-center " + heading_class - badge_html = Component( - tag_name="span", + badge_html = Span( attributes=[ ( "class", diff --git a/common/components/search_select.py b/common/components/search_select.py new file mode 100644 index 0000000..4addc9c --- /dev/null +++ b/common/components/search_select.py @@ -0,0 +1,182 @@ +"""Search field + dropdown select component (pure Python, domain-agnostic). + +Pairs a search box with a dropdown of options. Supports single/multi select; +in multi-select, chosen items render as removable ``Pill``s, each backed by a +hidden ```` so an existing ``ModelMultipleChoiceField`` keeps validating. + +This module imports only from ``common.components`` — it has no Django-forms or +``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are +``data-*`` attributes wired up by ``games/static/js/search_select.js``. +""" + +from collections.abc import Callable, Iterable +from typing import TypedDict + +from django.utils.safestring import SafeText + +from common.components.core import Component, HTMLAttribute +from common.components.primitives import Pill + + +class SearchSelectOption(TypedDict): + value: str | int + label: str + data: dict[str, str] # becomes data-* attrs on the row / pill + + +# removed border and border-default-medium, see later if it's needed +_CONTAINER_CLASS = "relative rounded-base bg-neutral-secondary-medium" +_PILLS_CLASS = "flex flex-wrap gap-1 p-2 empty:hidden" +_SEARCH_CLASS = ( + "block w-full border-0 bg-transparent text-sm text-heading p-2 " + "focus:ring-0 focus:outline-hidden placeholder:text-body" +) +_OPTIONS_CLASS = ( + "absolute z-10 left-0 right-0 mt-1 overflow-y-auto border border-default-medium " + "rounded-base bg-neutral-secondary-medium shadow-lg" +) +_OPTION_ROW_CLASS = "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15" +_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden" + +# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem, +# used to derive the panel's max-height from items_visible. +_ROW_HEIGHT_REM = 2.25 + + +def _normalize_option(option) -> SearchSelectOption: + """Coerce a dict option or a ``(value, label)`` tuple into the TypedDict.""" + if isinstance(option, dict): + return { + "value": option["value"], + "label": option["label"], + "data": option.get("data") or {}, + } + value, label = option + return {"value": value, "label": label, "data": {}} + + +def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]: + return [(f"data-{key}", str(value)) for key, value in data.items()] + + +def _hidden_input(name: str, value) -> SafeText: + return Component( + tag_name="input", + attributes=[("type", "hidden"), ("name", name), ("value", str(value))], + ) + + +def _option_row(option: SearchSelectOption) -> SafeText: + return Component( + tag_name="div", + attributes=[ + ("data-ss-option", ""), + ("data-value", str(option["value"])), + ("data-label", option["label"]), + ("class", _OPTION_ROW_CLASS), + *_data_attributes(option["data"]), + ], + children=[option["label"]], + ) + + +def SearchSelect( + *, + name: str, + selected: list[SearchSelectOption] | None = None, + options: list[SearchSelectOption] | None = None, + search_url: str = "", + multi_select: bool = False, + always_visible: bool = False, + items_visible: int = 5, + items_scroll: int = 10, + placeholder: str = "Search…", + id: str = "", + sync_url: bool = False, +) -> SafeText: + """Render the search-select widget. See module docstring for the contract.""" + selected = [_normalize_option(o) for o in (selected or [])] + options = [_normalize_option(o) for o in (options or [])] + + # ── Pills + their hidden inputs (the submitted channel) ── + pills_children: list[SafeText] = [] + for option in selected: + pills_children.append( + Pill( + option["label"], + value=str(option["value"]), + removable=True, + attributes=_data_attributes(option["data"]), + ) + ) + pills_children.append(_hidden_input(name, option["value"])) + + pills = Component( + tag_name="div", + attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)], + children=pills_children, + ) + + # ── 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), + ], + ) + + # ── 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 [] + no_results = Component( + tag_name="div", + attributes=[("data-ss-no-results", ""), ("class", _NO_RESULTS_CLASS)], + children=["No results"], + ) + options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden" + options_panel = Component( + tag_name="div", + attributes=[ + ("data-ss-options", ""), + ("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"), + ("class", options_class), + ], + children=[*option_rows, no_results], + ) + + container_attrs: list[HTMLAttribute] = [ + ("data-search-select", ""), + ("data-name", name), + ("data-search-url", search_url), + ("data-multi", "true" if multi_select else "false"), + ("data-always-visible", "true" if always_visible else "false"), + ("data-items-visible", str(items_visible)), + ("data-items-scroll", str(items_scroll)), + ("data-sync-url", "true" if sync_url else "false"), + ("class", _CONTAINER_CLASS), + ] + if id: + container_attrs.append(("id", id)) + + return Component( + tag_name="div", + attributes=container_attrs, + children=[pills, search, options_panel], + ) + + +def searchselect_selected( + values: list, + resolver: Callable[[list], Iterable[SearchSelectOption]], +) -> list[SearchSelectOption]: + """Resolve ``values`` into ``SearchSelectOption``s via ``resolver``. + + ``resolver(values)`` should resolve ONLY the given ids (a ``pk__in`` query) + — never iterating all choices, so it stays cheap. + """ + if not values: + return [] + return [_normalize_option(o) for o in resolver(values)] diff --git a/games/api.py b/games/api.py index a6e0440..8b205b6 100644 --- a/games/api.py +++ b/games/api.py @@ -2,6 +2,7 @@ from datetime import date, datetime from typing import List from django.contrib import messages +from django.db.models import Q 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 @@ -50,6 +51,27 @@ class PlayEventOut(Schema): created_at: datetime +class GameOption(Schema): # mirrors SearchSelectOption + value: int + label: str + data: dict + + +@game_router.get("/search", response=list[GameOption]) +def search_games(request, q: str = "", limit: int = 10): + qs = Game.objects.select_related("platform").order_by("sort_name") + if q: + qs = qs.filter(Q(name__icontains=q) | Q(sort_name__icontains=q)) + return [ + { + "value": g.id, + "label": g.search_label, + "data": {"platform": g.platform_id or ""}, + } + for g in qs[:limit] + ] + + @game_router.patch("/{game_id}/status", response={204: None}) def partial_update_game(request, game_id: int, payload: GameStatusUpdate): game = get_object_or_404(Game, id=game_id) diff --git a/games/forms.py b/games/forms.py index 0f5323a..15dfc0d 100644 --- a/games/forms.py +++ b/games/forms.py @@ -1,8 +1,12 @@ from django import forms from django.db import transaction -from django.urls import reverse +from django.db.models import OuterRef, Subquery -from common.utils import safe_getattr +from common.components import ( + SearchSelect, + SearchSelectOption, + searchselect_selected, +) from games.models import ( Device, Game, @@ -22,18 +26,90 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) class MultipleGameChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, obj) -> str: - return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" + return obj.search_label class SingleGameChoiceField(forms.ModelChoiceField): def label_from_instance(self, obj) -> str: - return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" + return obj.search_label + + +def _game_options(values) -> list[SearchSelectOption]: + """Resolve game ids (or instances) to SearchSelectOptions via one pk__in query.""" + return [ + { + "value": g.id, + "label": g.search_label, + "data": {"platform": g.platform_id or ""}, + } + for g in Game.objects.filter(pk__in=values).select_related("platform") + ] + + +class SearchSelectWidget(forms.Widget): + """Thin Django adapter that renders a `SearchSelect()` component. + + The only place that knows about Django/forms — the component itself stays + reusable outside forms. + """ + + def __init__( + self, + *, + search_url, + multi_select=False, + items_visible=5, + items_scroll=10, + always_visible=False, + placeholder="Search…", + attrs=None, + ): + super().__init__(attrs) + self.search_url = search_url + self.multi_select = multi_select + self.items_visible = items_visible + self.items_scroll = items_scroll + self.always_visible = always_visible + self.placeholder = placeholder + + @staticmethod + def _values(value) -> list: + if value is None: + return [] + if isinstance(value, (list, tuple)): + return [v for v in value if v not in (None, "")] + 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) + return SearchSelect( + name=name, + selected=selected, + options=None, + search_url=self.search_url, + multi_select=self.multi_select, + items_visible=self.items_visible, + items_scroll=self.items_scroll, + always_visible=self.always_visible, + placeholder=self.placeholder, + id=(attrs or {}).get("id", ""), + ) + + def value_from_datadict(self, data, files, name): + return data.get(name) + + +class SearchSelectMultiple(SearchSelectWidget): + def value_from_datadict(self, data, files, name): + if hasattr(data, "getlist"): + return data.getlist(name) + return data.get(name) class SessionForm(forms.ModelForm): game = SingleGameChoiceField( queryset=Game.objects.order_by("sort_name"), - widget=forms.Select(attrs={"autofocus": "autofocus"}), + widget=SearchSelectWidget(search_url="/api/games/search"), ) duration_manual = forms.DurationField( @@ -83,38 +159,43 @@ class SessionForm(forms.ModelForm): return session -class IncludePlatformSelect(forms.SelectMultiple): - def create_option(self, name, value, *args, **kwargs): - option = super().create_option(name, value, *args, **kwargs) - if platform_id := safe_getattr(value, "instance.platform.id"): - option["attrs"]["data-platform"] = platform_id - return option +def related_purchase_queryset(): + """GAME purchases annotated with their first game's name. + + Rendering the ``related_purchase`` ```` calls ``str()`` on every + option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra + query per option (700+ on a large library). Annotating the first game's + name via a subquery lets the choice field build labels without those + per-row queries. + """ + first_game_name = Subquery( + Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1] + ) + return Purchase.objects.filter(type=Purchase.GAME).annotate( + _first_game_name=first_game_name + ) + + +class RelatedPurchaseChoiceField(forms.ModelChoiceField): + def label_from_instance(self, obj) -> str: + # Mirrors Purchase.standardized_name but reads the annotated first-game + # name instead of querying first_game per option. + name = obj.name or getattr(obj, "_first_game_name", None) + return name or obj.standardized_name class PurchaseForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - - # Automatically update related_purchase - # to only include purchases of the selected game. - related_purchase_by_game_url = reverse("games:related_purchase_by_game") - self.fields["games"].widget.attrs.update( - { - "hx-trigger": "load, click", - "hx-get": related_purchase_by_game_url, - "hx-target": "#id_related_purchase", - "hx-swap": "outerHTML", - } - ) self.fields["platform"].queryset = Platform.objects.order_by("name") games = MultipleGameChoiceField( queryset=Game.objects.order_by("sort_name"), - widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), + widget=SearchSelectMultiple(search_url="/api/games/search", multi_select=True), ) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) - related_purchase = forms.ModelChoiceField( - queryset=Purchase.objects.filter(type=Purchase.GAME), + related_purchase = RelatedPurchaseChoiceField( + queryset=related_purchase_queryset(), required=False, ) diff --git a/games/migrations/0018_alter_session_timestamp_start.py b/games/migrations/0018_alter_session_timestamp_start.py new file mode 100644 index 0000000..9e8bbde --- /dev/null +++ b/games/migrations/0018_alter_session_timestamp_start.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-06-06 20:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0017_add_filter_preset'), + ] + + operations = [ + migrations.AlterField( + model_name='session', + name='timestamp_start', + field=models.DateTimeField(db_index=True, verbose_name='Start'), + ), + ] diff --git a/games/models.py b/games/models.py index 7cae0b5..f8dd269 100644 --- a/games/models.py +++ b/games/models.py @@ -65,6 +65,10 @@ class Game(models.Model): def __str__(self): return self.name + @property + def search_label(self) -> str: + return f"{self.sort_name} ({self.platform}, {self.year_released})" + def finished(self): return ( self.status == self.Status.FINISHED @@ -290,7 +294,7 @@ class Session(models.Model): default=None, related_name="sessions", ) - timestamp_start = models.DateTimeField(verbose_name="Start") + timestamp_start = models.DateTimeField(verbose_name="Start", db_index=True) timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End") duration_manual = models.DurationField( blank=True, null=True, default=timedelta(0), verbose_name="Manual duration" diff --git a/games/static/base.css b/games/static/base.css index f2cb7aa..0279af6 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -293,27 +293,85 @@ --leading-5: 20px; --radius-base: 12px; --color-body: var(--color-gray-600); + --color-body-subtle: var(--color-gray-500); --color-heading: var(--color-gray-900); + --color-fg-brand-subtle: var(--color-blue-200); --color-fg-brand: var(--color-blue-700); + --color-fg-brand-strong: var(--color-blue-900); + --color-fg-success: var(--color-emerald-700); + --color-fg-success-strong: var(--color-emerald-900); + --color-fg-danger: var(--color-rose-700); + --color-fg-danger-strong: var(--color-rose-900); + --color-fg-warning-subtle: var(--color-orange-600); + --color-fg-warning: var(--color-orange-900); + --color-fg-yellow: var(--color-yellow-400); --color-fg-disabled: var(--color-gray-400); + --color-fg-purple: var(--color-purple-600); + --color-fg-cyan: var(--color-cyan-600); + --color-fg-indigo: var(--color-indigo-600); + --color-fg-pink: var(--color-pink-600); + --color-fg-lime: var(--color-lime-600); --color-neutral-primary-soft: var(--color-white); --color-neutral-primary: var(--color-white); --color-neutral-primary-medium: var(--color-white); + --color-neutral-primary-strong: var(--color-white); --color-neutral-secondary-soft: var(--color-gray-50); --color-neutral-secondary: var(--color-gray-50); --color-neutral-secondary-medium: var(--color-gray-50); --color-neutral-secondary-strong: var(--color-gray-50); + --color-neutral-secondary-strongest: var(--color-gray-50); + --color-neutral-tertiary-soft: var(--color-gray-100); --color-neutral-tertiary: var(--color-gray-100); --color-neutral-tertiary-medium: var(--color-gray-100); --color-neutral-quaternary: var(--color-gray-200); + --color-neutral-quaternary-medium: var(--color-gray-200); + --color-gray: var(--color-gray-300); + --color-brand-softer: var(--color-blue-50); --color-brand-soft: var(--color-blue-100); --color-brand: var(--color-blue-700); --color-brand-medium: var(--color-blue-200); --color-brand-strong: var(--color-blue-800); + --color-success-soft: var(--color-emerald-50); + --color-success: var(--color-emerald-700); + --color-success-medium: var(--color-emerald-100); + --color-success-strong: var(--color-emerald-800); + --color-danger-soft: var(--color-rose-50); + --color-danger: var(--color-rose-700); + --color-danger-medium: var(--color-rose-100); + --color-danger-strong: var(--color-rose-800); + --color-warning-soft: var(--color-orange-50); + --color-warning: var(--color-orange-500); + --color-warning-medium: var(--color-orange-100); + --color-warning-strong: var(--color-orange-700); + --color-dark-soft: var(--color-gray-800); --color-dark: var(--color-gray-800); + --color-dark-strong: var(--color-gray-900); + --color-disabled: var(--color-gray-100); + --color-purple: var(--color-purple-500); + --color-sky: var(--color-sky-500); + --color-teal: var(--color-teal-600); + --color-pink: var(--color-pink-600); + --color-cyan: var(--color-cyan-500); + --color-fuchsia: var(--color-fuchsia-600); + --color-indigo: var(--color-indigo-600); + --color-orange: var(--color-orange-400); + --color-buffer: var(--color-white); + --color-buffer-medium: var(--color-white); + --color-buffer-strong: var(--color-white); + --color-muted: var(--color-gray-50); + --color-light-subtle: var(--color-gray-100); --color-light: var(--color-gray-100); + --color-light-medium: var(--color-gray-100); + --color-default-subtle: var(--color-gray-200); --color-default: var(--color-gray-200); --color-default-medium: var(--color-gray-200); + --color-default-strong: var(--color-gray-200); + --color-success-subtle: var(--color-emerald-200); + --color-danger-subtle: var(--color-rose-200); + --color-warning-subtle: var(--color-orange-200); + --color-brand-subtle: var(--color-blue-200); + --color-brand-light: var(--color-blue-600); + --color-dark-subtle: var(--color-gray-800); --color-dark-backdrop: var(--color-gray-950); --color-accent: #7c3aed; } @@ -820,12 +878,18 @@ .start-0 { inset-inline-start: calc(var(--spacing) * 0); } + .end-1 { + inset-inline-end: calc(var(--spacing) * 1); + } .end-1\.5 { inset-inline-end: calc(var(--spacing) * 1.5); } .top-0 { top: calc(var(--spacing) * 0); } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1 / 2 * 100%); } @@ -847,6 +911,9 @@ .bottom-0 { bottom: calc(var(--spacing) * 0); } + .bottom-1 { + bottom: calc(var(--spacing) * 1); + } .bottom-1\.5 { bottom: calc(var(--spacing) * 1.5); } @@ -1276,6 +1343,9 @@ margin-left: -10px !important; } } + .ml-1 { + margin-left: calc(var(--spacing) * 1); + } .ml-4 { margin-left: calc(var(--spacing) * 4); } @@ -1470,9 +1540,15 @@ .h-full { height: 100%; } + .max-h-40 { + max-height: calc(var(--spacing) * 40); + } .max-h-full { max-height: 100%; } + .min-h-\[28px\] { + min-height: 28px; + } .min-h-screen { min-height: 100vh; } @@ -1541,9 +1617,15 @@ text-align: center; } } + .w-1 { + width: calc(var(--spacing) * 1); + } .w-1\/2 { width: calc(1 / 2 * 100%); } + .w-2 { + width: calc(var(--spacing) * 2); + } .w-2\.5 { width: calc(var(--spacing) * 2.5); } @@ -1652,6 +1734,9 @@ .shrink-0 { flex-shrink: 0; } + .border-collapse { + border-collapse: collapse; + } .-translate-x-full { --tw-translate-x: -100%; translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1668,6 +1753,10 @@ --tw-translate-x: 100%; translate: var(--tw-translate-x) var(--tw-translate-y); } + .-translate-y-1 { + --tw-translate-y: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1710,6 +1799,9 @@ .list-disc { list-style-type: disc; } + .appearance-none { + appearance: none; + } .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } @@ -2053,6 +2145,9 @@ .bg-amber-50 { background-color: var(--color-amber-50); } + .bg-black { + background-color: var(--color-black); + } .bg-black\/70 { background-color: color-mix(in srgb, #000 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2071,6 +2166,15 @@ .bg-brand { background-color: var(--color-brand); } + .bg-brand\/15 { + background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-brand) 15%, transparent); + } + } + .bg-dark-backdrop { + background-color: var(--color-dark-backdrop); + } .bg-dark-backdrop\/70 { background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2089,12 +2193,18 @@ .bg-gray-500 { background-color: var(--color-gray-500); } + .bg-gray-800 { + background-color: var(--color-gray-800); + } .bg-gray-800\/20 { background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent); } } + .bg-gray-900 { + background-color: var(--color-gray-900); + } .bg-gray-900\/50 { background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2209,6 +2319,18 @@ fill: white !important; } } + .apexcharts-gridline { + stroke: var(--color-default) !important; + .dark & { + stroke: var(--color-default) !important; + } + } + .apexcharts-xcrosshairs { + stroke: var(--color-default) !important; + .dark & { + stroke: var(--color-default) !important; + } + } .apexcharts-ycrosshairs { stroke: var(--color-default) !important; .dark & { @@ -2267,6 +2389,9 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } + .py-0 { + padding-block: calc(var(--spacing) * 0); + } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -2328,6 +2453,9 @@ color: heading !important; } } + .pb-1 { + padding-bottom: calc(var(--spacing) * 1); + } .pb-16 { padding-bottom: calc(var(--spacing) * 16); } @@ -2494,6 +2622,9 @@ .text-balance { text-wrap: balance; } + .text-wrap { + text-wrap: wrap; + } .whitespace-nowrap { white-space: nowrap; } @@ -2620,6 +2751,9 @@ .italic { font-style: italic; } + .no-underline { + text-decoration-line: none; + } .no-underline\! { text-decoration-line: none !important; } @@ -2683,6 +2817,10 @@ -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } + .backdrop-filter { + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); @@ -2847,6 +2985,11 @@ background-color: var(--color-gray-50); } } + .empty\:hidden { + &:empty { + display: none; + } + } .hover\:scale-110 { &:hover { @media (hover: hover) { @@ -2892,6 +3035,16 @@ } } } + .hover\:bg-brand\/15 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-brand) 15%, transparent); + } + } + } + } .hover\:bg-gray-50 { &:hover { @media (hover: hover) { @@ -3068,6 +3221,12 @@ color: var(--color-blue-700); } } + .focus\:ring-0 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } .focus\:ring-2 { &:focus { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); @@ -3883,6 +4042,51 @@ } } } + .\[\&\:\:-webkit-slider-thumb\]\:relative { + &::-webkit-slider-thumb { + position: relative; + } + } + .\[\&\:\:-webkit-slider-thumb\]\:z-10 { + &::-webkit-slider-thumb { + z-index: 10; + } + } + .\[\&\:\:-webkit-slider-thumb\]\:z-20 { + &::-webkit-slider-thumb { + z-index: 20; + } + } + .\[\&\:\:-webkit-slider-thumb\]\:h-4 { + &::-webkit-slider-thumb { + height: calc(var(--spacing) * 4); + } + } + .\[\&\:\:-webkit-slider-thumb\]\:w-4 { + &::-webkit-slider-thumb { + width: calc(var(--spacing) * 4); + } + } + .\[\&\:\:-webkit-slider-thumb\]\:cursor-pointer { + &::-webkit-slider-thumb { + cursor: pointer; + } + } + .\[\&\:\:-webkit-slider-thumb\]\:appearance-none { + &::-webkit-slider-thumb { + appearance: none; + } + } + .\[\&\:\:-webkit-slider-thumb\]\:rounded-full { + &::-webkit-slider-thumb { + border-radius: calc(infinity * 1px); + } + } + .\[\&\:\:-webkit-slider-thumb\]\:bg-brand { + &::-webkit-slider-thumb { + background-color: var(--color-brand); + } + } .\[\&\:first-of-type_button\]\:rounded-s-lg { &:first-of-type button { border-start-start-radius: var(--radius-lg); diff --git a/games/static/js/add_purchase.js b/games/static/js/add_purchase.js index 720be9b..248d38a 100644 --- a/games/static/js/add_purchase.js +++ b/games/static/js/add_purchase.js @@ -1,20 +1,35 @@ -import { - syncSelectInputUntilChanged, - getEl, - disableElementsWhenTrue, - disableElementsWhenValueNotEqual, -} from "./utils.js"; +import { getEl, disableElementsWhenTrue } from "./utils.js"; -let syncData = [ - { - source: "#id_games", - source_value: "dataset.platform", - target: "#id_platform", - target_value: "value", - }, -]; +const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game"; -syncSelectInputUntilChanged(syncData, "form"); +// The games field is now a SearchSelect widget (a , not a ), so we +// react to its custom "search-select:change" event instead of syncing a select. +document.addEventListener("search-select:change", (event) => { + if (event.detail.name !== "games") return; + + // (a) Auto-fill platform from the clicked option's data-platform. + const last = event.detail.last; + const platformId = last && last.data ? last.data.platform : ""; + if (platformId) { + const platformEl = getEl("#id_platform"); + if (platformEl) platformEl.value = platformId; + } + + // (b) Refresh #id_related_purchase for the currently selected games. + const query = event.detail.values + .map((value) => "games=" + encodeURIComponent(value)) + .join("&"); + fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" }) + .then((response) => { + if (response.status === 204) return null; + return response.text(); + }) + .then((html) => { + if (html === null) return; + const target = getEl("#id_related_purchase"); + if (target) target.outerHTML = html; + }); +}); function setupElementHandlers() { disableElementsWhenTrue("#id_type", "game", [ @@ -27,5 +42,4 @@ document.addEventListener("DOMContentLoaded", setupElementHandlers); document.addEventListener("htmx:afterSwap", setupElementHandlers); getEl("#id_type").addEventListener("change", () => { setupElementHandlers(); -} -); +}); diff --git a/games/static/js/search_select.js b/games/static/js/search_select.js new file mode 100644 index 0000000..99cc7c0 --- /dev/null +++ b/games/static/js/search_select.js @@ -0,0 +1,277 @@ +/** + * SearchSelect widget — a search box paired with a dropdown of options. + * Single/multi select; chosen items render as removable pills, each backed by a + * hidden so existing Django form validation keeps working. + * + * Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap, + * each widget guarded with el._ssInit. + * + * The pill / option class strings below are kept byte-identical to the Python + * Pill / SearchSelect components so Tailwind generates the classes and + * server-rendered and JS-created pills are indistinguishable. + */ +(function () { + "use strict"; + + var PILL_CLASS = + "inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " + + "bg-brand/15 text-heading"; + var PILL_REMOVE_CLASS = + "ml-1 text-body hover:text-heading font-bold cursor-pointer"; + var OPTION_ROW_CLASS = + "px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15"; + + var DEBOUNCE_MS = 500; + + function initAll() { + document.querySelectorAll("[data-search-select]").forEach(function (el) { + if (el._ssInit) return; + el._ssInit = true; + initWidget(el); + }); + } + + function initWidget(container) { + var search = container.querySelector("[data-ss-search]"); + var options = container.querySelector("[data-ss-options]"); + var pills = container.querySelector("[data-ss-pills]"); + if (!search || !options || !pills) return; + + var name = container.getAttribute("data-name"); + var searchUrl = container.getAttribute("data-search-url"); + var multi = container.getAttribute("data-multi") === "true"; + var alwaysVisible = container.getAttribute("data-always-visible") === "true"; + var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10; + var syncUrl = container.getAttribute("data-sync-url") === "true"; + + var noResults = options.querySelector("[data-ss-no-results]"); + var debounceTimer = null; + + function showPanel() { + options.classList.remove("hidden"); + } + function hidePanel() { + if (!alwaysVisible) options.classList.add("hidden"); + } + + function setNoResults(visible) { + if (noResults) noResults.classList.toggle("hidden", !visible); + } + + // ── Render server-fetched rows into the panel ── + function renderRows(items) { + options.querySelectorAll("[data-ss-option]").forEach(function (r) { + r.remove(); + }); + items.slice(0, itemsScroll).forEach(function (item) { + options.insertBefore(buildRow(item), noResults || null); + }); + setNoResults(items.length === 0); + showPanel(); + } + + function buildRow(option) { + var row = document.createElement("div"); + row.setAttribute("data-ss-option", ""); + row.setAttribute("data-value", option.value); + row.setAttribute("data-label", option.label); + row.className = OPTION_ROW_CLASS; + var data = option.data || {}; + Object.keys(data).forEach(function (key) { + row.setAttribute("data-" + key, data[key]); + }); + row.textContent = option.label; + row._ssOption = option; + return row; + } + + // ── Client-side filter of pre-rendered rows ── + function filterRows(q) { + var lower = q.toLowerCase(); + var anyVisible = false; + options.querySelectorAll("[data-ss-option]").forEach(function (item) { + var label = (item.getAttribute("data-label") || "").toLowerCase(); + var match = label.indexOf(lower) !== -1; + item.style.display = match ? "" : "none"; + if (match) anyVisible = true; + }); + setNoResults(!anyVisible); + showPanel(); + } + + function runSearch() { + var q = search.value.trim(); + if (searchUrl && q) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(function () { + fetch(searchUrl + "?q=" + encodeURIComponent(q), { + credentials: "same-origin", + }) + .then(function (r) { + return r.json(); + }) + .then(renderRows) + .catch(function () { + setNoResults(true); + }); + }, DEBOUNCE_MS); + } else { + filterRows(q); + } + } + + search.addEventListener("focus", runSearch); + search.addEventListener("input", runSearch); + + // ── Option click → select ── + options.addEventListener("click", function (e) { + var row = e.target.closest("[data-ss-option]"); + if (!row) return; + var option = optionFromRow(row); + selectOption(option); + }); + + function optionFromRow(row) { + if (row._ssOption) return row._ssOption; + var data = {}; + Object.keys(row.dataset).forEach(function (key) { + if (key !== "value" && key !== "label" && key !== "ssOption") { + data[key] = row.dataset[key]; + } + }); + return { + value: row.getAttribute("data-value"), + label: row.getAttribute("data-label"), + data: data, + }; + } + + function selectOption(option) { + if (multi) { + if (!pills.querySelector('input[value="' + cssEscape(option.value) + '"]')) { + addPill(option); + } + } else { + pills.innerHTML = ""; + addPill(option); + search.value = option.label; + hidePanel(); + } + emitChange(option); + } + + function addPill(option) { + pills.appendChild(buildPill(option)); + pills.appendChild(buildHidden(option.value)); + } + + function buildPill(option) { + var pill = document.createElement("span"); + pill.className = PILL_CLASS; + pill.setAttribute("data-pill", ""); + pill.setAttribute("data-value", option.value); + var data = option.data || {}; + Object.keys(data).forEach(function (key) { + pill.setAttribute("data-" + key, data[key]); + }); + pill.appendChild(document.createTextNode(option.label)); + var remove = document.createElement("button"); + remove.type = "button"; + remove.setAttribute("data-pill-remove", ""); + remove.className = PILL_REMOVE_CLASS; + remove.setAttribute("aria-label", "Remove"); + remove.textContent = "×"; + pill.appendChild(remove); + return pill; + } + + function buildHidden(value) { + var input = document.createElement("input"); + input.type = "hidden"; + input.name = name; + input.value = value; + return input; + } + + // ── Pill × → remove ── + pills.addEventListener("click", function (e) { + var removeBtn = e.target.closest("[data-pill-remove]"); + if (!removeBtn) return; + var pill = removeBtn.closest("[data-pill]"); + if (!pill) return; + var value = pill.getAttribute("data-value"); + pill.remove(); + var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]'); + if (hidden) hidden.remove(); + emitChange(null); + }); + + function currentValues() { + return Array.prototype.map.call( + pills.querySelectorAll('input[type="hidden"]'), + function (input) { + return input.value; + } + ); + } + + function emitChange(last) { + var values = currentValues(); + if (syncUrl) syncToUrl(values); + container.dispatchEvent( + new CustomEvent("search-select:change", { + bubbles: true, + detail: { name: name, values: values, last: last }, + }) + ); + } + + function syncToUrl(values) { + var params = new URLSearchParams(window.location.search); + params.delete(name); + values.forEach(function (v) { + params.append(name, v); + }); + var qs = params.toString(); + history.replaceState(null, "", qs ? "?" + qs : window.location.pathname); + } + + // On init, restore from URL params if the server supplied no selected pills. + if (syncUrl && !pills.querySelector("[data-pill]")) { + var initial = new URLSearchParams(window.location.search).getAll(name); + initial.forEach(function (v) { + addPill({ value: v, label: v, data: {} }); + }); + } + + // ── Close panel on outside click ── + document.addEventListener("click", function (e) { + if (!container.contains(e.target)) hidePanel(); + }); + } + + /** Minimal escape for use inside an attribute-value selector. */ + function cssEscape(value) { + return String(value).replace(/["\\]/g, "\\$&"); + } + + // Forward-looking hook (parallels readSelectableFilters): write each widget's + // current values to a data-values JSON attribute. + window.readSearchSelect = function (form) { + form.querySelectorAll("[data-search-select]").forEach(function (container) { + var pills = container.querySelector("[data-ss-pills]"); + var values = pills + ? Array.prototype.map.call( + pills.querySelectorAll('input[type="hidden"]'), + function (input) { + return input.value; + } + ) + : []; + container.setAttribute("data-values", JSON.stringify(values)); + }); + }; + + document.addEventListener("DOMContentLoaded", initAll); + document.addEventListener("htmx:afterSwap", initAll); +})(); diff --git a/games/views/general.py b/games/views/general.py index 1be95eb..bb592ca 100644 --- a/games/views/general.py +++ b/games/views/general.py @@ -10,6 +10,7 @@ from django.db.models import ( from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect from django.urls import reverse +from django.utils.timezone import localtime from django.utils.timezone import now as timezone_now from common.layout import render_page @@ -21,11 +22,14 @@ from games.views.stats_data import compute_stats def model_counts(request: HttpRequest) -> dict[str, bool]: now = timezone_now() - this_day, this_month, this_year = now.day, now.month, now.year + # Use a contiguous [midnight, next midnight) range in the active timezone + # instead of day/month/year extracts: a range filter can use an index on + # timestamp_start, whereas the extracts force a per-row datetime function. + start_of_today = localtime(now).replace(hour=0, minute=0, second=0, microsecond=0) + start_of_tomorrow = start_of_today + timedelta(days=1) today_played = Session.objects.filter( - timestamp_start__day=this_day, - timestamp_start__month=this_month, - timestamp_start__year=this_year, + timestamp_start__gte=start_of_today, + timestamp_start__lt=start_of_tomorrow, ).aggregate(time=Sum(F("duration_total")))["time"] last_7_played = Session.objects.filter( timestamp_start__gte=(now - timedelta(days=7)) diff --git a/games/views/purchase.py b/games/views/purchase.py index 6dda8d1..70309e5 100644 --- a/games/views/purchase.py +++ b/games/views/purchase.py @@ -203,7 +203,9 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse: request, AddForm(form, request=request, additional_row=_purchase_additional_row()), title="Add New Purchase", - scripts=ModuleScript("add_purchase.js"), + scripts=mark_safe( + ModuleScript("search_select.js") + ModuleScript("add_purchase.js") + ), ) @@ -219,7 +221,9 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: request, AddForm(form, request=request, additional_row=_purchase_additional_row()), title="Edit Purchase", - scripts=ModuleScript("add_purchase.js"), + scripts=mark_safe( + ModuleScript("search_select.js") + ModuleScript("add_purchase.js") + ), ) @@ -401,8 +405,10 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def related_purchase_by_game(request: HttpRequest) -> HttpResponse: games: list[str] = request.GET.getlist("games") if games: + from games.forms import related_purchase_queryset + form = PurchaseForm() - qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by( + qs = related_purchase_queryset().filter(games__in=games).order_by( "games__sort_name" ) diff --git a/games/views/session.py b/games/views/session.py index 539c755..782e074 100644 --- a/games/views/session.py +++ b/games/views/session.py @@ -270,7 +270,9 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse: request, AddForm(form, request=request, fields=_session_fields(form), submit_class=""), title="Add New Session", - scripts=ModuleScript("add_session.js"), + scripts=mark_safe( + ModuleScript("search_select.js") + ModuleScript("add_session.js") + ), ) @@ -285,7 +287,9 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse: request, AddForm(form, request=request, fields=_session_fields(form), submit_class=""), title="Edit Session", - scripts=ModuleScript("add_session.js"), + scripts=mark_safe( + ModuleScript("search_select.js") + ModuleScript("add_session.js") + ), ) diff --git a/tests/test_search_select.py b/tests/test_search_select.py new file mode 100644 index 0000000..1f6599a --- /dev/null +++ b/tests/test_search_select.py @@ -0,0 +1,177 @@ +"""Tests for the SearchSelect component, the Pill primitive, the games resolver, +the search API endpoint, and the shared Game.search_label.""" + +import unittest + +import django.test +from django.utils.safestring import SafeText + +from common.components import ( + Pill, + SearchSelect, + searchselect_selected, +) +from games.models import Game, Platform + + +class PillTest(unittest.TestCase): + def test_returns_safetext(self): + self.assertIsInstance(Pill("hi"), SafeText) + + def test_plain_pill_has_data_pill_no_remove(self): + html = Pill("hi") + self.assertIn("data-pill", html) + self.assertNotIn("data-pill-remove", html) + + def test_removable_adds_remove_button(self): + html = Pill("hi", removable=True) + self.assertIn("data-pill-remove", html) + self.assertIn('aria-label="Remove"', html) + + def test_value_becomes_data_value(self): + html = Pill("hi", value="42") + self.assertIn('data-value="42"', html) + + def test_no_value_omits_data_value(self): + self.assertNotIn("data-value", Pill("hi")) + + def test_label_is_escaped(self): + html = Pill("x") + self.assertIn("<b>", html) + self.assertNotIn("x", html) + + def test_extra_data_attributes(self): + html = Pill("hi", attributes=[("data-platform", "3")]) + self.assertIn('data-platform="3"', html) + + +class SearchSelectComponentTest(unittest.TestCase): + def test_returns_safetext(self): + self.assertIsInstance(SearchSelect(name="games"), SafeText) + + def test_empty_options_renders_no_results_scaffold(self): + html = SearchSelect(name="games") + self.assertIn("data-ss-no-results", html) + self.assertIn("No results", html) + + def test_outer_container_carries_config(self): + html = SearchSelect( + name="games", search_url="/api/games/search", multi_select=True + ) + self.assertIn("data-search-select", html) + self.assertIn('data-name="games"', html) + self.assertIn('data-search-url="/api/games/search"', html) + self.assertIn('data-multi="true"', html) + + def test_selected_renders_pills_and_hidden_inputs(self): + html = SearchSelect( + name="games", + selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}], + ) + self.assertIn("data-pill", html) + self.assertIn('', html) + self.assertIn('data-platform="2"', html) + # exactly one submitted value (the hidden input) — the search box has no + # name. The leading space avoids matching the container's data-name. + self.assertEqual(html.count(' name="games"'), 1) + + def test_search_box_has_no_name(self): + html = SearchSelect(name="games") + self.assertIn("data-ss-search", html) + # container exposes data-name, never a submittable name on the search box + self.assertEqual(html.count(' name="games"'), 0) + + def test_tuple_options_are_normalized(self): + html = SearchSelect(name="t", options=[("1", "One")]) + self.assertIn('data-ss-option=""', html) + self.assertIn('data-value="1"', html) + self.assertIn("One", html) + + def test_options_omitted_when_search_url_set(self): + html = SearchSelect( + name="t", options=[("1", "One")], search_url="/api/games/search" + ) + self.assertNotIn('data-ss-option=""', html) + + +class SearchLabelTest(django.test.TestCase): + @classmethod + def setUpTestData(cls): + cls.platform = Platform.objects.create(name="Steam", icon="steam") + cls.game = Game.objects.create( + name="Mario", sort_name="Mario", platform=cls.platform, year_released=2020 + ) + + def test_format(self): + self.assertEqual(self.game.search_label, "Mario (Steam, 2020)") + + def test_choice_fields_use_search_label(self): + from games.forms import MultipleGameChoiceField, SingleGameChoiceField + + multi = MultipleGameChoiceField(queryset=Game.objects.all()) + single = SingleGameChoiceField(queryset=Game.objects.all()) + self.assertEqual(multi.label_from_instance(self.game), self.game.search_label) + self.assertEqual(single.label_from_instance(self.game), self.game.search_label) + + def test_api_uses_search_label(self): + from games.api import search_games + + results = search_games(None, q="Mario") + self.assertEqual(results[0]["label"], self.game.search_label) + + +class GameResolverTest(django.test.TestCase): + @classmethod + def setUpTestData(cls): + cls.platform = Platform.objects.create(name="Steam", icon="steam") + cls.g1 = Game.objects.create(name="A", sort_name="A", platform=cls.platform) + cls.g2 = Game.objects.create(name="B", sort_name="B", platform=cls.platform) + + def test_resolver_one_query(self): + from games.forms import _game_options + + with self.assertNumQueries(1): + options = list(_game_options([self.g1.id, self.g2.id])) + self.assertEqual(len(options), 2) + self.assertEqual({o["value"] for o in options}, {self.g1.id, self.g2.id}) + + def test_searchselect_selected_wraps_resolver(self): + from games.forms import _game_options + + options = searchselect_selected([self.g1.id], _game_options) + self.assertEqual(len(options), 1) + self.assertEqual(options[0]["value"], self.g1.id) + self.assertEqual(options[0]["data"]["platform"], self.platform.id) + + def test_searchselect_selected_empty(self): + self.assertEqual(searchselect_selected([], lambda v: []), []) + + +class SearchGamesApiTest(django.test.TestCase): + @classmethod + def setUpTestData(cls): + cls.platform = Platform.objects.create(name="Steam", icon="steam") + for name in ["Mario", "Zelda", "Metroid"]: + Game.objects.create(name=name, sort_name=name, platform=cls.platform) + + def test_filters_by_q(self): + from games.api import search_games + + results = search_games(None, q="mar") + self.assertEqual([r["label"].split(" (")[0] for r in results], ["Mario"]) + + def test_respects_limit(self): + from games.api import search_games + + results = search_games(None, q="", limit=2) + self.assertEqual(len(results), 2) + + def test_data_carries_platform(self): + from games.api import search_games + + results = search_games(None, q="Zelda") + self.assertEqual(results[0]["data"]["platform"], self.platform.id) + + +if __name__ == "__main__": + unittest.main()