Implement search select component
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from django.utils.html import escape
|
|||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components.core import Component
|
from common.components.core import Component
|
||||||
|
from common.components.primitives import Label, Span
|
||||||
|
|
||||||
|
|
||||||
class FilterChoice(NamedTuple):
|
class FilterChoice(NamedTuple):
|
||||||
@@ -115,8 +116,7 @@ def _filter_field(label: str, widget) -> SafeText:
|
|||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("class", "flex flex-col gap-1")],
|
attributes=[("class", "flex flex-col gap-1")],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Label(
|
||||||
tag_name="label",
|
|
||||||
attributes=[("class", _FILTER_LABEL_CLASS)],
|
attributes=[("class", _FILTER_LABEL_CLASS)],
|
||||||
children=[label],
|
children=[label],
|
||||||
),
|
),
|
||||||
@@ -143,8 +143,7 @@ def _filter_number(label, name, value="", placeholder="") -> SafeText:
|
|||||||
|
|
||||||
|
|
||||||
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
||||||
return Component(
|
return Label(
|
||||||
tag_name="label",
|
|
||||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading")],
|
attributes=[("class", "flex items-center gap-2 text-sm text-heading")],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Component(
|
||||||
@@ -216,8 +215,7 @@ def RangeSlider(
|
|||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Label(
|
||||||
tag_name="label",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", _FILTER_LABEL_CLASS),
|
("class", _FILTER_LABEL_CLASS),
|
||||||
("for", min_input_id),
|
("for", min_input_id),
|
||||||
@@ -239,8 +237,7 @@ def RangeSlider(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -280,8 +277,7 @@ def RangeSlider(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -291,8 +287,7 @@ def RangeSlider(
|
|||||||
],
|
],
|
||||||
children=[mark_safe(_RANGE_ICON_SVG)],
|
children=[mark_safe(_RANGE_ICON_SVG)],
|
||||||
),
|
),
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -444,8 +439,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
|||||||
],
|
],
|
||||||
children=["Clear"],
|
children=["Clear"],
|
||||||
),
|
),
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", "flex gap-2 items-center"),
|
("class", "flex gap-2 items-center"),
|
||||||
("id", "save-preset-area"),
|
("id", "save-preset-area"),
|
||||||
@@ -510,8 +504,7 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
|||||||
("data-preset-list-url", preset_list_url),
|
("data-preset-list-url", preset_list_url),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", "text-sm text-body")],
|
attributes=[("class", "text-sm text-body")],
|
||||||
children=["Loading presets..."],
|
children=["Loading presets..."],
|
||||||
),
|
),
|
||||||
@@ -684,16 +677,14 @@ def _selectable_filter_tag(
|
|||||||
"""A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter."""
|
"""A selected (\u2713) or excluded (\u2717) value pill in the SelectableFilter."""
|
||||||
checkmark = "\u2717" if excluded else "\u2713"
|
checkmark = "\u2717" if excluded else "\u2713"
|
||||||
css = "sf-tag sf-excluded" if excluded else "sf-tag"
|
css = "sf-tag sf-excluded" if excluded else "sf-tag"
|
||||||
return Component(
|
return Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", css),
|
("class", css),
|
||||||
("data-value", value),
|
("data-value", value),
|
||||||
("data-type", "exclude" if excluded else "include"),
|
("data-type", "exclude" if excluded else "include"),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", "sf-tag-text")],
|
attributes=[("class", "sf-tag-text")],
|
||||||
children=[f"{checkmark} {label}"],
|
children=[f"{checkmark} {label}"],
|
||||||
),
|
),
|
||||||
@@ -712,8 +703,7 @@ def _selectable_filter_tag(
|
|||||||
|
|
||||||
def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText:
|
def _selectable_filter_modifier_tag(modifier: str, label: str) -> SafeText:
|
||||||
"""An active modifier pill ((Any) / (None)) in the SelectableFilter."""
|
"""An active modifier pill ((Any) / (None)) in the SelectableFilter."""
|
||||||
return Component(
|
return Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", "sf-modifier-tag active"),
|
("class", "sf-modifier-tag active"),
|
||||||
("data-modifier", modifier),
|
("data-modifier", modifier),
|
||||||
@@ -732,8 +722,7 @@ def _selectable_filter_modifier_option(modifier: str, label: str) -> SafeText:
|
|||||||
("data-label", label),
|
("data-label", label),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", "sf-option-label")],
|
attributes=[("class", "sf-option-label")],
|
||||||
children=[label],
|
children=[label],
|
||||||
),
|
),
|
||||||
@@ -751,13 +740,11 @@ def _selectable_filter_option(value: str, label: str) -> SafeText:
|
|||||||
("data-label", label),
|
("data-label", label),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", "sf-option-label")],
|
attributes=[("class", "sf-option-label")],
|
||||||
children=[label],
|
children=[label],
|
||||||
),
|
),
|
||||||
Component(
|
Span(
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", "sf-option-buttons")],
|
attributes=[("class", "sf-option-buttons")],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
Component(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -293,27 +293,85 @@
|
|||||||
--leading-5: 20px;
|
--leading-5: 20px;
|
||||||
--radius-base: 12px;
|
--radius-base: 12px;
|
||||||
--color-body: var(--color-gray-600);
|
--color-body: var(--color-gray-600);
|
||||||
|
--color-body-subtle: var(--color-gray-500);
|
||||||
--color-heading: var(--color-gray-900);
|
--color-heading: var(--color-gray-900);
|
||||||
|
--color-fg-brand-subtle: var(--color-blue-200);
|
||||||
--color-fg-brand: var(--color-blue-700);
|
--color-fg-brand: var(--color-blue-700);
|
||||||
|
--color-fg-brand-strong: var(--color-blue-900);
|
||||||
|
--color-fg-success: var(--color-emerald-700);
|
||||||
|
--color-fg-success-strong: var(--color-emerald-900);
|
||||||
|
--color-fg-danger: var(--color-rose-700);
|
||||||
|
--color-fg-danger-strong: var(--color-rose-900);
|
||||||
|
--color-fg-warning-subtle: var(--color-orange-600);
|
||||||
|
--color-fg-warning: var(--color-orange-900);
|
||||||
|
--color-fg-yellow: var(--color-yellow-400);
|
||||||
--color-fg-disabled: var(--color-gray-400);
|
--color-fg-disabled: var(--color-gray-400);
|
||||||
|
--color-fg-purple: var(--color-purple-600);
|
||||||
|
--color-fg-cyan: var(--color-cyan-600);
|
||||||
|
--color-fg-indigo: var(--color-indigo-600);
|
||||||
|
--color-fg-pink: var(--color-pink-600);
|
||||||
|
--color-fg-lime: var(--color-lime-600);
|
||||||
--color-neutral-primary-soft: var(--color-white);
|
--color-neutral-primary-soft: var(--color-white);
|
||||||
--color-neutral-primary: var(--color-white);
|
--color-neutral-primary: var(--color-white);
|
||||||
--color-neutral-primary-medium: var(--color-white);
|
--color-neutral-primary-medium: var(--color-white);
|
||||||
|
--color-neutral-primary-strong: var(--color-white);
|
||||||
--color-neutral-secondary-soft: var(--color-gray-50);
|
--color-neutral-secondary-soft: var(--color-gray-50);
|
||||||
--color-neutral-secondary: var(--color-gray-50);
|
--color-neutral-secondary: var(--color-gray-50);
|
||||||
--color-neutral-secondary-medium: var(--color-gray-50);
|
--color-neutral-secondary-medium: var(--color-gray-50);
|
||||||
--color-neutral-secondary-strong: var(--color-gray-50);
|
--color-neutral-secondary-strong: var(--color-gray-50);
|
||||||
|
--color-neutral-secondary-strongest: var(--color-gray-50);
|
||||||
|
--color-neutral-tertiary-soft: var(--color-gray-100);
|
||||||
--color-neutral-tertiary: var(--color-gray-100);
|
--color-neutral-tertiary: var(--color-gray-100);
|
||||||
--color-neutral-tertiary-medium: var(--color-gray-100);
|
--color-neutral-tertiary-medium: var(--color-gray-100);
|
||||||
--color-neutral-quaternary: var(--color-gray-200);
|
--color-neutral-quaternary: var(--color-gray-200);
|
||||||
|
--color-neutral-quaternary-medium: var(--color-gray-200);
|
||||||
|
--color-gray: var(--color-gray-300);
|
||||||
|
--color-brand-softer: var(--color-blue-50);
|
||||||
--color-brand-soft: var(--color-blue-100);
|
--color-brand-soft: var(--color-blue-100);
|
||||||
--color-brand: var(--color-blue-700);
|
--color-brand: var(--color-blue-700);
|
||||||
--color-brand-medium: var(--color-blue-200);
|
--color-brand-medium: var(--color-blue-200);
|
||||||
--color-brand-strong: var(--color-blue-800);
|
--color-brand-strong: var(--color-blue-800);
|
||||||
|
--color-success-soft: var(--color-emerald-50);
|
||||||
|
--color-success: var(--color-emerald-700);
|
||||||
|
--color-success-medium: var(--color-emerald-100);
|
||||||
|
--color-success-strong: var(--color-emerald-800);
|
||||||
|
--color-danger-soft: var(--color-rose-50);
|
||||||
|
--color-danger: var(--color-rose-700);
|
||||||
|
--color-danger-medium: var(--color-rose-100);
|
||||||
|
--color-danger-strong: var(--color-rose-800);
|
||||||
|
--color-warning-soft: var(--color-orange-50);
|
||||||
|
--color-warning: var(--color-orange-500);
|
||||||
|
--color-warning-medium: var(--color-orange-100);
|
||||||
|
--color-warning-strong: var(--color-orange-700);
|
||||||
|
--color-dark-soft: var(--color-gray-800);
|
||||||
--color-dark: var(--color-gray-800);
|
--color-dark: var(--color-gray-800);
|
||||||
|
--color-dark-strong: var(--color-gray-900);
|
||||||
|
--color-disabled: var(--color-gray-100);
|
||||||
|
--color-purple: var(--color-purple-500);
|
||||||
|
--color-sky: var(--color-sky-500);
|
||||||
|
--color-teal: var(--color-teal-600);
|
||||||
|
--color-pink: var(--color-pink-600);
|
||||||
|
--color-cyan: var(--color-cyan-500);
|
||||||
|
--color-fuchsia: var(--color-fuchsia-600);
|
||||||
|
--color-indigo: var(--color-indigo-600);
|
||||||
|
--color-orange: var(--color-orange-400);
|
||||||
|
--color-buffer: var(--color-white);
|
||||||
|
--color-buffer-medium: var(--color-white);
|
||||||
|
--color-buffer-strong: var(--color-white);
|
||||||
|
--color-muted: var(--color-gray-50);
|
||||||
|
--color-light-subtle: var(--color-gray-100);
|
||||||
--color-light: var(--color-gray-100);
|
--color-light: var(--color-gray-100);
|
||||||
|
--color-light-medium: var(--color-gray-100);
|
||||||
|
--color-default-subtle: var(--color-gray-200);
|
||||||
--color-default: var(--color-gray-200);
|
--color-default: var(--color-gray-200);
|
||||||
--color-default-medium: var(--color-gray-200);
|
--color-default-medium: var(--color-gray-200);
|
||||||
|
--color-default-strong: var(--color-gray-200);
|
||||||
|
--color-success-subtle: var(--color-emerald-200);
|
||||||
|
--color-danger-subtle: var(--color-rose-200);
|
||||||
|
--color-warning-subtle: var(--color-orange-200);
|
||||||
|
--color-brand-subtle: var(--color-blue-200);
|
||||||
|
--color-brand-light: var(--color-blue-600);
|
||||||
|
--color-dark-subtle: var(--color-gray-800);
|
||||||
--color-dark-backdrop: var(--color-gray-950);
|
--color-dark-backdrop: var(--color-gray-950);
|
||||||
--color-accent: #7c3aed;
|
--color-accent: #7c3aed;
|
||||||
}
|
}
|
||||||
@@ -820,12 +878,18 @@
|
|||||||
.start-0 {
|
.start-0 {
|
||||||
inset-inline-start: calc(var(--spacing) * 0);
|
inset-inline-start: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
|
.end-1 {
|
||||||
|
inset-inline-end: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.end-1\.5 {
|
.end-1\.5 {
|
||||||
inset-inline-end: calc(var(--spacing) * 1.5);
|
inset-inline-end: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
.top-0 {
|
.top-0 {
|
||||||
top: calc(var(--spacing) * 0);
|
top: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
|
.top-1 {
|
||||||
|
top: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.top-1\/2 {
|
.top-1\/2 {
|
||||||
top: calc(1 / 2 * 100%);
|
top: calc(1 / 2 * 100%);
|
||||||
}
|
}
|
||||||
@@ -847,6 +911,9 @@
|
|||||||
.bottom-0 {
|
.bottom-0 {
|
||||||
bottom: calc(var(--spacing) * 0);
|
bottom: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
|
.bottom-1 {
|
||||||
|
bottom: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.bottom-1\.5 {
|
.bottom-1\.5 {
|
||||||
bottom: calc(var(--spacing) * 1.5);
|
bottom: calc(var(--spacing) * 1.5);
|
||||||
}
|
}
|
||||||
@@ -1276,6 +1343,9 @@
|
|||||||
margin-left: -10px !important;
|
margin-left: -10px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.ml-1 {
|
||||||
|
margin-left: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.ml-4 {
|
.ml-4 {
|
||||||
margin-left: calc(var(--spacing) * 4);
|
margin-left: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -1470,9 +1540,15 @@
|
|||||||
.h-full {
|
.h-full {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.max-h-40 {
|
||||||
|
max-height: calc(var(--spacing) * 40);
|
||||||
|
}
|
||||||
.max-h-full {
|
.max-h-full {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
.min-h-\[28px\] {
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
.min-h-screen {
|
.min-h-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
@@ -1541,9 +1617,15 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.w-1 {
|
||||||
|
width: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.w-1\/2 {
|
.w-1\/2 {
|
||||||
width: calc(1 / 2 * 100%);
|
width: calc(1 / 2 * 100%);
|
||||||
}
|
}
|
||||||
|
.w-2 {
|
||||||
|
width: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
.w-2\.5 {
|
.w-2\.5 {
|
||||||
width: calc(var(--spacing) * 2.5);
|
width: calc(var(--spacing) * 2.5);
|
||||||
}
|
}
|
||||||
@@ -1652,6 +1734,9 @@
|
|||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.border-collapse {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
.-translate-x-full {
|
.-translate-x-full {
|
||||||
--tw-translate-x: -100%;
|
--tw-translate-x: -100%;
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -1668,6 +1753,10 @@
|
|||||||
--tw-translate-x: 100%;
|
--tw-translate-x: 100%;
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
}
|
}
|
||||||
|
.-translate-y-1 {
|
||||||
|
--tw-translate-y: calc(var(--spacing) * -1);
|
||||||
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
|
}
|
||||||
.-translate-y-1\/2 {
|
.-translate-y-1\/2 {
|
||||||
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
|
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
|
||||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||||
@@ -1710,6 +1799,9 @@
|
|||||||
.list-disc {
|
.list-disc {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
|
.appearance-none {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -2053,6 +2145,9 @@
|
|||||||
.bg-amber-50 {
|
.bg-amber-50 {
|
||||||
background-color: var(--color-amber-50);
|
background-color: var(--color-amber-50);
|
||||||
}
|
}
|
||||||
|
.bg-black {
|
||||||
|
background-color: var(--color-black);
|
||||||
|
}
|
||||||
.bg-black\/70 {
|
.bg-black\/70 {
|
||||||
background-color: color-mix(in srgb, #000 70%, transparent);
|
background-color: color-mix(in srgb, #000 70%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2071,6 +2166,15 @@
|
|||||||
.bg-brand {
|
.bg-brand {
|
||||||
background-color: var(--color-brand);
|
background-color: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
.bg-brand\/15 {
|
||||||
|
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bg-dark-backdrop {
|
||||||
|
background-color: var(--color-dark-backdrop);
|
||||||
|
}
|
||||||
.bg-dark-backdrop\/70 {
|
.bg-dark-backdrop\/70 {
|
||||||
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2089,12 +2193,18 @@
|
|||||||
.bg-gray-500 {
|
.bg-gray-500 {
|
||||||
background-color: var(--color-gray-500);
|
background-color: var(--color-gray-500);
|
||||||
}
|
}
|
||||||
|
.bg-gray-800 {
|
||||||
|
background-color: var(--color-gray-800);
|
||||||
|
}
|
||||||
.bg-gray-800\/20 {
|
.bg-gray-800\/20 {
|
||||||
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
|
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent);
|
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.bg-gray-900 {
|
||||||
|
background-color: var(--color-gray-900);
|
||||||
|
}
|
||||||
.bg-gray-900\/50 {
|
.bg-gray-900\/50 {
|
||||||
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
|
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2209,6 +2319,18 @@
|
|||||||
fill: white !important;
|
fill: white !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.apexcharts-gridline {
|
||||||
|
stroke: var(--color-default) !important;
|
||||||
|
.dark & {
|
||||||
|
stroke: var(--color-default) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.apexcharts-xcrosshairs {
|
||||||
|
stroke: var(--color-default) !important;
|
||||||
|
.dark & {
|
||||||
|
stroke: var(--color-default) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
.apexcharts-ycrosshairs {
|
.apexcharts-ycrosshairs {
|
||||||
stroke: var(--color-default) !important;
|
stroke: var(--color-default) !important;
|
||||||
.dark & {
|
.dark & {
|
||||||
@@ -2267,6 +2389,9 @@
|
|||||||
.px-6 {
|
.px-6 {
|
||||||
padding-inline: calc(var(--spacing) * 6);
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.py-0 {
|
||||||
|
padding-block: calc(var(--spacing) * 0);
|
||||||
|
}
|
||||||
.py-0\.5 {
|
.py-0\.5 {
|
||||||
padding-block: calc(var(--spacing) * 0.5);
|
padding-block: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -2328,6 +2453,9 @@
|
|||||||
color: heading !important;
|
color: heading !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.pb-1 {
|
||||||
|
padding-bottom: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.pb-16 {
|
.pb-16 {
|
||||||
padding-bottom: calc(var(--spacing) * 16);
|
padding-bottom: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
@@ -2494,6 +2622,9 @@
|
|||||||
.text-balance {
|
.text-balance {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
.text-wrap {
|
||||||
|
text-wrap: wrap;
|
||||||
|
}
|
||||||
.whitespace-nowrap {
|
.whitespace-nowrap {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -2620,6 +2751,9 @@
|
|||||||
.italic {
|
.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
.no-underline {
|
||||||
|
text-decoration-line: none;
|
||||||
|
}
|
||||||
.no-underline\! {
|
.no-underline\! {
|
||||||
text-decoration-line: none !important;
|
text-decoration-line: none !important;
|
||||||
}
|
}
|
||||||
@@ -2683,6 +2817,10 @@
|
|||||||
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||||
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||||
}
|
}
|
||||||
|
.backdrop-filter {
|
||||||
|
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||||
|
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||||
|
}
|
||||||
.transition {
|
.transition {
|
||||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
@@ -2847,6 +2985,11 @@
|
|||||||
background-color: var(--color-gray-50);
|
background-color: var(--color-gray-50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.empty\:hidden {
|
||||||
|
&:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:scale-110 {
|
.hover\:scale-110 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -2892,6 +3035,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-brand\/15 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-gray-50 {
|
.hover\:bg-gray-50 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -3068,6 +3221,12 @@
|
|||||||
color: var(--color-blue-700);
|
color: var(--color-blue-700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.focus\:ring-0 {
|
||||||
|
&:focus {
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||||
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
.focus\:ring-2 {
|
.focus\:ring-2 {
|
||||||
&:focus {
|
&:focus {
|
||||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||||
@@ -3883,6 +4042,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.\[\&\:\:-webkit-slider-thumb\]\:relative {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\[\&\:\:-webkit-slider-thumb\]\:z-10 {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\[\&\:\:-webkit-slider-thumb\]\:z-20 {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\[\&\:\:-webkit-slider-thumb\]\:h-4 {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
height: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\[\&\:\:-webkit-slider-thumb\]\:w-4 {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
width: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\[\&\:\:-webkit-slider-thumb\]\:cursor-pointer {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\[\&\:\:-webkit-slider-thumb\]\:appearance-none {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\[\&\:\:-webkit-slider-thumb\]\:rounded-full {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
border-radius: calc(infinity * 1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\[\&\:\:-webkit-slider-thumb\]\:bg-brand {
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
.\[\&\:first-of-type_button\]\:rounded-s-lg {
|
.\[\&\:first-of-type_button\]\:rounded-s-lg {
|
||||||
&:first-of-type button {
|
&:first-of-type button {
|
||||||
border-start-start-radius: var(--radius-lg);
|
border-start-start-radius: var(--radius-lg);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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("<b>", 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()
|
||||||
Reference in New Issue
Block a user