Migrate filter bars to FilterSelect
Replace the bespoke SelectableFilter in all three bars with FilterSelect: enum
fields (status, type, ownership) pre-render their fixed options; model-backed
fields (game(s), platform, device) use the search endpoints with prefetch and
resolve only the selected ids to pill labels — dropping the per-page queries that
fetched every game/platform/device. filter_bar.js now reads filter-mode
SearchSelect widgets via readSearchSelect (data-included/excluded/modifier),
preserving the {value, excludes, modifier} JSON and id Number() coercion; the
redundant session game/device blocks are gone. Drop FilterBar's now-unused
platform_options param. Rebuild base.css for the inline filter-pill utilities and
update the bar tests to the new markup.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
+109
-50
@@ -8,6 +8,7 @@ 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
|
from common.components.primitives import Label, Span
|
||||||
|
from common.components.search_select import FilterSelect
|
||||||
|
|
||||||
|
|
||||||
class FilterChoice(NamedTuple):
|
class FilterChoice(NamedTuple):
|
||||||
@@ -97,6 +98,84 @@ def _get_filter_options(model_class, order_by="name") -> list[tuple[str, str]]:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
# ── FilterSelect adapters ────────────────────────────────────────────────────
|
||||||
|
# Each list filter is a FilterSelect. Enum fields pre-render their small, fixed
|
||||||
|
# option set; model-backed fields fetch from a search endpoint and only resolve
|
||||||
|
# the currently-selected ids to labels for their pills.
|
||||||
|
|
||||||
|
_FILTER_PREFETCH = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _modifier_options(nullable: bool) -> list[tuple[str, str]]:
|
||||||
|
"""Pinned (Any)/(None) pseudo-options; (None) only when the field is nullable."""
|
||||||
|
options = [("NOT_NULL", "(Any)")]
|
||||||
|
if nullable:
|
||||||
|
options.append(("IS_NULL", "(None)"))
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_game_options(ids):
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"value": g.id, "label": g.search_label}
|
||||||
|
for g in Game.objects.filter(pk__in=ids)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_device_options(ids):
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
from games.models import Device
|
||||||
|
|
||||||
|
return [{"value": d.id, "label": d.name} for d in Device.objects.filter(pk__in=ids)]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_platform_options(ids):
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
from games.models import Platform
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"value": p.id, "label": p.name} for p in Platform.objects.filter(pk__in=ids)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_filter(
|
||||||
|
field_name: str, options, choice: FilterChoice, *, nullable
|
||||||
|
) -> SafeText:
|
||||||
|
"""A FilterSelect over a small, fully pre-rendered option set (enum field)."""
|
||||||
|
options_str = [(str(value), label) for value, label in options]
|
||||||
|
included = [(value, _find_label(options_str, value)) for value in choice.selected]
|
||||||
|
excluded = [(value, _find_label(options_str, value)) for value in choice.excluded]
|
||||||
|
return FilterSelect(
|
||||||
|
field_name=field_name,
|
||||||
|
options=options_str,
|
||||||
|
included=included,
|
||||||
|
excluded=excluded,
|
||||||
|
modifier=choice.modifier,
|
||||||
|
modifier_options=_modifier_options(nullable),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _model_filter(
|
||||||
|
field_name: str, choice: FilterChoice, *, search_url, resolver, nullable
|
||||||
|
) -> SafeText:
|
||||||
|
"""A FilterSelect backed by a search endpoint; only selected ids are resolved
|
||||||
|
to labels (for the pills) — the option rows are fetched on demand."""
|
||||||
|
return FilterSelect(
|
||||||
|
field_name=field_name,
|
||||||
|
included=list(resolver(choice.selected)),
|
||||||
|
excluded=list(resolver(choice.excluded)),
|
||||||
|
modifier=choice.modifier,
|
||||||
|
modifier_options=_modifier_options(nullable),
|
||||||
|
search_url=search_url,
|
||||||
|
prefetch=_FILTER_PREFETCH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_mins_to_hrs(val) -> str:
|
def _filter_mins_to_hrs(val) -> str:
|
||||||
if val is None or val == "" or val == 0:
|
if val is None or val == "" or val == 0:
|
||||||
return ""
|
return ""
|
||||||
@@ -347,8 +426,7 @@ def RangeSlider(
|
|||||||
("data-target", min_input_id),
|
("data-target", min_input_id),
|
||||||
(
|
(
|
||||||
"style",
|
"style",
|
||||||
"left:0"
|
"left:0" + (";display:none" if point_mode else ""),
|
||||||
+ (";display:none" if point_mode else ""),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -565,22 +643,18 @@ def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeTe
|
|||||||
def FilterBar(
|
def FilterBar(
|
||||||
filter_json: str = "",
|
filter_json: str = "",
|
||||||
status_options: list[tuple[str, str]] | None = None,
|
status_options: list[tuple[str, str]] | None = None,
|
||||||
platform_options: list[tuple[int, str]] | None = None,
|
|
||||||
preset_list_url: str = "",
|
preset_list_url: str = "",
|
||||||
preset_save_url: str = "",
|
preset_save_url: str = "",
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Collapsible filter bar for the Game list."""
|
"""Collapsible filter bar for the Game list."""
|
||||||
from games.models import Game, Platform
|
from games.models import Game
|
||||||
|
|
||||||
if status_options is None:
|
if status_options is None:
|
||||||
status_options = [(s.value, s.label) for s in Game.Status]
|
status_options = [(s.value, s.label) for s in Game.Status]
|
||||||
if platform_options is None:
|
|
||||||
platform_options = _get_filter_options(Platform)
|
|
||||||
|
|
||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
status_choice = _filter_get_choice(existing, "status")
|
status_choice = _filter_get_choice(existing, "status")
|
||||||
platform_choice = _filter_get_choice(existing, "platform")
|
platform_choice = _filter_get_choice(existing, "platform")
|
||||||
platform_options_str = [(str(pk), name) for pk, name in platform_options]
|
|
||||||
|
|
||||||
year_min, year_max = _parse_range(existing, "year_released")
|
year_min, year_max = _parse_range(existing, "year_released")
|
||||||
mastered_value = _parse_bool(existing, "mastered")
|
mastered_value = _parse_bool(existing, "mastered")
|
||||||
@@ -617,23 +691,20 @@ def FilterBar(
|
|||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Status",
|
"Status",
|
||||||
SelectableFilter(
|
_enum_filter(
|
||||||
"status",
|
"status",
|
||||||
status_options,
|
status_options,
|
||||||
status_choice.selected,
|
status_choice,
|
||||||
status_choice.excluded,
|
|
||||||
status_choice.modifier,
|
|
||||||
nullable=not Game._meta.get_field("status").has_default(),
|
nullable=not Game._meta.get_field("status").has_default(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Platform",
|
"Platform",
|
||||||
SelectableFilter(
|
_model_filter(
|
||||||
"platform",
|
"platform",
|
||||||
platform_options_str,
|
platform_choice,
|
||||||
platform_choice.selected,
|
search_url="/api/platforms/search",
|
||||||
platform_choice.excluded,
|
resolver=_resolve_platform_options,
|
||||||
platform_choice.modifier,
|
|
||||||
nullable=Game._meta.get_field("platform").null,
|
nullable=Game._meta.get_field("platform").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -865,10 +936,8 @@ def SessionFilterBar(
|
|||||||
filter_json="", preset_list_url="", preset_save_url=""
|
filter_json="", preset_list_url="", preset_save_url=""
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Collapsible filter bar for the Session list."""
|
"""Collapsible filter bar for the Session list."""
|
||||||
from games.models import Device, Game, Session
|
from games.models import Game, Session
|
||||||
|
|
||||||
game_options = _get_filter_options(Game)
|
|
||||||
device_options = _get_filter_options(Device)
|
|
||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
game_choice = _filter_get_choice(existing, "game")
|
game_choice = _filter_get_choice(existing, "game")
|
||||||
device_choice = _filter_get_choice(existing, "device")
|
device_choice = _filter_get_choice(existing, "device")
|
||||||
@@ -898,23 +967,21 @@ def SessionFilterBar(
|
|||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Game",
|
"Game",
|
||||||
SelectableFilter(
|
_model_filter(
|
||||||
"game",
|
"game",
|
||||||
game_options,
|
game_choice,
|
||||||
game_choice.selected,
|
search_url="/api/games/search",
|
||||||
game_choice.excluded,
|
resolver=_resolve_game_options,
|
||||||
game_choice.modifier,
|
|
||||||
nullable=not Game._meta.get_field("name").has_default(),
|
nullable=not Game._meta.get_field("name").has_default(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Device",
|
"Device",
|
||||||
SelectableFilter(
|
_model_filter(
|
||||||
"device",
|
"device",
|
||||||
device_options,
|
device_choice,
|
||||||
device_choice.selected,
|
search_url="/api/devices/search",
|
||||||
device_choice.excluded,
|
resolver=_resolve_device_options,
|
||||||
device_choice.modifier,
|
|
||||||
nullable=Session._meta.get_field("device").null,
|
nullable=Session._meta.get_field("device").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -946,10 +1013,8 @@ def PurchaseFilterBar(
|
|||||||
filter_json="", preset_list_url="", preset_save_url=""
|
filter_json="", preset_list_url="", preset_save_url=""
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Collapsible filter bar for the Purchase list."""
|
"""Collapsible filter bar for the Purchase list."""
|
||||||
from games.models import Game, Platform, Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
game_options = _get_filter_options(Game)
|
|
||||||
platform_options = _get_filter_options(Platform)
|
|
||||||
type_options = [(value, label) for value, label in Purchase.TYPES]
|
type_options = [(value, label) for value, label in Purchase.TYPES]
|
||||||
ownership_options = [(value, label) for value, label in Purchase.OWNERSHIP_TYPES]
|
ownership_options = [(value, label) for value, label in Purchase.OWNERSHIP_TYPES]
|
||||||
existing = _filter_parse(filter_json)
|
existing = _filter_parse(filter_json)
|
||||||
@@ -975,45 +1040,39 @@ def PurchaseFilterBar(
|
|||||||
children=[
|
children=[
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Game",
|
"Game",
|
||||||
SelectableFilter(
|
_model_filter(
|
||||||
"games",
|
"games",
|
||||||
game_options,
|
game_choice,
|
||||||
game_choice.selected,
|
search_url="/api/games/search",
|
||||||
game_choice.excluded,
|
resolver=_resolve_game_options,
|
||||||
game_choice.modifier,
|
|
||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Platform",
|
"Platform",
|
||||||
SelectableFilter(
|
_model_filter(
|
||||||
"platform",
|
"platform",
|
||||||
platform_options,
|
platform_choice,
|
||||||
platform_choice.selected,
|
search_url="/api/platforms/search",
|
||||||
platform_choice.excluded,
|
resolver=_resolve_platform_options,
|
||||||
platform_choice.modifier,
|
|
||||||
nullable=Purchase._meta.get_field("platform").null,
|
nullable=Purchase._meta.get_field("platform").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Type",
|
"Type",
|
||||||
SelectableFilter(
|
_enum_filter(
|
||||||
"type",
|
"type",
|
||||||
type_options,
|
type_options,
|
||||||
type_choice.selected,
|
type_choice,
|
||||||
type_choice.excluded,
|
|
||||||
type_choice.modifier,
|
|
||||||
nullable=not Purchase._meta.get_field("type").has_default(),
|
nullable=not Purchase._meta.get_field("type").has_default(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Ownership",
|
"Ownership",
|
||||||
SelectableFilter(
|
_enum_filter(
|
||||||
"ownership_type",
|
"ownership_type",
|
||||||
ownership_options,
|
ownership_options,
|
||||||
ownership_choice.selected,
|
ownership_choice,
|
||||||
ownership_choice.excluded,
|
|
||||||
ownership_choice.modifier,
|
|
||||||
nullable=not Purchase._meta.get_field(
|
nullable=not Purchase._meta.get_field(
|
||||||
"ownership_type"
|
"ownership_type"
|
||||||
).has_default(),
|
).has_default(),
|
||||||
|
|||||||
@@ -811,6 +811,9 @@
|
|||||||
.static {
|
.static {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
.sticky {
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
.inset-0 {
|
.inset-0 {
|
||||||
inset: calc(var(--spacing) * 0);
|
inset: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
@@ -1285,6 +1288,9 @@
|
|||||||
.ml-1 {
|
.ml-1 {
|
||||||
margin-left: calc(var(--spacing) * 1);
|
margin-left: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
.ml-4 {
|
.ml-4 {
|
||||||
margin-left: calc(var(--spacing) * 4);
|
margin-left: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -2074,6 +2080,12 @@
|
|||||||
.bg-amber-50 {
|
.bg-amber-50 {
|
||||||
background-color: var(--color-amber-50);
|
background-color: var(--color-amber-50);
|
||||||
}
|
}
|
||||||
|
.bg-amber-500\/15 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
.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)) {
|
||||||
@@ -2179,6 +2191,12 @@
|
|||||||
.bg-red-500 {
|
.bg-red-500 {
|
||||||
background-color: var(--color-red-500);
|
background-color: var(--color-red-500);
|
||||||
}
|
}
|
||||||
|
.bg-red-500\/15 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
.bg-red-600 {
|
.bg-red-600 {
|
||||||
background-color: var(--color-red-600);
|
background-color: var(--color-red-600);
|
||||||
}
|
}
|
||||||
@@ -2562,6 +2580,9 @@
|
|||||||
.text-amber-500 {
|
.text-amber-500 {
|
||||||
color: var(--color-amber-500);
|
color: var(--color-amber-500);
|
||||||
}
|
}
|
||||||
|
.text-amber-600 {
|
||||||
|
color: var(--color-amber-600);
|
||||||
|
}
|
||||||
.text-amber-800 {
|
.text-amber-800 {
|
||||||
color: var(--color-amber-800);
|
color: var(--color-amber-800);
|
||||||
}
|
}
|
||||||
@@ -2658,12 +2679,18 @@
|
|||||||
.italic {
|
.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
.line-through {
|
||||||
|
text-decoration-line: line-through;
|
||||||
|
}
|
||||||
.no-underline\! {
|
.no-underline\! {
|
||||||
text-decoration-line: none !important;
|
text-decoration-line: none !important;
|
||||||
}
|
}
|
||||||
.underline {
|
.underline {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
}
|
}
|
||||||
|
.decoration-red-400 {
|
||||||
|
text-decoration-color: var(--color-red-400);
|
||||||
|
}
|
||||||
.decoration-slate-500 {
|
.decoration-slate-500 {
|
||||||
text-decoration-color: var(--color-slate-500);
|
text-decoration-color: var(--color-slate-500);
|
||||||
}
|
}
|
||||||
@@ -2913,6 +2940,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:border-brand {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:border-default {
|
.hover\:border-default {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -2934,6 +2968,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-brand {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-brand-strong {
|
.hover\:bg-brand-strong {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -2993,6 +3034,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-neutral-secondary-strong {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-neutral-secondary-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-neutral-tertiary-medium {
|
.hover\:bg-neutral-tertiary-medium {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
|
|||||||
@@ -59,62 +59,34 @@
|
|||||||
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generic SelectableFilter widgets ──
|
// ── FilterSelect widgets (data-ss-mode="filter") ──
|
||||||
readSelectableFilters(form);
|
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
|
||||||
var widgets = form.querySelectorAll("[data-selectable-filter]");
|
readSearchSelect(form);
|
||||||
widgets.forEach(function (w) {
|
var widgets = form.querySelectorAll('[data-search-select][data-ss-mode="filter"]');
|
||||||
var field = w.getAttribute("data-selectable-filter");
|
widgets.forEach(function (widget) {
|
||||||
var inc = parseJSONAttr(w, "data-included");
|
var field = widget.getAttribute("data-name");
|
||||||
var exc = parseJSONAttr(w, "data-excluded");
|
var included = parseJSONAttr(widget, "data-included");
|
||||||
var mod = w.getAttribute("data-modifier");
|
var excluded = parseJSONAttr(widget, "data-excluded");
|
||||||
if (mod === "NOT_NULL" || mod === "IS_NULL") {
|
var modifier = widget.getAttribute("data-modifier");
|
||||||
filter[field] = { modifier: mod };
|
if (modifier === "NOT_NULL" || modifier === "IS_NULL") {
|
||||||
} else if (inc.length > 0 || exc.length > 0) {
|
filter[field] = { modifier: modifier };
|
||||||
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
|
} else if (included.length > 0 || excluded.length > 0) {
|
||||||
|
var isIdField =
|
||||||
|
field === "platform" ||
|
||||||
|
field === "game" ||
|
||||||
|
field === "device" ||
|
||||||
|
field === "games";
|
||||||
filter[field] = {
|
filter[field] = {
|
||||||
value: isIdField ? inc.map(Number) : inc,
|
value: isIdField ? included.map(Number) : included,
|
||||||
excludes: isIdField ? exc.map(Number) : exc,
|
excludes: isIdField ? excluded.map(Number) : excluded,
|
||||||
modifier: mod || "INCLUDES",
|
modifier: modifier || "INCLUDES",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Session-specific fields ──
|
// ── Session-specific fields ──
|
||||||
var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]');
|
var pageIsSessions =
|
||||||
|
!!form.querySelector('[data-search-select][data-ss-mode="filter"][data-name="game"]');
|
||||||
// Game (sessions page)
|
|
||||||
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
|
|
||||||
if (gameWidget) {
|
|
||||||
var gIncluded = parseJSONAttr(gameWidget, "data-included");
|
|
||||||
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
|
|
||||||
var gMod = gameWidget.getAttribute("data-modifier");
|
|
||||||
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
|
|
||||||
filter.game = { modifier: gMod };
|
|
||||||
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
|
|
||||||
filter.game = {
|
|
||||||
value: gIncluded.map(Number),
|
|
||||||
excludes: gExcluded.map(Number),
|
|
||||||
modifier: gMod || "INCLUDES",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Device (sessions page)
|
|
||||||
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
|
|
||||||
if (deviceWidget) {
|
|
||||||
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
|
|
||||||
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
|
|
||||||
var dMod = deviceWidget.getAttribute("data-modifier");
|
|
||||||
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
|
|
||||||
filter.device = { modifier: dMod };
|
|
||||||
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
|
|
||||||
filter.device = {
|
|
||||||
value: dIncluded.map(Number),
|
|
||||||
excludes: dExcluded.map(Number),
|
|
||||||
modifier: dMod || "INCLUDES",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emulated checkbox (sessions page)
|
// Emulated checkbox (sessions page)
|
||||||
var emulated = form.querySelector('[name="filter-emulated"]');
|
var emulated = form.querySelector('[name="filter-emulated"]');
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from django.test import TestCase
|
|||||||
from common.components import (
|
from common.components import (
|
||||||
FilterBar,
|
FilterBar,
|
||||||
PurchaseFilterBar,
|
PurchaseFilterBar,
|
||||||
SelectableFilter,
|
|
||||||
SessionFilterBar,
|
SessionFilterBar,
|
||||||
)
|
)
|
||||||
from games.models import Device, Game, Platform
|
from games.models import Device, Game, Platform
|
||||||
@@ -94,14 +93,15 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
self._assert_range_slider(html)
|
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 an include pill in the widget."""
|
||||||
filter_json = json.dumps({"status": {"value": ["f"], "modifier": ""}})
|
filter_json = json.dumps({"status": {"value": ["f"], "modifier": ""}})
|
||||||
html = str(
|
html = str(
|
||||||
FilterBar(
|
FilterBar(
|
||||||
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertIn("sf-tag", html)
|
self.assertIn('data-ss-mode="filter"', html)
|
||||||
|
self.assertIn('data-ss-type="include"', html) # rendered as an include pill
|
||||||
self.assertIn('data-value="f"', html) # selected status reflected in widget
|
self.assertIn('data-value="f"', html) # selected status reflected in widget
|
||||||
self.assertIn("Finished", html) # ...with its label
|
self.assertIn("Finished", html) # ...with its label
|
||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
@@ -110,22 +110,3 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
# for the double-escape bug the dedup fixed.
|
# for the double-escape bug the dedup fixed.
|
||||||
self.assertIn(""status"", html)
|
self.assertIn(""status"", html)
|
||||||
self.assertNotIn(""", html)
|
self.assertNotIn(""", html)
|
||||||
|
|
||||||
|
|
||||||
class SelectableFilterTest(TestCase):
|
|
||||||
"""The shared widget the deduped FilterBar will be built on."""
|
|
||||||
|
|
||||||
OPTIONS = [("f", "Finished"), ("a", "Abandoned"), ("u", "Unplayed")]
|
|
||||||
|
|
||||||
def test_plain_widget_has_no_tags(self):
|
|
||||||
html = str(SelectableFilter("status", self.OPTIONS))
|
|
||||||
self.assertNotIn("sf-tag", html)
|
|
||||||
|
|
||||||
def test_include_and_exclude_tags(self):
|
|
||||||
html = str(
|
|
||||||
SelectableFilter("status", self.OPTIONS, selected=["f"], excluded=["a"])
|
|
||||||
)
|
|
||||||
self.assertIn('data-type="include"', html)
|
|
||||||
self.assertIn('data-type="exclude"', html)
|
|
||||||
self.assertIn("Finished", html)
|
|
||||||
self.assertIn("Abandoned", html)
|
|
||||||
|
|||||||
+16
-17
@@ -235,20 +235,20 @@ class TestGameFilterToQ:
|
|||||||
|
|
||||||
|
|
||||||
class TestFilterBarRendering:
|
class TestFilterBarRendering:
|
||||||
"""Tests for FilterBar with SelectableFilter widgets."""
|
"""Tests for FilterBar with FilterSelect widgets."""
|
||||||
|
|
||||||
def test_status_uses_selectable_filter(self):
|
def test_status_uses_filter_select(self):
|
||||||
html = str(FilterBar(platform_options=[]))
|
html = str(FilterBar())
|
||||||
assert "data-selectable-filter" in html
|
assert 'data-ss-mode="filter"' in html
|
||||||
|
assert 'data-name="status"' in html
|
||||||
|
|
||||||
def test_mastered_not_checked_by_default(self):
|
def test_mastered_not_checked_by_default(self):
|
||||||
html = str(FilterBar(filter_json="", platform_options=[]))
|
html = str(FilterBar(filter_json=""))
|
||||||
assert 'checked="true"' not in html
|
assert 'checked="true"' not in html
|
||||||
|
|
||||||
def test_mastered_checked_when_filtered(self):
|
def test_mastered_checked_when_filtered(self):
|
||||||
html = str(
|
html = str(
|
||||||
FilterBar(
|
FilterBar(
|
||||||
platform_options=[],
|
|
||||||
filter_json=json.dumps(
|
filter_json=json.dumps(
|
||||||
{"mastered": {"value": True, "modifier": "EQUALS"}}
|
{"mastered": {"value": True, "modifier": "EQUALS"}}
|
||||||
),
|
),
|
||||||
@@ -259,7 +259,6 @@ class TestFilterBarRendering:
|
|||||||
def test_status_prefilled(self):
|
def test_status_prefilled(self):
|
||||||
html = str(
|
html = str(
|
||||||
FilterBar(
|
FilterBar(
|
||||||
platform_options=[],
|
|
||||||
filter_json=json.dumps(
|
filter_json=json.dumps(
|
||||||
{"status": {"value": ["f"], "modifier": "INCLUDES"}}
|
{"status": {"value": ["f"], "modifier": "INCLUDES"}}
|
||||||
),
|
),
|
||||||
@@ -269,19 +268,19 @@ class TestFilterBarRendering:
|
|||||||
assert "Finished" in html
|
assert "Finished" in html
|
||||||
|
|
||||||
def test_no_hx_get(self):
|
def test_no_hx_get(self):
|
||||||
html = str(FilterBar(platform_options=[]))
|
html = str(FilterBar())
|
||||||
assert "hx-get" not in html
|
assert "hx-get" not in html
|
||||||
|
|
||||||
def test_platform_options_rendered(self):
|
def test_platform_uses_search_url(self):
|
||||||
html = str(FilterBar(platform_options=[(1, "Steam"), (2, "Switch")]))
|
"""Platform is model-backed: rows are fetched, not pre-rendered."""
|
||||||
assert "Steam" in html
|
html = str(FilterBar())
|
||||||
assert "Switch" in html
|
assert 'data-search-url="/api/platforms/search"' in html
|
||||||
|
|
||||||
def test_status_has_no_modifiers(self):
|
def test_status_has_no_modifiers(self):
|
||||||
"""Non-nullable fields should not show (None) but MUST show (Any)."""
|
"""Non-nullable fields should not show (None) but MUST show (Any)."""
|
||||||
html = str(FilterBar(platform_options=[]))
|
html = str(FilterBar())
|
||||||
status_start = html.find('data-selectable-filter="status"')
|
status_start = html.find('data-name="status"')
|
||||||
platform_start = html.find('data-selectable-filter="platform"')
|
platform_start = html.find('data-name="platform"')
|
||||||
status_section = html[status_start:platform_start]
|
status_section = html[status_start:platform_start]
|
||||||
# Must have (Any) — always available
|
# Must have (Any) — always available
|
||||||
assert "(Any)" in status_section
|
assert "(Any)" in status_section
|
||||||
@@ -290,8 +289,8 @@ class TestFilterBarRendering:
|
|||||||
|
|
||||||
def test_platform_has_modifiers(self):
|
def test_platform_has_modifiers(self):
|
||||||
"""Nullable ForeignKey fields should show (Any)/(None)."""
|
"""Nullable ForeignKey fields should show (Any)/(None)."""
|
||||||
html = str(FilterBar(platform_options=[(1, "Steam")]))
|
html = str(FilterBar())
|
||||||
platform_start = html.find('data-selectable-filter="platform"')
|
platform_start = html.find('data-name="platform"')
|
||||||
platform_section = html[platform_start:]
|
platform_section = html[platform_start:]
|
||||||
# Should have at least one modifier option
|
# Should have at least one modifier option
|
||||||
assert "(Any)" in platform_section or "(None)" in platform_section
|
assert "(Any)" in platform_section or "(None)" in platform_section
|
||||||
|
|||||||
Reference in New Issue
Block a user