Compare commits

...

2 Commits

Author SHA1 Message Date
lukas afc16aabbb Implement search select component
Django CI/CD / test (push) Successful in 40s
Django CI/CD / build-and-push (push) Successful in 1m24s
2026-06-06 22:52:26 +02:00
lukas 3ce3356064 Refine filters 2026-06-06 19:37:14 +02:00
22 changed files with 2062 additions and 2352 deletions
-1950
View File
File diff suppressed because it is too large Load Diff
+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"),
File diff suppressed because it is too large Load Diff
+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)]
+46
View File
@@ -231,3 +231,49 @@ textarea:disabled {
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4; @apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
} }
} }
/* SelectableFilter widget styling */
.sf-container {
@apply border border-default-medium rounded-base bg-neutral-secondary-medium;
}
.sf-selected {
@apply flex flex-wrap gap-1 p-2 min-h-[2rem];
}
.sf-tag {
@apply inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded bg-brand/15 text-heading;
}
.sf-tag.sf-excluded {
@apply bg-red-500/15 text-red-600 line-through decoration-red-400;
}
.sf-remove {
@apply ml-1 text-body hover:text-heading font-bold cursor-pointer;
}
.sf-modifier-tag {
@apply inline-flex items-center px-2 py-0.5 text-sm rounded bg-amber-500/15 text-amber-600 cursor-pointer;
}
.sf-search {
@apply block w-full border-0 border-t border-default-medium bg-transparent text-sm text-heading p-2;
&:focus {
@apply ring-0 outline-hidden;
}
}
.sf-options {
@apply max-h-40 overflow-y-auto p-1 text-body;
}
.sf-option {
@apply flex items-center justify-between px-2 py-1 rounded text-sm hover:bg-neutral-secondary-strong cursor-pointer;
}
.sf-option-label {
@apply truncate;
}
.sf-option-buttons {
@apply flex gap-1 ml-2 shrink-0;
}
.sf-btn-include,
.sf-btn-exclude {
@apply w-5 h-5 flex items-center justify-center text-xs font-bold rounded border border-default-medium hover:bg-brand hover:text-white hover:border-brand;
}
.sf-modifier-option {
@apply px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer;
}
+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"
+312
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);
} }
@@ -1547,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);
} }
@@ -1655,6 +1731,12 @@
.flex-shrink-0 { .flex-shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
.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);
@@ -1671,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);
@@ -2059,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)) {
@@ -2077,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)) {
@@ -2095,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)) {
@@ -2125,6 +2229,9 @@
.bg-neutral-primary-soft { .bg-neutral-primary-soft {
background-color: var(--color-neutral-primary-soft); background-color: var(--color-neutral-primary-soft);
} }
.bg-neutral-quaternary {
background-color: var(--color-neutral-quaternary);
}
.bg-neutral-secondary-medium { .bg-neutral-secondary-medium {
background-color: var(--color-neutral-secondary-medium); background-color: var(--color-neutral-secondary-medium);
} }
@@ -2212,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 & {
@@ -2270,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);
} }
@@ -2500,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;
} }
@@ -2626,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;
} }
@@ -2689,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));
@@ -2853,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) {
@@ -2898,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) {
@@ -4358,6 +4505,171 @@ form input:disabled, select:disabled, textarea:disabled {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
} }
.sf-container {
border-radius: var(--radius-base);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
background-color: var(--color-neutral-secondary-medium);
}
.sf-selected {
display: flex;
min-height: 2rem;
flex-wrap: wrap;
gap: calc(var(--spacing) * 1);
padding: calc(var(--spacing) * 2);
}
.sf-tag {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 1);
border-radius: var(--radius);
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);
}
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 0.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
}
.sf-tag.sf-excluded {
background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent);
}
color: var(--color-red-600);
text-decoration-line: line-through;
text-decoration-color: var(--color-red-400);
}
.sf-remove {
margin-left: calc(var(--spacing) * 1);
cursor: pointer;
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-body);
&:hover {
@media (hover: hover) {
color: var(--color-heading);
}
}
}
.sf-modifier-tag {
display: inline-flex;
cursor: pointer;
align-items: center;
border-radius: var(--radius);
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 0.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-amber-600);
}
.sf-search {
display: block;
width: 100%;
border-style: var(--tw-border-style);
border-width: 0px;
border-top-style: var(--tw-border-style);
border-top-width: 1px;
border-color: var(--color-default-medium);
background-color: transparent;
padding: calc(var(--spacing) * 2);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-heading);
&: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);
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
}
.sf-options {
max-height: calc(var(--spacing) * 40);
overflow-y: auto;
padding: calc(var(--spacing) * 1);
color: var(--color-body);
}
.sf-option {
display: flex;
cursor: pointer;
align-items: center;
justify-content: space-between;
border-radius: var(--radius);
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
.sf-option-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-option-buttons {
margin-left: calc(var(--spacing) * 2);
display: flex;
flex-shrink: 0;
gap: calc(var(--spacing) * 1);
}
.sf-btn-include, .sf-btn-exclude {
display: flex;
height: calc(var(--spacing) * 5);
width: calc(var(--spacing) * 5);
align-items: center;
justify-content: center;
border-radius: var(--radius);
border-style: var(--tw-border-style);
border-width: 1px;
border-color: var(--color-default-medium);
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
&:hover {
@media (hover: hover) {
border-color: var(--color-brand);
}
}
&:hover {
@media (hover: hover) {
background-color: var(--color-brand);
}
}
&:hover {
@media (hover: hover) {
color: var(--color-white);
}
}
}
.sf-modifier-option {
cursor: pointer;
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
color: var(--color-body);
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-strong);
}
}
}
@layer base { @layer base {
input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select { input:where([type='text']),input:where(:not([type])),input:where([type='email']),input:where([type='url']),input:where([type='password']),input:where([type='number']),input:where([type='date']),input:where([type='datetime-local']),input:where([type='month']),input:where([type='search']),input:where([type='tel']),input:where([type='time']),input:where([type='week']),select:where([multiple]),textarea,select {
appearance: none; appearance: none;
+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();
} });
);
-12
View File
@@ -46,9 +46,6 @@
* Returns a plain object ready for JSON.stringify. * Returns a plain object ready for JSON.stringify.
*/ */
function buildFilterJSON(form) { function buildFilterJSON(form) {
// Read all SelectableFilter widgets first
readSelectableFilters(form);
var filter = {}; var filter = {};
var yearMin = numberValue(form, "filter-year-min"); var yearMin = numberValue(form, "filter-year-min");
var yearMax = numberValue(form, "filter-year-max"); var yearMax = numberValue(form, "filter-year-max");
@@ -132,14 +129,7 @@
} }
if (yearMin !== "" && yearMax !== "") { if (yearMin !== "" && yearMax !== "") {
// Skip if both equal the data range extremes (no real filter)
var yrMinNum = parseInt(yearMin, 10);
var yrMaxNum = parseInt(yearMax, 10);
if (yrMinNum === yrMaxNum) {
// don't add filter
} else {
filter.year_released = criterion(yearMin, yearMax, "BETWEEN"); filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
}
} else if (yearMin !== "") { } else if (yearMin !== "") {
filter.year_released = criterion(yearMin, null, "GREATER_THAN"); filter.year_released = criterion(yearMin, null, "GREATER_THAN");
} else if (yearMax !== "") { } else if (yearMax !== "") {
@@ -371,8 +361,6 @@
} }
}); });
} }
injectSearchInputs();
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs(); injectSearchInputs();
loadPresets(); loadPresets();
+142 -42
View File
@@ -1,5 +1,12 @@
/** /**
* Dual-handle range slider — pure JS with draggable handles. * Range slider — custom draggable handles (no native <input type=range>).
*
* Supports two modes on each slider, toggled via the .range-mode-toggle button:
* range (default) — two handles, min ≤ max constraint
* point — single handle, sets both number inputs to the same value
*
* Handles track-fill positioning and sync between handles and the connected
* number inputs (linked via data-target attributes).
*/ */
(function () { (function () {
"use strict"; "use strict";
@@ -10,63 +17,109 @@
if (slider._rsInit) return; if (slider._rsInit) return;
slider._rsInit = true; slider._rsInit = true;
var mode = slider.getAttribute("data-mode") || "range";
var trackFill = slider.querySelector(".range-track-fill");
var minHandle = slider.querySelector(".range-handle-min"); var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max"); var maxHandle = slider.querySelector(".range-handle-max");
var track = slider.querySelector(".range-track-fill");
if (!minHandle || !maxHandle) return; if (!minHandle || !maxHandle) return;
var minTarget = document.getElementById(minHandle.getAttribute("data-target")); var minTarget = document.getElementById(
var maxTarget = document.getElementById(maxHandle.getAttribute("data-target")); minHandle.getAttribute("data-target")
var dMin = parseInt(slider.getAttribute("data-min"), 10); );
var dMax = parseInt(slider.getAttribute("data-max"), 10); var maxTarget = document.getElementById(
maxHandle.getAttribute("data-target")
);
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1; var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; } // ── Helpers ──
function percentToValue(p) {
var raw = dMin + (p / 100) * (dMax - dMin); function valueToPercent(value) {
return ((value - dataMin) / (dataMax - dataMin)) * 100;
}
function percentToValue(percent) {
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
return Math.round(raw / step) * step; return Math.round(raw / step) * step;
} }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function clamp(value, lo, hi) {
return Math.max(lo, Math.min(hi, value));
}
function getTargetVal(el) { return parseInt(el ? el.value : minTarget.value, 10) || dMin; } function getTargetValue(target) {
function setTargetVal(el, v) { if (el) el.value = v; } return parseInt(target ? target.value : 0, 10) || dataMin;
}
function setTargetValue(target, value) {
if (target) target.value = value;
}
function update() { // ── Track fill positioning ──
var minV = getTargetVal(minTarget);
var maxV = getTargetVal(maxTarget); function updateTrackFill() {
minV = clamp(minV, dMin, dMax); if (!trackFill) return;
maxV = clamp(maxV, dMin, dMax); var minValue = getTargetValue(minTarget);
if (minV > maxV) minV = maxV; var maxValue = getTargetValue(maxTarget);
if (maxV < minV) maxV = minV; if (mode === "point") {
setTargetVal(minTarget, minV); trackFill.style.left = "0%";
setTargetVal(maxTarget, maxV); trackFill.style.width = valueToPercent(maxValue) + "%";
var minP = valueToPercent(minV); } else {
var maxP = valueToPercent(maxV); var leftPct = valueToPercent(minValue);
minHandle.style.left = minP + "%"; var widthPct = valueToPercent(maxValue) - leftPct;
maxHandle.style.left = maxP + "%"; trackFill.style.left = leftPct + "%";
if (track) { trackFill.style.width = widthPct + "%";
track.style.left = minP + "%";
track.style.width = (maxP - minP) + "%";
} }
} }
function updateHandles() {
minHandle.style.left = valueToPercent(getTargetValue(minTarget)) + "%";
maxHandle.style.left = valueToPercent(getTargetValue(maxTarget)) + "%";
updateTrackFill();
}
// ── Dragging ──
function makeDraggable(handle, isMin) { function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) { handle.addEventListener("mousedown", function (e) {
e.preventDefault(); e.preventDefault();
var rect = slider.getBoundingClientRect(); var rect = slider.getBoundingClientRect();
function onMove(ev) { function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100; var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var v = percentToValue(clamp(pct, 0, 100)); var value = percentToValue(clamp(pct, 0, 100));
if (isMin) {
minTarget.value = clamp(v, dMin, getTargetVal(maxTarget)); if (mode === "point") {
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else if (isMin) {
setTargetValue(
minTarget,
clamp(value, dataMin, getTargetValue(maxTarget))
);
if (minTarget)
minTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} else { } else {
maxTarget.value = clamp(v, getTargetVal(minTarget), dMax); setTargetValue(
maxTarget,
clamp(value, getTargetValue(minTarget), dataMax)
);
if (maxTarget)
maxTarget.dispatchEvent(
new Event("input", { bubbles: true })
);
} }
update(); updateHandles();
// Trigger input event on the target so any listeners fire
var tgt = isMin ? minTarget : maxTarget;
if (tgt) tgt.dispatchEvent(new Event("input", { bubbles: true }));
} }
function onUp() { function onUp() {
document.removeEventListener("mousemove", onMove); document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp); document.removeEventListener("mouseup", onUp);
@@ -80,17 +133,64 @@
makeDraggable(minHandle, true); makeDraggable(minHandle, true);
makeDraggable(maxHandle, false); makeDraggable(maxHandle, false);
// Sync from inputs to slider // ── Sync from number inputs back to handles ──
function fromInputs() { update(); }
if (minTarget) minTarget.addEventListener("input", fromInputs);
if (maxTarget) maxTarget.addEventListener("input", fromInputs);
update(); function syncFromInputs() {
if (mode === "point") {
var value =
getTargetValue(minTarget) || getTargetValue(maxTarget);
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
}
updateHandles();
}
if (minTarget)
minTarget.addEventListener("input", syncFromInputs);
if (maxTarget)
maxTarget.addEventListener("input", syncFromInputs);
// ── Mode toggle ──
var block = slider.closest(".range-slider-block");
var toggleButton =
block && block.querySelector(".range-mode-toggle");
if (toggleButton) {
toggleButton.addEventListener("click", function () {
var newMode = mode === "range" ? "point" : "range";
slider.setAttribute("data-mode", newMode);
// Swap toggle icons
var iconRange = toggleButton.querySelector(
".range-mode-icon-range"
);
var iconPoint = toggleButton.querySelector(
".range-mode-icon-point"
);
if (iconRange) iconRange.classList.toggle("hidden");
if (iconPoint) iconPoint.classList.toggle("hidden");
var dashSpan = block && block.querySelector(".range-dash");
if (newMode === "point") {
minHandle.style.display = "none";
setTargetValue(minTarget, getTargetValue(maxTarget));
if (minTarget) minTarget.classList.add("hidden");
if (dashSpan) dashSpan.classList.add("hidden");
} else {
minHandle.style.display = "";
if (minTarget) minTarget.classList.remove("hidden");
if (dashSpan) dashSpan.classList.remove("hidden");
}
mode = newMode;
updateHandles();
});
}
// ── Initial position ──
updateHandles();
}); });
} }
document.addEventListener("DOMContentLoaded", initAll); document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll); document.addEventListener("htmx:afterSwap", initAll);
// Expose for manual re-init (filter bar toggle)
window.initRangeSliders = initAll; window.initRangeSliders = initAll;
})(); })();
+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")
),
) )
+26 -4
View File
@@ -1,10 +1,11 @@
"""Characterization tests locking the rendered output of the three filter bars. """Characterization tests locking the rendered output of the three filter bars.
The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) is the The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) is the
target of an upcoming dedup + module split. These tests pin the structural target of a dedup + module split + RangeSlider component extraction. These tests
contract — form/input ids, the hidden ``filter`` field, preset wiring, the pin the structural contract — form/input ids, the hidden ``filter`` field,
filter_json round-trip, and no double-escaping — so that refactor stays preset wiring, the filter_json round-trip, no double-escaping, and the
behaviour-preserving. The renderers were previously untested. Flowbite-styled native range slider unification — so that refactor stays
behaviour-preserving.
""" """
import json import json
@@ -41,6 +42,24 @@ class FilterBarRenderingTest(TestCase):
self.assertIn(save_url, html) # preset save URL wired in self.assertIn(save_url, html) # preset save URL wired in
self.assertNoEscapedTags(html) self.assertNoEscapedTags(html)
def _assert_range_slider(self, html):
"""Every filter bar must use the RangeSlider component with custom
draggable <div> handles, a track fill, and mode-toggle button."""
self.assertIn("range-slider-block", html)
self.assertIn('data-mode="range"', html)
self.assertIn("range-mode-toggle", html)
self.assertIn("range-mode-icon-range", html)
self.assertIn("range-mode-icon-point", html)
self.assertIn("range-track-fill", html)
self.assertIn("range-handle-min", html)
self.assertIn("range-handle-max", html)
# No native range inputs
self.assertNotIn(
'<input type="range"',
html,
"native <input type=range> found — should use custom div handles",
)
def test_game_filter_bar(self): def test_game_filter_bar(self):
html = str( html = str(
FilterBar( FilterBar(
@@ -50,6 +69,7 @@ class FilterBarRenderingTest(TestCase):
) )
) )
self._assert_shell(html, "/presets/games/list", "/presets/games/save") self._assert_shell(html, "/presets/games/list", "/presets/games/save")
self._assert_range_slider(html)
def test_session_filter_bar(self): def test_session_filter_bar(self):
html = str( html = str(
@@ -60,6 +80,7 @@ class FilterBarRenderingTest(TestCase):
) )
) )
self._assert_shell(html, "/presets/sessions/list", "/presets/sessions/save") self._assert_shell(html, "/presets/sessions/list", "/presets/sessions/save")
self._assert_range_slider(html)
def test_purchase_filter_bar(self): def test_purchase_filter_bar(self):
html = str( html = str(
@@ -70,6 +91,7 @@ class FilterBarRenderingTest(TestCase):
) )
) )
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save") self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
self._assert_range_slider(html)
def test_game_filter_bar_roundtrips_selected_status(self): def test_game_filter_bar_roundtrips_selected_status(self):
"""A status in filter_json renders as a selected tag in the widget.""" """A status in filter_json renders as a selected tag in the widget."""
+68
View File
@@ -0,0 +1,68 @@
"""Unit tests for filter JSON parsing helpers."""
from django.test import SimpleTestCase
from common.components.filters import _parse_bool, _parse_range
class ParseRangeTest(SimpleTestCase):
def test_empty_dict(self):
self.assertEqual(_parse_range({}, "field"), ("", ""))
def test_missing_key(self):
self.assertEqual(_parse_range({"other": 1}, "field"), ("", ""))
def test_null_value(self):
self.assertEqual(_parse_range({"field": None}, "field"), ("", ""))
def test_non_dict_value(self):
"""A non-dict field value is coerced to ("", "")."""
self.assertEqual(_parse_range({"field": "not_a_dict"}, "field"), ("", ""))
def test_value_only(self):
self.assertEqual(_parse_range({"field": {"value": "10"}}, "field"), ("10", ""))
def test_value_and_value2(self):
self.assertEqual(
_parse_range({"field": {"value": "10", "value2": "20"}}, "field"),
("10", "20"),
)
def test_empty_strings(self):
self.assertEqual(
_parse_range({"field": {"value": "", "value2": ""}}, "field"), ("", "")
)
def test_integer_values_become_strings(self):
self.assertEqual(
_parse_range({"field": {"value": 5, "value2": 15}}, "field"),
("5", "15"),
)
class ParseBoolTest(SimpleTestCase):
def test_empty_dict(self):
self.assertFalse(_parse_bool({}, "field"))
def test_missing_key(self):
self.assertFalse(_parse_bool({"other": 1}, "field"))
def test_null_value(self):
self.assertFalse(_parse_bool({"field": None}, "field"))
def test_non_dict_value(self):
"""A non-dict field value is coerced to False."""
self.assertFalse(_parse_bool({"field": "not_a_dict"}, "field"))
def test_false_value(self):
self.assertFalse(_parse_bool({"field": {"value": False}}, "field"))
def test_true_value(self):
self.assertTrue(_parse_bool({"field": {"value": True}}, "field"))
def test_truthy_string(self):
"""Non-empty strings are truthy — bool("yes") is True."""
self.assertTrue(_parse_bool({"field": {"value": "yes"}}, "field"))
def test_missing_value_in_field(self):
self.assertFalse(_parse_bool({"field": {}}, "field"))
+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()