Implement search select component
Django CI/CD / test (push) Successful in 40s
Django CI/CD / build-and-push (push) Successful in 1m24s

This commit is contained in:
2026-06-06 22:52:26 +02:00
parent 3ce3356064
commit afc16aabbb
16 changed files with 1152 additions and 97 deletions
+14
View File
@@ -25,15 +25,23 @@ from common.components.primitives import (
Input, Input,
Modal, Modal,
ModuleScript, ModuleScript,
Pill,
Popover, Popover,
PopoverTruncated, PopoverTruncated,
SearchField, SearchField,
SimpleTable, SimpleTable,
Span,
Label,
TableHeader, TableHeader,
TableRow, TableRow,
TableTd, TableTd,
paginated_table_content, paginated_table_content,
) )
from common.components.search_select import (
SearchSelect,
SearchSelectOption,
searchselect_selected,
)
from common.components.domain import ( from common.components.domain import (
GameLink, GameLink,
GameStatus, GameStatus,
@@ -70,10 +78,16 @@ __all__ = [
"Input", "Input",
"Modal", "Modal",
"ModuleScript", "ModuleScript",
"Pill",
"Popover", "Popover",
"PopoverTruncated", "PopoverTruncated",
"SearchField", "SearchField",
"SearchSelect",
"SearchSelectOption",
"searchselect_selected",
"SimpleTable", "SimpleTable",
"Span",
"Label",
"TableHeader", "TableHeader",
"TableRow", "TableRow",
"TableTd", "TableTd",
+5 -8
View File
@@ -13,6 +13,7 @@ from common.components.primitives import (
Icon, Icon,
Popover, Popover,
PopoverTruncated, PopoverTruncated,
Span,
) )
from games.models import Game, Purchase, Session from games.models import Game, Purchase, Session
@@ -29,8 +30,7 @@ def GameLink(
display = children if children else [name] display = children if children else [name]
link = reverse("games:view_game", args=[game_id]) link = reverse("games:view_game", args=[game_id])
return Component( return Span(
tag_name="span",
attributes=[("class", "truncate-container")], attributes=[("class", "truncate-container")],
children=[ children=[
Component( Component(
@@ -70,14 +70,12 @@ def GameStatus(
outer_class += f" {class_}" outer_class += f" {class_}"
dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"]) dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"])
dot = Component( dot = Span(
tag_name="span",
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")], attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
children=["\xa0"], children=["\xa0"],
) )
return Component( return Span(
tag_name="span",
attributes=[("class", outer_class)], attributes=[("class", outer_class)],
children=[dot] + (children if isinstance(children, list) else [children]), children=[dot] + (children if isinstance(children, list) else [children]),
) )
@@ -88,8 +86,7 @@ def PriceConverted(
) -> SafeText: ) -> SafeText:
"""Wrap content in a span that indicates the price was converted.""" """Wrap content in a span that indicates the price was converted."""
children = children or [] children = children or []
return Component( return Span(
tag_name="span",
attributes=[ attributes=[
("title", "Price is a result of conversion and rounding."), ("title", "Price is a result of conversion and rounding."),
("class", "decoration-dotted underline"), ("class", "decoration-dotted underline"),
+15 -28
View File
@@ -7,6 +7,7 @@ from django.utils.html import escape
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component from common.components.core import Component
from common.components.primitives import Label, Span
class FilterChoice(NamedTuple): class FilterChoice(NamedTuple):
@@ -115,8 +116,7 @@ def _filter_field(label: str, widget) -> SafeText:
tag_name="div", tag_name="div",
attributes=[("class", "flex flex-col gap-1")], attributes=[("class", "flex flex-col gap-1")],
children=[ children=[
Component( Label(
tag_name="label",
attributes=[("class", _FILTER_LABEL_CLASS)], attributes=[("class", _FILTER_LABEL_CLASS)],
children=[label], children=[label],
), ),
@@ -143,8 +143,7 @@ def _filter_number(label, name, value="", placeholder="") -> SafeText:
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText: def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
return Component( return Label(
tag_name="label",
attributes=[("class", "flex items-center gap-2 text-sm text-heading")], attributes=[("class", "flex items-center gap-2 text-sm text-heading")],
children=[ children=[
Component( Component(
@@ -216,8 +215,7 @@ def RangeSlider(
tag_name="div", tag_name="div",
attributes=[("class", "flex items-center gap-2 mb-1")], attributes=[("class", "flex items-center gap-2 mb-1")],
children=[ children=[
Component( Label(
tag_name="label",
attributes=[ attributes=[
("class", _FILTER_LABEL_CLASS), ("class", _FILTER_LABEL_CLASS),
("for", min_input_id), ("for", min_input_id),
@@ -239,8 +237,7 @@ def RangeSlider(
), ),
], ],
), ),
Component( Span(
tag_name="span",
attributes=[ attributes=[
( (
"class", "class",
@@ -280,8 +277,7 @@ def RangeSlider(
), ),
], ],
children=[ children=[
Component( Span(
tag_name="span",
attributes=[ attributes=[
( (
"class", "class",
@@ -291,8 +287,7 @@ def RangeSlider(
], ],
children=[mark_safe(_RANGE_ICON_SVG)], children=[mark_safe(_RANGE_ICON_SVG)],
), ),
Component( Span(
tag_name="span",
attributes=[ attributes=[
( (
"class", "class",
@@ -444,8 +439,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
], ],
children=["Clear"], children=["Clear"],
), ),
Component( Span(
tag_name="span",
attributes=[ attributes=[
("class", "flex gap-2 items-center"), ("class", "flex gap-2 items-center"),
("id", "save-preset-area"), ("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), ("data-preset-list-url", preset_list_url),
], ],
children=[ children=[
Component( Span(
tag_name="span",
attributes=[("class", "text-sm text-body")], attributes=[("class", "text-sm text-body")],
children=["Loading presets..."], children=["Loading presets..."],
), ),
@@ -684,16 +677,14 @@ def _selectable_filter_tag(
"""A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter.""" """A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter."""
checkmark = "\u2717" if excluded else "\u2713" checkmark = "\u2717" if excluded else "\u2713"
css = "sf-tag sf-excluded" if excluded else "sf-tag" css = "sf-tag sf-excluded" if excluded else "sf-tag"
return Component( return Span(
tag_name="span",
attributes=[ attributes=[
("class", css), ("class", css),
("data-value", value), ("data-value", value),
("data-type", "exclude" if excluded else "include"), ("data-type", "exclude" if excluded else "include"),
], ],
children=[ children=[
Component( Span(
tag_name="span",
attributes=[("class", "sf-tag-text")], attributes=[("class", "sf-tag-text")],
children=[f"{checkmark} {label}"], children=[f"{checkmark} {label}"],
), ),
@@ -712,8 +703,7 @@ def _selectable_filter_tag(
def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText: def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText:
"""An active modifier pill ((Any) / (None)) in the SelectableFilter.""" """An active modifier pill ((Any) / (None)) in the SelectableFilter."""
return Component( return Span(
tag_name="span",
attributes=[ attributes=[
("class", "sf-modifier-tag active"), ("class", "sf-modifier-tag active"),
("data-modifier", modifier), ("data-modifier", modifier),
@@ -732,8 +722,7 @@ def _selectable_filter_modifier_option(modifier: str, label: str) -> SafeText:
("data-label", label), ("data-label", label),
], ],
children=[ children=[
Component( Span(
tag_name="span",
attributes=[("class", "sf-option-label")], attributes=[("class", "sf-option-label")],
children=[label], children=[label],
), ),
@@ -751,13 +740,11 @@ def _selectable_filter_option(value: str, label: str) -> SafeText:
("data-label", label), ("data-label", label),
], ],
children=[ children=[
Component( Span(
tag_name="span",
attributes=[("class", "sf-option-label")], attributes=[("class", "sf-option-label")],
children=[label], children=[label],
), ),
Component( Span(
tag_name="span",
attributes=[("class", "sf-option-buttons")], attributes=[("class", "sf-option-buttons")],
children=[ children=[
Component( Component(
+72 -8
View File
@@ -42,8 +42,7 @@ def _popover_html(
""" """
display_content = wrapped_content if wrapped_content else slot display_content = wrapped_content if wrapped_content else slot
span = Component( span = Span(
tag_name="span",
attributes=[ attributes=[
("data-popover-target", id), ("data-popover-target", id),
("class", wrapped_classes), ("class", wrapped_classes),
@@ -77,8 +76,7 @@ def _popover_html(
"<!-- for Tailwind CSS to generate decoration-dotted CSS " "<!-- for Tailwind CSS to generate decoration-dotted CSS "
"from Python component -->" "from Python component -->"
), ),
Component( Span(
tag_name="span",
attributes=[("class", "hidden decoration-dotted")], 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: def CsrfInput(request) -> SafeText:
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.""" """Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
return mark_safe( return mark_safe(
@@ -421,8 +487,7 @@ def SearchField(
tag_name="form", tag_name="form",
attributes=[("class", "max-w-md")], attributes=[("class", "max-w-md")],
children=[ children=[
Component( Label(
tag_name="label",
attributes=[ attributes=[
("for", "search"), ("for", "search"),
("class", "block mb-2.5 text-sm font-medium text-heading sr-only"), ("class", "block mb-2.5 text-sm font-medium text-heading sr-only"),
@@ -491,8 +556,7 @@ def H1(
if badge: if badge:
heading_class = "flex items-center " + heading_class heading_class = "flex items-center " + heading_class
badge_html = Component( badge_html = Span(
tag_name="span",
attributes=[ attributes=[
( (
"class", "class",
+182
View File
@@ -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 ``<input>`` 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)]
+22
View File
@@ -2,6 +2,7 @@ from datetime import date, datetime
from typing import List from typing import List
from django.contrib import messages from django.contrib import messages
from django.db.models import Q
from django.shortcuts import get_object_or_404 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
@@ -50,6 +51,27 @@ class PlayEventOut(Schema):
created_at: datetime 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}) @game_router.patch("/{game_id}/status", response={204: None})
def partial_update_game(request, game_id: int, payload: GameStatusUpdate): def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
game = get_object_or_404(Game, id=game_id) game = get_object_or_404(Game, id=game_id)
+107 -26
View File
@@ -1,8 +1,12 @@
from django import forms from django import forms
from django.db import transaction 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 ( from games.models import (
Device, Device,
Game, Game,
@@ -22,18 +26,90 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class MultipleGameChoiceField(forms.ModelMultipleChoiceField): class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str: 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): class SingleGameChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str: 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): class SessionForm(forms.ModelForm):
game = SingleGameChoiceField( 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"),
) )
duration_manual = forms.DurationField( duration_manual = forms.DurationField(
@@ -83,38 +159,43 @@ class SessionForm(forms.ModelForm):
return session return session
class IncludePlatformSelect(forms.SelectMultiple): def related_purchase_queryset():
def create_option(self, name, value, *args, **kwargs): """GAME purchases annotated with their first game's name.
option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"): Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
option["attrs"]["data-platform"] = platform_id option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
return option 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): class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# 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") self.fields["platform"].queryset = Platform.objects.order_by("name")
games = MultipleGameChoiceField( games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), 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")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField( related_purchase = RelatedPurchaseChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME), queryset=related_purchase_queryset(),
required=False, required=False,
) )
@@ -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'),
),
]
+5 -1
View File
@@ -65,6 +65,10 @@ class Game(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
@property
def search_label(self) -> str:
return f"{self.sort_name} ({self.platform}, {self.year_released})"
def finished(self): def finished(self):
return ( return (
self.status == self.Status.FINISHED self.status == self.Status.FINISHED
@@ -290,7 +294,7 @@ class Session(models.Model):
default=None, default=None,
related_name="sessions", 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") timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
duration_manual = models.DurationField( duration_manual = models.DurationField(
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration" blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
+204
View File
@@ -293,27 +293,85 @@
--leading-5: 20px; --leading-5: 20px;
--radius-base: 12px; --radius-base: 12px;
--color-body: var(--color-gray-600); --color-body: var(--color-gray-600);
--color-body-subtle: var(--color-gray-500);
--color-heading: var(--color-gray-900); --color-heading: var(--color-gray-900);
--color-fg-brand-subtle: var(--color-blue-200);
--color-fg-brand: var(--color-blue-700); --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-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-soft: var(--color-white);
--color-neutral-primary: var(--color-white); --color-neutral-primary: var(--color-white);
--color-neutral-primary-medium: 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-soft: var(--color-gray-50);
--color-neutral-secondary: var(--color-gray-50); --color-neutral-secondary: var(--color-gray-50);
--color-neutral-secondary-medium: var(--color-gray-50); --color-neutral-secondary-medium: var(--color-gray-50);
--color-neutral-secondary-strong: 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: var(--color-gray-100);
--color-neutral-tertiary-medium: var(--color-gray-100); --color-neutral-tertiary-medium: var(--color-gray-100);
--color-neutral-quaternary: var(--color-gray-200); --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-soft: var(--color-blue-100);
--color-brand: var(--color-blue-700); --color-brand: var(--color-blue-700);
--color-brand-medium: var(--color-blue-200); --color-brand-medium: var(--color-blue-200);
--color-brand-strong: var(--color-blue-800); --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: 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: 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: var(--color-gray-200);
--color-default-medium: 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-dark-backdrop: var(--color-gray-950);
--color-accent: #7c3aed; --color-accent: #7c3aed;
} }
@@ -820,12 +878,18 @@
.start-0 { .start-0 {
inset-inline-start: calc(var(--spacing) * 0); inset-inline-start: calc(var(--spacing) * 0);
} }
.end-1 {
inset-inline-end: calc(var(--spacing) * 1);
}
.end-1\.5 { .end-1\.5 {
inset-inline-end: calc(var(--spacing) * 1.5); inset-inline-end: calc(var(--spacing) * 1.5);
} }
.top-0 { .top-0 {
top: calc(var(--spacing) * 0); top: calc(var(--spacing) * 0);
} }
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 { .top-1\/2 {
top: calc(1 / 2 * 100%); top: calc(1 / 2 * 100%);
} }
@@ -847,6 +911,9 @@
.bottom-0 { .bottom-0 {
bottom: calc(var(--spacing) * 0); bottom: calc(var(--spacing) * 0);
} }
.bottom-1 {
bottom: calc(var(--spacing) * 1);
}
.bottom-1\.5 { .bottom-1\.5 {
bottom: calc(var(--spacing) * 1.5); bottom: calc(var(--spacing) * 1.5);
} }
@@ -1276,6 +1343,9 @@
margin-left: -10px !important; margin-left: -10px !important;
} }
} }
.ml-1 {
margin-left: calc(var(--spacing) * 1);
}
.ml-4 { .ml-4 {
margin-left: calc(var(--spacing) * 4); margin-left: calc(var(--spacing) * 4);
} }
@@ -1470,9 +1540,15 @@
.h-full { .h-full {
height: 100%; height: 100%;
} }
.max-h-40 {
max-height: calc(var(--spacing) * 40);
}
.max-h-full { .max-h-full {
max-height: 100%; max-height: 100%;
} }
.min-h-\[28px\] {
min-height: 28px;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
@@ -1541,9 +1617,15 @@
text-align: center; text-align: center;
} }
} }
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-1\/2 { .w-1\/2 {
width: calc(1 / 2 * 100%); width: calc(1 / 2 * 100%);
} }
.w-2 {
width: calc(var(--spacing) * 2);
}
.w-2\.5 { .w-2\.5 {
width: calc(var(--spacing) * 2.5); width: calc(var(--spacing) * 2.5);
} }
@@ -1652,6 +1734,9 @@
.shrink-0 { .shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
.border-collapse {
border-collapse: collapse;
}
.-translate-x-full { .-translate-x-full {
--tw-translate-x: -100%; --tw-translate-x: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1668,6 +1753,10 @@
--tw-translate-x: 100%; --tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y); 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 { .-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1); --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y); translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1710,6 +1799,9 @@
.list-disc { .list-disc {
list-style-type: disc; list-style-type: disc;
} }
.appearance-none {
appearance: none;
}
.grid-cols-1 { .grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
} }
@@ -2053,6 +2145,9 @@
.bg-amber-50 { .bg-amber-50 {
background-color: var(--color-amber-50); background-color: var(--color-amber-50);
} }
.bg-black {
background-color: var(--color-black);
}
.bg-black\/70 { .bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent); background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -2071,6 +2166,15 @@
.bg-brand { .bg-brand {
background-color: var(--color-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 { .bg-dark-backdrop\/70 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent); background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -2089,12 +2193,18 @@
.bg-gray-500 { .bg-gray-500 {
background-color: var(--color-gray-500); background-color: var(--color-gray-500);
} }
.bg-gray-800 {
background-color: var(--color-gray-800);
}
.bg-gray-800\/20 { .bg-gray-800\/20 {
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent); background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent); 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 { .bg-gray-900\/50 {
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent); background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) { @supports (color: color-mix(in lab, red, red)) {
@@ -2209,6 +2319,18 @@
fill: white !important; 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 { .apexcharts-ycrosshairs {
stroke: var(--color-default) !important; stroke: var(--color-default) !important;
.dark & { .dark & {
@@ -2267,6 +2389,9 @@
.px-6 { .px-6 {
padding-inline: calc(var(--spacing) * 6); padding-inline: calc(var(--spacing) * 6);
} }
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 { .py-0\.5 {
padding-block: calc(var(--spacing) * 0.5); padding-block: calc(var(--spacing) * 0.5);
} }
@@ -2328,6 +2453,9 @@
color: heading !important; color: heading !important;
} }
} }
.pb-1 {
padding-bottom: calc(var(--spacing) * 1);
}
.pb-16 { .pb-16 {
padding-bottom: calc(var(--spacing) * 16); padding-bottom: calc(var(--spacing) * 16);
} }
@@ -2494,6 +2622,9 @@
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
} }
.text-wrap {
text-wrap: wrap;
}
.whitespace-nowrap { .whitespace-nowrap {
white-space: nowrap; white-space: nowrap;
} }
@@ -2620,6 +2751,9 @@
.italic { .italic {
font-style: italic; font-style: italic;
} }
.no-underline {
text-decoration-line: none;
}
.no-underline\! { .no-underline\! {
text-decoration-line: none !important; 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,); -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: 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 {
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-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)); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -2847,6 +2985,11 @@
background-color: var(--color-gray-50); background-color: var(--color-gray-50);
} }
} }
.empty\:hidden {
&:empty {
display: none;
}
}
.hover\:scale-110 { .hover\:scale-110 {
&:hover { &:hover {
@media (hover: 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\:bg-gray-50 {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -3068,6 +3221,12 @@
color: var(--color-blue-700); 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\:ring-2 {
&:focus { &:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); --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\]\:rounded-s-lg {
&:first-of-type button { &:first-of-type button {
border-start-start-radius: var(--radius-lg); border-start-start-radius: var(--radius-lg);
+31 -17
View File
@@ -1,20 +1,35 @@
import { import { getEl, disableElementsWhenTrue } from "./utils.js";
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenValueNotEqual,
} from "./utils.js";
let syncData = [ const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
{
source: "#id_games",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form"); // The games field is now a SearchSelect widget (a <div>, not a <select>), 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() { function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [ disableElementsWhenTrue("#id_type", "game", [
@@ -27,5 +42,4 @@ document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers); document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => { getEl("#id_type").addEventListener("change", () => {
setupElementHandlers(); setupElementHandlers();
} });
);
+277
View File
@@ -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 <input> 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);
})();
+8 -4
View File
@@ -10,6 +10,7 @@ from django.db.models import (
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localtime
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from common.layout import render_page 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]: def model_counts(request: HttpRequest) -> dict[str, bool]:
now = timezone_now() 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( today_played = Session.objects.filter(
timestamp_start__day=this_day, timestamp_start__gte=start_of_today,
timestamp_start__month=this_month, timestamp_start__lt=start_of_tomorrow,
timestamp_start__year=this_year,
).aggregate(time=Sum(F("duration_total")))["time"] ).aggregate(time=Sum(F("duration_total")))["time"]
last_7_played = Session.objects.filter( last_7_played = Session.objects.filter(
timestamp_start__gte=(now - timedelta(days=7)) timestamp_start__gte=(now - timedelta(days=7))
+9 -3
View File
@@ -203,7 +203,9 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request, request,
AddForm(form, request=request, additional_row=_purchase_additional_row()), AddForm(form, request=request, additional_row=_purchase_additional_row()),
title="Add New Purchase", 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, request,
AddForm(form, request=request, additional_row=_purchase_additional_row()), AddForm(form, request=request, additional_row=_purchase_additional_row()),
title="Edit Purchase", 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: def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
games: list[str] = request.GET.getlist("games") games: list[str] = request.GET.getlist("games")
if games: if games:
from games.forms import related_purchase_queryset
form = PurchaseForm() 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" "games__sort_name"
) )
+6 -2
View File
@@ -270,7 +270,9 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session", 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, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session", title="Edit Session",
scripts=ModuleScript("add_session.js"), scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("add_session.js")
),
) )
+177
View File
@@ -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("<b>x</b>")
self.assertIn("&lt;b&gt;", html)
self.assertNotIn("<b>x</b>", 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('<input type="hidden" name="games" value="7">', 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()