diff --git a/common/components/filters.py b/common/components/filters.py index 3ea1ef7..e14fa87 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -8,6 +8,7 @@ from django.utils.safestring import SafeText, mark_safe from common.components.core import Component from common.components.primitives import Label, Span +from common.components.search_select import FilterSelect class FilterChoice(NamedTuple): @@ -97,6 +98,84 @@ def _get_filter_options(model_class, order_by="name") -> list[tuple[str, str]]: 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: if val is None or val == "" or val == 0: return "" @@ -347,8 +426,7 @@ def RangeSlider( ("data-target", min_input_id), ( "style", - "left:0" - + (";display:none" if point_mode else ""), + "left:0" + (";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( filter_json: str = "", status_options: list[tuple[str, str]] | None = None, - platform_options: list[tuple[int, str]] | None = None, preset_list_url: str = "", preset_save_url: str = "", ) -> SafeText: """Collapsible filter bar for the Game list.""" - from games.models import Game, Platform + from games.models import Game if status_options is None: 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) status_choice = _filter_get_choice(existing, "status") 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") mastered_value = _parse_bool(existing, "mastered") @@ -617,23 +691,20 @@ def FilterBar( children=[ _filter_field( "Status", - SelectableFilter( + _enum_filter( "status", status_options, - status_choice.selected, - status_choice.excluded, - status_choice.modifier, + status_choice, nullable=not Game._meta.get_field("status").has_default(), ), ), _filter_field( "Platform", - SelectableFilter( + _model_filter( "platform", - platform_options_str, - platform_choice.selected, - platform_choice.excluded, - platform_choice.modifier, + platform_choice, + search_url="/api/platforms/search", + resolver=_resolve_platform_options, nullable=Game._meta.get_field("platform").null, ), ), @@ -865,10 +936,8 @@ def SessionFilterBar( filter_json="", preset_list_url="", preset_save_url="" ) -> SafeText: """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) game_choice = _filter_get_choice(existing, "game") device_choice = _filter_get_choice(existing, "device") @@ -898,23 +967,21 @@ def SessionFilterBar( children=[ _filter_field( "Game", - SelectableFilter( + _model_filter( "game", - game_options, - game_choice.selected, - game_choice.excluded, - game_choice.modifier, + game_choice, + search_url="/api/games/search", + resolver=_resolve_game_options, nullable=not Game._meta.get_field("name").has_default(), ), ), _filter_field( "Device", - SelectableFilter( + _model_filter( "device", - device_options, - device_choice.selected, - device_choice.excluded, - device_choice.modifier, + device_choice, + search_url="/api/devices/search", + resolver=_resolve_device_options, nullable=Session._meta.get_field("device").null, ), ), @@ -946,10 +1013,8 @@ def PurchaseFilterBar( filter_json="", preset_list_url="", preset_save_url="" ) -> SafeText: """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] ownership_options = [(value, label) for value, label in Purchase.OWNERSHIP_TYPES] existing = _filter_parse(filter_json) @@ -975,45 +1040,39 @@ def PurchaseFilterBar( children=[ _filter_field( "Game", - SelectableFilter( + _model_filter( "games", - game_options, - game_choice.selected, - game_choice.excluded, - game_choice.modifier, + game_choice, + search_url="/api/games/search", + resolver=_resolve_game_options, nullable=False, ), ), _filter_field( "Platform", - SelectableFilter( + _model_filter( "platform", - platform_options, - platform_choice.selected, - platform_choice.excluded, - platform_choice.modifier, + platform_choice, + search_url="/api/platforms/search", + resolver=_resolve_platform_options, nullable=Purchase._meta.get_field("platform").null, ), ), _filter_field( "Type", - SelectableFilter( + _enum_filter( "type", type_options, - type_choice.selected, - type_choice.excluded, - type_choice.modifier, + type_choice, nullable=not Purchase._meta.get_field("type").has_default(), ), ), _filter_field( "Ownership", - SelectableFilter( + _enum_filter( "ownership_type", ownership_options, - ownership_choice.selected, - ownership_choice.excluded, - ownership_choice.modifier, + ownership_choice, nullable=not Purchase._meta.get_field( "ownership_type" ).has_default(), diff --git a/games/static/base.css b/games/static/base.css index e13a3cb..21d46a8 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -811,6 +811,9 @@ .static { position: static; } + .sticky { + position: sticky; + } .inset-0 { inset: calc(var(--spacing) * 0); } @@ -1285,6 +1288,9 @@ .ml-1 { margin-left: calc(var(--spacing) * 1); } + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } .ml-4 { margin-left: calc(var(--spacing) * 4); } @@ -2074,6 +2080,12 @@ .bg-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 { background-color: color-mix(in srgb, #000 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2179,6 +2191,12 @@ .bg-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 { background-color: var(--color-red-600); } @@ -2562,6 +2580,9 @@ .text-amber-500 { color: var(--color-amber-500); } + .text-amber-600 { + color: var(--color-amber-600); + } .text-amber-800 { color: var(--color-amber-800); } @@ -2658,12 +2679,18 @@ .italic { font-style: italic; } + .line-through { + text-decoration-line: line-through; + } .no-underline\! { text-decoration-line: none !important; } .underline { text-decoration-line: underline; } + .decoration-red-400 { + text-decoration-color: var(--color-red-400); + } .decoration-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 { @media (hover: hover) { @@ -2934,6 +2968,13 @@ } } } + .hover\:bg-brand { + &:hover { + @media (hover: hover) { + background-color: var(--color-brand); + } + } + } .hover\:bg-brand-strong { &: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 { @media (hover: hover) { diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index 393aa5f..5fe7682 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -59,62 +59,34 @@ filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" }; } - // ── Generic SelectableFilter widgets ── - readSelectableFilters(form); - var widgets = form.querySelectorAll("[data-selectable-filter]"); - widgets.forEach(function (w) { - var field = w.getAttribute("data-selectable-filter"); - var inc = parseJSONAttr(w, "data-included"); - var exc = parseJSONAttr(w, "data-excluded"); - var mod = w.getAttribute("data-modifier"); - if (mod === "NOT_NULL" || mod === "IS_NULL") { - filter[field] = { modifier: mod }; - } else if (inc.length > 0 || exc.length > 0) { - var isIdField = field === "platform" || field === "game" || field === "device" || field === "games"; + // ── FilterSelect widgets (data-ss-mode="filter") ── + // readSearchSelect serialises each into data-included/data-excluded/data-modifier. + readSearchSelect(form); + var widgets = form.querySelectorAll('[data-search-select][data-ss-mode="filter"]'); + widgets.forEach(function (widget) { + var field = widget.getAttribute("data-name"); + var included = parseJSONAttr(widget, "data-included"); + var excluded = parseJSONAttr(widget, "data-excluded"); + var modifier = widget.getAttribute("data-modifier"); + if (modifier === "NOT_NULL" || modifier === "IS_NULL") { + filter[field] = { modifier: modifier }; + } else if (included.length > 0 || excluded.length > 0) { + var isIdField = + field === "platform" || + field === "game" || + field === "device" || + field === "games"; filter[field] = { - value: isIdField ? inc.map(Number) : inc, - excludes: isIdField ? exc.map(Number) : exc, - modifier: mod || "INCLUDES", + value: isIdField ? included.map(Number) : included, + excludes: isIdField ? excluded.map(Number) : excluded, + modifier: modifier || "INCLUDES", }; } }); // ── Session-specific fields ── - var pageIsSessions = !!form.querySelector('[data-selectable-filter="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", - }; - } - } + var pageIsSessions = + !!form.querySelector('[data-search-select][data-ss-mode="filter"][data-name="game"]'); // Emulated checkbox (sessions page) var emulated = form.querySelector('[name="filter-emulated"]'); diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 22bde72..ce89885 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -15,7 +15,6 @@ from django.test import TestCase from common.components import ( FilterBar, PurchaseFilterBar, - SelectableFilter, SessionFilterBar, ) from games.models import Device, Game, Platform @@ -94,14 +93,15 @@ class FilterBarRenderingTest(TestCase): self._assert_range_slider(html) 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": ""}}) html = str( FilterBar( 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("Finished", html) # ...with its label self.assertNoEscapedTags(html) @@ -110,22 +110,3 @@ class FilterBarRenderingTest(TestCase): # for the double-escape bug the dedup fixed. self.assertIn(""status"", 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) diff --git a/tests/test_filters.py b/tests/test_filters.py index 9a12553..53254af 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -235,20 +235,20 @@ class TestGameFilterToQ: class TestFilterBarRendering: - """Tests for FilterBar with SelectableFilter widgets.""" + """Tests for FilterBar with FilterSelect widgets.""" - def test_status_uses_selectable_filter(self): - html = str(FilterBar(platform_options=[])) - assert "data-selectable-filter" in html + def test_status_uses_filter_select(self): + html = str(FilterBar()) + assert 'data-ss-mode="filter"' in html + assert 'data-name="status"' in html 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 def test_mastered_checked_when_filtered(self): html = str( FilterBar( - platform_options=[], filter_json=json.dumps( {"mastered": {"value": True, "modifier": "EQUALS"}} ), @@ -259,7 +259,6 @@ class TestFilterBarRendering: def test_status_prefilled(self): html = str( FilterBar( - platform_options=[], filter_json=json.dumps( {"status": {"value": ["f"], "modifier": "INCLUDES"}} ), @@ -269,19 +268,19 @@ class TestFilterBarRendering: assert "Finished" in html def test_no_hx_get(self): - html = str(FilterBar(platform_options=[])) + html = str(FilterBar()) assert "hx-get" not in html - def test_platform_options_rendered(self): - html = str(FilterBar(platform_options=[(1, "Steam"), (2, "Switch")])) - assert "Steam" in html - assert "Switch" in html + def test_platform_uses_search_url(self): + """Platform is model-backed: rows are fetched, not pre-rendered.""" + html = str(FilterBar()) + assert 'data-search-url="/api/platforms/search"' in html def test_status_has_no_modifiers(self): """Non-nullable fields should not show (None) but MUST show (Any).""" - html = str(FilterBar(platform_options=[])) - status_start = html.find('data-selectable-filter="status"') - platform_start = html.find('data-selectable-filter="platform"') + html = str(FilterBar()) + status_start = html.find('data-name="status"') + platform_start = html.find('data-name="platform"') status_section = html[status_start:platform_start] # Must have (Any) — always available assert "(Any)" in status_section @@ -290,8 +289,8 @@ class TestFilterBarRendering: def test_platform_has_modifiers(self): """Nullable ForeignKey fields should show (Any)/(None).""" - html = str(FilterBar(platform_options=[(1, "Steam")])) - platform_start = html.find('data-selectable-filter="platform"') + html = str(FilterBar()) + platform_start = html.find('data-name="platform"') platform_section = html[platform_start:] # Should have at least one modifier option assert "(Any)" in platform_section or "(None)" in platform_section