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,
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",
+5 -8
View File
@@ -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"),
+15 -28
View File
@@ -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(
+72 -8
View File
@@ -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(
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
"from Python component -->"
),
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",
+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 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)
+107 -26
View File
@@ -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`` ``<select>`` 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 <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")
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,
)
@@ -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):
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"
+204
View File
@@ -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);
+31 -17
View File
@@ -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 <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() {
disableElementsWhenTrue("#id_type", "game", [
@@ -27,5 +42,4 @@ document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => {
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.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))
+9 -3
View File
@@ -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"
)
+6 -2
View File
@@ -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")
),
)
+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()