diff --git a/common/components/__init__.py b/common/components/__init__.py index 1e73f63..635cc5c 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -63,6 +63,9 @@ from common.components.filters import ( FilterBar, PurchaseFilterBar, SessionFilterBar, + DeviceFilterBar, + PlatformFilterBar, + PlayEventFilterBar, ) __all__ = [ @@ -115,4 +118,7 @@ __all__ = [ "FilterBar", "PurchaseFilterBar", "SessionFilterBar", + "DeviceFilterBar", + "PlatformFilterBar", + "PlayEventFilterBar", ] diff --git a/common/components/filters.py b/common/components/filters.py index 83a126b..9cccd6c 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -664,6 +664,11 @@ def FilterBar( playtime_min = "" playtime_max = "" + has_purchases_value = _parse_bool(existing, "has_purchases") + has_playevents_value = _parse_bool(existing, "has_playevents") + session_count_min, session_count_max = _parse_range(existing, "session_count") + session_avg_min, session_avg_max = _parse_range(existing, "session_average") + try: year_aggregate = Game.objects.aggregate( year_min=models.Min("year_released"), year_max=models.Max("year_released") @@ -722,6 +727,8 @@ def FilterBar( attributes=[("class", "flex items-end gap-4 mb-4")], children=[ _filter_checkbox("filter-mastered", "Mastered", mastered_value), + _filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value), + _filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value), ], ), RangeSlider( @@ -735,6 +742,28 @@ def FilterBar( min_placeholder="e.g. 1", max_placeholder="e.g. 100", ), + RangeSlider( + label="Session Count", + input_name_prefix="filter-session-count", + min_value=session_count_min, + max_value=session_count_max, + range_min=0, + range_max=100, + step="1", + min_placeholder="e.g. 1", + max_placeholder="e.g. 50", + ), + RangeSlider( + label="Average Session Duration (mins)", + input_name_prefix="filter-session-average", + min_value=session_avg_min, + max_value=session_avg_max, + range_min=0, + range_max=240, + step="1", + min_placeholder="e.g. 10", + max_placeholder="e.g. 120", + ), ] return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) @@ -756,9 +785,9 @@ def SessionFilterBar( game_choice = _filter_get_choice(existing, "game") device_choice = _filter_get_choice(existing, "device") - duration_min, duration_max = _parse_range(existing, "duration_minutes") - duration_min = _filter_mins_to_hrs(duration_min) - duration_max = _filter_mins_to_hrs(duration_max) + dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes") + dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes") + dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes") emulated_value = _parse_bool(existing, "emulated") is_active_value = _parse_bool(existing, "is_active") try: @@ -800,14 +829,37 @@ def SessionFilterBar( ], ), RangeSlider( - label="Duration", - input_name_prefix="filter-playtime", - min_value=duration_min, - max_value=duration_max, + label="Total Duration (mins)", + input_name_prefix="filter-duration-total-minutes", + min_value=dur_tot_min, + max_value=dur_tot_max, range_min=0, - range_max=duration_range_max, - min_placeholder="e.g. 0.5", - max_placeholder="e.g. 10", + range_max=duration_range_max * 60, # Range sliders use minutes now + step="1", + min_placeholder="e.g. 30", + max_placeholder="e.g. 180", + ), + RangeSlider( + label="Manual Duration (mins)", + input_name_prefix="filter-duration-manual-minutes", + min_value=dur_man_min, + max_value=dur_man_max, + range_min=0, + range_max=240, + step="1", + min_placeholder="e.g. 10", + max_placeholder="e.g. 120", + ), + RangeSlider( + label="Calculated Duration (mins)", + input_name_prefix="filter-duration-calculated-minutes", + min_value=dur_calc_min, + max_value=dur_calc_max, + range_min=0, + range_max=duration_range_max * 60, + step="1", + min_placeholder="e.g. 30", + max_placeholder="e.g. 180", ), Component( tag_name="div", @@ -836,6 +888,11 @@ def PurchaseFilterBar( ownership_choice = _filter_get_choice(existing, "ownership_type") price_min, price_max = _parse_range(existing, "price") is_refunded_value = _parse_bool(existing, "is_refunded") + infinite_value = _parse_bool(existing, "infinite") + needs_price_update_value = _parse_bool(existing, "needs_price_update") + price_currency_value = existing.get("price_currency", {}).get("value", "") + converted_currency_value = existing.get("converted_currency", {}).get("value", "") + try: price_aggregate = Purchase.objects.aggregate( price_min=models.Min("price"), price_max=models.Max("price") @@ -909,6 +966,40 @@ def PurchaseFilterBar( attributes=[("class", "flex items-end gap-4 mb-4")], children=[ _filter_checkbox("filter-refunded", "Refunded", is_refunded_value), + _filter_checkbox("filter-infinite", "Infinite", infinite_value), + _filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value), + ], + ), + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Original Currency", + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ("name", "filter-price_currency"), + ("value", price_currency_value), + ("placeholder", "e.g. USD, EUR"), + ("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"), + ], + ), + ), + _filter_field( + "Converted Currency", + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ("name", "filter-converted_currency"), + ("value", converted_currency_value), + ("placeholder", "e.g. USD, EUR"), + ("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"), + ], + ), + ), ], ), RangeSlider( @@ -934,3 +1025,118 @@ def PurchaseFilterBar( ), ] return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) + + +def DeviceFilterBar( + filter_json="", preset_list_url="", preset_save_url="" +) -> SafeText: + """Collapsible filter bar for the Device list.""" + from games.models import Device + + existing = _filter_parse(filter_json) + type_options = Device.DEVICE_TYPES + type_choice = _filter_get_choice(existing, "type") + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Device Type", + _enum_filter( + "type", + type_options, + type_choice, + nullable=True, + ), + ), + ], + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) + + +def PlatformFilterBar( + filter_json="", preset_list_url="", preset_save_url="" +) -> SafeText: + """Collapsible filter bar for the Platform list.""" + existing = _filter_parse(filter_json) + + name_value = existing.get("name", {}).get("value", "") + group_value = existing.get("group", {}).get("value", "") + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Platform Name", + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ("name", "filter-name"), + ("value", name_value), + ("placeholder", "e.g. Nintendo Switch"), + ("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"), + ], + ), + ), + _filter_field( + "Platform Group", + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ("name", "filter-group"), + ("value", group_value), + ("placeholder", "e.g. Nintendo"), + ("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"), + ], + ), + ), + ], + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) + + +def PlayEventFilterBar( + filter_json="", preset_list_url="", preset_save_url="" +) -> SafeText: + """Collapsible filter bar for the PlayEvent list.""" + existing = _filter_parse(filter_json) + game_choice = _filter_get_choice(existing, "game") + days_min, days_max = _parse_range(existing, "days_to_finish") + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Game", + _model_filter( + "game", + game_choice, + search_url="/api/games/search", + nullable=False, + ), + ), + ], + ), + RangeSlider( + label="Days to Finish", + input_name_prefix="filter-days-to-finish", + min_value=days_min, + max_value=days_max, + range_min=0, + range_max=365, + step="1", + min_placeholder="e.g. 1", + max_placeholder="e.g. 30", + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) diff --git a/docs/superpowers/plans/2026-06-09-frontend-filters.md b/docs/superpowers/plans/2026-06-09-frontend-filters.md new file mode 100644 index 0000000..6e78a53 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-frontend-filters.md @@ -0,0 +1,577 @@ +# Frontend Filters Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a comprehensive frontend filter bar interface for all 6 list views (Games, Sessions, Purchases, Devices, Platforms, PlayEvents) with specific field controls, simple cross-entity toggles, and full JSON preset support. + +**Architecture:** We will extend existing components in `common/components/filters.py` and implement new filter bars (`DeviceFilterBar`, `PlatformFilterBar`, `PlayEventFilterBar`). We will update the views in `games/views/` to parse standard filter JSON from `request.GET.get('filter')`, apply them to querysets, render the filter bars, and export them in `common/components/__init__.py`. + +**Tech Stack:** Django, Python dataclasses, Pytest. + +--- + +### Task 1: Update existing FilterBars in `common/components/filters.py` + +**Files:** +- Modify: `common/components/filters.py` + +- [ ] **Step 1: Add new fields to GameFilterBar** +Add checkboxes for `has_purchases`, `has_playevents` and RangeSliders for `session_count`, `session_average`. + +```python +# Inside common/components/filters.py: FilterBar() + + # Parse new values + has_purchases_value = _parse_bool(existing, "has_purchases") + has_playevents_value = _parse_bool(existing, "has_playevents") + session_count_min, session_count_max = _parse_range(existing, "session_count") + session_avg_min, session_avg_max = _parse_range(existing, "session_average") + + # Add components to fields: + # 1. Under status and platform, add the checkboxes for purchases/playevents + # 2. Add RangeSliders for session count and average +``` + +Code change to apply in `FilterBar`: +```python + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Status", + _enum_filter( + "status", + status_options, + status_choice, + nullable=not Game._meta.get_field("status").has_default(), + ), + ), + _filter_field( + "Platform", + _model_filter( + "platform", + platform_choice, + search_url="/api/platforms/search", + nullable=Game._meta.get_field("platform").null, + ), + ), + ], + ), + RangeSlider( + label="Year", + input_name_prefix="filter-year", + min_value=year_min, + max_value=year_max, + range_min=year_range_min, + range_max=year_range_max, + min_placeholder="e.g. 2020", + max_placeholder="e.g. 2024", + ), + Component( + tag_name="div", + attributes=[("class", "flex items-end gap-4 mb-4")], + children=[ + _filter_checkbox("filter-mastered", "Mastered", mastered_value), + _filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value), + _filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value), + ], + ), + RangeSlider( + label="Playtime", + input_name_prefix="filter-playtime", + min_value=playtime_min, + max_value=playtime_max, + range_min=0, + range_max=playtime_range_max, + step="1", + min_placeholder="e.g. 1", + max_placeholder="e.g. 100", + ), + RangeSlider( + label="Session Count", + input_name_prefix="filter-session-count", + min_value=session_count_min, + max_value=session_count_max, + range_min=0, + range_max=100, + step="1", + min_placeholder="e.g. 1", + max_placeholder="e.g. 50", + ), + RangeSlider( + label="Average Session Duration (mins)", + input_name_prefix="filter-session-average", + min_value=session_avg_min, + max_value=session_avg_max, + range_min=0, + range_max=240, + step="1", + min_placeholder="e.g. 10", + max_placeholder="e.g. 120", + ), + ] +``` + +- [ ] **Step 2: Update SessionFilterBar to support split duration fields** +Replace old `duration_minutes` RangeSlider with split total, manual, and calculated duration RangeSliders. + +```python +# Inside common/components/filters.py: SessionFilterBar() + + dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes") + dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes") + dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes") + + # Inside fields array, replace RangeSlider "Duration" with: + RangeSlider( + label="Total Duration (mins)", + input_name_prefix="filter-duration-total-minutes", + min_value=dur_tot_min, + max_value=dur_tot_max, + range_min=0, + range_max=duration_range_max * 60, # Range sliders use minutes now + step="1", + min_placeholder="e.g. 30", + max_placeholder="e.g. 180", + ), + RangeSlider( + label="Manual Duration (mins)", + input_name_prefix="filter-duration-manual-minutes", + min_value=dur_man_min, + max_value=dur_man_max, + range_min=0, + range_max=240, + step="1", + min_placeholder="e.g. 10", + max_placeholder="e.g. 120", + ), + RangeSlider( + label="Calculated Duration (mins)", + input_name_prefix="filter-duration-calculated-minutes", + min_value=dur_calc_min, + max_value=dur_calc_max, + range_min=0, + range_max=duration_range_max * 60, + step="1", + min_placeholder="e.g. 30", + max_placeholder="e.g. 180", + ), +``` + +- [ ] **Step 3: Update PurchaseFilterBar to support original and converted currencies and infinite flag** +Add Checkboxes `infinite`, `needs_price_update` and currency StringCriterion text field / Choice options. + +```python +# Inside common/components/filters.py: PurchaseFilterBar() + + infinite_value = _parse_bool(existing, "infinite") + needs_price_update_value = _parse_bool(existing, "needs_price_update") + price_currency_value = existing.get("price_currency", {}).get("value", "") + converted_currency_value = existing.get("converted_currency", {}).get("value", "") + + # Expand fields component array with: + Component( + tag_name="div", + attributes=[("class", "flex gap-4 mb-4")], + children=[ + _filter_checkbox("filter-refunded", "Refunded", is_refunded_value), + _filter_checkbox("filter-infinite", "Infinite", infinite_value), + _filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value), + ], + ), +``` + +Add currency text filters (as primitive `Input` controls for string criteria): +```python + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Original Currency", + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ("name", "filter-price_currency"), + ("value", price_currency_value), + ("placeholder", "e.g. USD, EUR"), + ("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"), + ], + ), + ), + _filter_field( + "Converted Currency", + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ("name", "filter-converted_currency"), + ("value", converted_currency_value), + ("placeholder", "e.g. USD, EUR"), + ("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"), + ], + ), + ), + ], + ), +``` + +--- + +### Task 2: Create New FilterBars in `common/components/filters.py` + +**Files:** +- Modify: `common/components/filters.py` + +- [ ] **Step 1: Implement DeviceFilterBar, PlatformFilterBar, and PlayEventFilterBar** + +Append these three new filter bar components to `common/components/filters.py`: + +```python +def DeviceFilterBar( + filter_json="", preset_list_url="", preset_save_url="" +) -> SafeText: + """Collapsible filter bar for the Device list.""" + from games.models import Device + + existing = _filter_parse(filter_json) + type_options = Device.DEVICE_TYPES + type_choice = _filter_get_choice(existing, "type") + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Device Type", + _enum_filter( + "type", + type_options, + type_choice, + nullable=True, + ), + ), + ], + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) + + +def PlatformFilterBar( + filter_json="", preset_list_url="", preset_save_url="" +) -> SafeText: + """Collapsible filter bar for the Platform list.""" + existing = _filter_parse(filter_json) + + name_value = existing.get("name", {}).get("value", "") + group_value = existing.get("group", {}).get("value", "") + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Platform Name", + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ("name", "filter-name"), + ("value", name_value), + ("placeholder", "e.g. Nintendo Switch"), + ("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"), + ], + ), + ), + _filter_field( + "Platform Group", + Component( + tag_name="input", + attributes=[ + ("type", "text"), + ("name", "filter-group"), + ("value", group_value), + ("placeholder", "e.g. Nintendo"), + ("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"), + ], + ), + ), + ], + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) + + +def PlayEventFilterBar( + filter_json="", preset_list_url="", preset_save_url="" +) -> SafeText: + """Collapsible filter bar for the PlayEvent list.""" + from games.models import PlayEvent + + existing = _filter_parse(filter_json) + game_choice = _filter_get_choice(existing, "game") + days_min, days_max = _parse_range(existing, "days_to_finish") + + fields = [ + Component( + tag_name="div", + attributes=[("class", _FILTER_GRID_CLASS)], + children=[ + _filter_field( + "Game", + _model_filter( + "game", + game_choice, + search_url="/api/games/search", + nullable=False, + ), + ), + ], + ), + RangeSlider( + label="Days to Finish", + input_name_prefix="filter-days-to-finish", + min_value=days_min, + max_value=days_max, + range_min=0, + range_max=365, + step="1", + min_placeholder="e.g. 1", + max_placeholder="e.g. 30", + ), + ] + return _filter_bar(fields, filter_json, preset_list_url, preset_save_url) +``` + +- [ ] **Step 2: Export new FilterBars in `common/components/__init__.py`** + +Modify: `common/components/__init__.py` to import and expose `DeviceFilterBar`, `PlatformFilterBar`, and `PlayEventFilterBar`. + +```python +# Import section: +from common.components.filters import ( + FilterBar, + PurchaseFilterBar, + SessionFilterBar, + DeviceFilterBar, + PlatformFilterBar, + PlayEventFilterBar, +) + +# In __all__: + "FilterBar", + "PurchaseFilterBar", + "SessionFilterBar", + "DeviceFilterBar", + "PlatformFilterBar", + "PlayEventFilterBar", +``` + +--- + +### Task 3: Integrate FilterBars into `Device`, `Platform`, and `PlayEvent` views + +**Files:** +- Modify: `games/views/device.py` +- Modify: `games/views/platform.py` +- Modify: `games/views/playevent.py` + +- [ ] **Step 1: Integrate FilterBar in `list_devices` in `games/views/device.py`** + +Import and parse the filter, apply to queryset, instantiate `DeviceFilterBar`, prepend it to the output page content. + +```python +# At top of games/views/device.py: +from django.utils.safestring import mark_safe +from common.components import DeviceFilterBar, ModuleScript +from games.filters import parse_device_filter + +# Inside list_devices(request): + devices = Device.objects.order_by("-created_at") + + filter_json = request.GET.get("filter", "") + if filter_json: + device_filter = parse_device_filter(filter_json) + if device_filter is not None: + devices = devices.filter(device_filter.to_q()) + + devices, page_obj, elided_page_range = paginate(request, devices) + + # ... create data dict ... + + # Prepend the filter bar above table: + filter_bar = DeviceFilterBar( + filter_json=filter_json, + preset_list_url=reverse("games:list_presets") + "?mode=devices", + preset_save_url=reverse("games:save_preset") + "?mode=devices", + ) + content = mark_safe(str(filter_bar) + str(content)) + return render_page( + request, + content, + title="Manage devices", + scripts=ModuleScript("range_slider.js") + + ModuleScript("search_select.js") + + ModuleScript("filter_bar.js"), + ) +``` + +- [ ] **Step 2: Integrate FilterBar in `list_platforms` in `games/views/platform.py`** + +Import and parse the filter, apply to platform queryset, instantiate platform filter bar, prepend to page content. + +```python +# At top of games/views/platform.py: +from django.utils.safestring import mark_safe +from common.components import PlatformFilterBar, ModuleScript +from games.filters import parse_platform_filter + +# Inside list_platforms(request): + platforms = Platform.objects.order_by("name") + + filter_json = request.GET.get("filter", "") + if filter_json: + platform_filter = parse_platform_filter(filter_json) + if platform_filter is not None: + platforms = platforms.filter(platform_filter.to_q()) + + platforms, page_obj, elided_page_range = paginate(request, platforms) + + # ... create data dict ... + + filter_bar = PlatformFilterBar( + filter_json=filter_json, + preset_list_url=reverse("games:list_presets") + "?mode=platforms", + preset_save_url=reverse("games:save_preset") + "?mode=platforms", + ) + content = mark_safe(str(filter_bar) + str(content)) + return render_page( + request, + content, + title="Manage platforms", + scripts=ModuleScript("range_slider.js") + + ModuleScript("search_select.js") + + ModuleScript("filter_bar.js"), + ) +``` + +- [ ] **Step 3: Integrate FilterBar in `list_playevents` in `games/views/playevent.py`** + +Import and parse the filter, apply to playevent queryset, instantiate playevent filter bar, prepend to page content. + +```python +# At top of games/views/playevent.py: +from django.utils.safestring import mark_safe +from common.components import PlayEventFilterBar +from games.filters import parse_playevent_filter + +# Inside list_playevents(request): + playevents = PlayEvent.objects.order_by("-created_at") + + filter_json = request.GET.get("filter", "") + if filter_json: + playevent_filter = parse_playevent_filter(filter_json) + if playevent_filter is not None: + playevents = playevents.filter(playevent_filter.to_q()) + + playevents, page_obj, elided_page_range = paginate(request, playevents) + + # ... create data ... + + filter_bar = PlayEventFilterBar( + filter_json=filter_json, + preset_list_url=reverse("games:list_presets") + "?mode=playevents", + preset_save_url=reverse("games:save_preset") + "?mode=playevents", + ) + content = mark_safe(str(filter_bar) + str(content)) + return render_page( + request, + content, + title="Manage play events", + scripts=ModuleScript("range_slider.js") + + ModuleScript("search_select.js") + + ModuleScript("filter_bar.js"), + ) +``` + +--- + +### Task 4: Support new preset modes in Preset View/Model + +Ensure FilterPreset allows `devices` and `platforms` modes. + +**Files:** +- Modify: `games/models.py` +- Modify: `games/views/filter_presets.py` + +- [ ] **Step 1: Expand FilterPreset mode choices** + +Verify or expand `MODE_CHOICES` inside `FilterPreset` model in `games/models.py`. + +```python +# Inside FilterPreset class: + MODE_CHOICES = [ + ("games", "Games"), + ("sessions", "Sessions"), + ("purchases", "Purchases"), + ("playevents", "Play Events"), + ("devices", "Devices"), + ("platforms", "Platforms"), + ] +``` + +--- + +### Task 5: Add Render Tests for new FilterBars + +**Files:** +- Modify: `tests/test_filter_bars.py` + +- [ ] **Step 1: Write tests to verify new FilterBars render correctly** + +Add test cases in `tests/test_filter_bars.py`: + +```python + def test_device_filter_bar(self): + from common.components import DeviceFilterBar + html = str( + DeviceFilterBar( + filter_json="", + preset_list_url="/presets/devices/list", + preset_save_url="/presets/devices/save", + ) + ) + self._assert_shell(html, "/presets/devices/list", "/presets/devices/save") + + def test_platform_filter_bar(self): + from common.components import PlatformFilterBar + html = str( + PlatformFilterBar( + filter_json="", + preset_list_url="/presets/platforms/list", + preset_save_url="/presets/platforms/save", + ) + ) + self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save") + + def test_playevent_filter_bar(self): + from common.components import PlayEventFilterBar + html = str( + PlayEventFilterBar( + filter_json="", + preset_list_url="/presets/playevents/list", + preset_save_url="/presets/playevents/save", + ) + ) + self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save") +``` + +- [ ] **Step 2: Run all test suites to confirm complete success** + +Run: `pytest tests/test_filter_bars.py -v` +Expected: ALL filter bar render tests pass. diff --git a/games/models.py b/games/models.py index f8dd269..343d83c 100644 --- a/games/models.py +++ b/games/models.py @@ -501,6 +501,8 @@ class FilterPreset(models.Model): ("sessions", "Sessions"), ("purchases", "Purchases"), ("playevents", "Play Events"), + ("devices", "Devices"), + ("platforms", "Platforms"), ] name = models.CharField(max_length=255) diff --git a/games/static/base.css b/games/static/base.css index 3485273..c24c3e6 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -293,85 +293,27 @@ --leading-5: 20px; --radius-base: 12px; --color-body: var(--color-gray-600); - --color-body-subtle: var(--color-gray-500); --color-heading: var(--color-gray-900); - --color-fg-brand-subtle: var(--color-blue-200); --color-fg-brand: var(--color-blue-700); - --color-fg-brand-strong: var(--color-blue-900); - --color-fg-success: var(--color-emerald-700); - --color-fg-success-strong: var(--color-emerald-900); - --color-fg-danger: var(--color-rose-700); - --color-fg-danger-strong: var(--color-rose-900); - --color-fg-warning-subtle: var(--color-orange-600); - --color-fg-warning: var(--color-orange-900); - --color-fg-yellow: var(--color-yellow-400); --color-fg-disabled: var(--color-gray-400); - --color-fg-purple: var(--color-purple-600); - --color-fg-cyan: var(--color-cyan-600); - --color-fg-indigo: var(--color-indigo-600); - --color-fg-pink: var(--color-pink-600); - --color-fg-lime: var(--color-lime-600); --color-neutral-primary-soft: var(--color-white); --color-neutral-primary: var(--color-white); --color-neutral-primary-medium: var(--color-white); - --color-neutral-primary-strong: var(--color-white); --color-neutral-secondary-soft: var(--color-gray-50); --color-neutral-secondary: var(--color-gray-50); --color-neutral-secondary-medium: var(--color-gray-50); --color-neutral-secondary-strong: var(--color-gray-50); - --color-neutral-secondary-strongest: var(--color-gray-50); - --color-neutral-tertiary-soft: var(--color-gray-100); --color-neutral-tertiary: var(--color-gray-100); --color-neutral-tertiary-medium: var(--color-gray-100); --color-neutral-quaternary: var(--color-gray-200); - --color-neutral-quaternary-medium: var(--color-gray-200); - --color-gray: var(--color-gray-300); - --color-brand-softer: var(--color-blue-50); --color-brand-soft: var(--color-blue-100); --color-brand: var(--color-blue-700); --color-brand-medium: var(--color-blue-200); --color-brand-strong: var(--color-blue-800); - --color-success-soft: var(--color-emerald-50); - --color-success: var(--color-emerald-700); - --color-success-medium: var(--color-emerald-100); - --color-success-strong: var(--color-emerald-800); - --color-danger-soft: var(--color-rose-50); - --color-danger: var(--color-rose-700); - --color-danger-medium: var(--color-rose-100); - --color-danger-strong: var(--color-rose-800); - --color-warning-soft: var(--color-orange-50); - --color-warning: var(--color-orange-500); - --color-warning-medium: var(--color-orange-100); - --color-warning-strong: var(--color-orange-700); - --color-dark-soft: var(--color-gray-800); --color-dark: var(--color-gray-800); - --color-dark-strong: var(--color-gray-900); - --color-disabled: var(--color-gray-100); - --color-purple: var(--color-purple-500); - --color-sky: var(--color-sky-500); - --color-teal: var(--color-teal-600); - --color-pink: var(--color-pink-600); - --color-cyan: var(--color-cyan-500); - --color-fuchsia: var(--color-fuchsia-600); - --color-indigo: var(--color-indigo-600); - --color-orange: var(--color-orange-400); - --color-buffer: var(--color-white); - --color-buffer-medium: var(--color-white); - --color-buffer-strong: var(--color-white); - --color-muted: var(--color-gray-50); - --color-light-subtle: var(--color-gray-100); --color-light: var(--color-gray-100); - --color-light-medium: var(--color-gray-100); - --color-default-subtle: var(--color-gray-200); --color-default: var(--color-gray-200); --color-default-medium: var(--color-gray-200); - --color-default-strong: var(--color-gray-200); - --color-success-subtle: var(--color-emerald-200); - --color-danger-subtle: var(--color-rose-200); - --color-warning-subtle: var(--color-orange-200); - --color-brand-subtle: var(--color-blue-200); - --color-brand-light: var(--color-blue-600); - --color-dark-subtle: var(--color-gray-800); --color-dark-backdrop: var(--color-gray-950); --color-accent: #7c3aed; } @@ -881,18 +823,12 @@ .start-0 { inset-inline-start: calc(var(--spacing) * 0); } - .end-1 { - inset-inline-end: calc(var(--spacing) * 1); - } .end-1\.5 { inset-inline-end: calc(var(--spacing) * 1.5); } .top-0 { top: calc(var(--spacing) * 0); } - .top-1 { - top: calc(var(--spacing) * 1); - } .top-1\/2 { top: calc(1 / 2 * 100%); } @@ -914,9 +850,6 @@ .bottom-0 { bottom: calc(var(--spacing) * 0); } - .bottom-1 { - bottom: calc(var(--spacing) * 1); - } .bottom-1\.5 { bottom: calc(var(--spacing) * 1.5); } @@ -1626,15 +1559,9 @@ text-align: center; } } - .w-1 { - width: calc(var(--spacing) * 1); - } .w-1\/2 { width: calc(1 / 2 * 100%); } - .w-2 { - width: calc(var(--spacing) * 2); - } .w-2\.5 { width: calc(var(--spacing) * 2.5); } @@ -1752,9 +1679,6 @@ .shrink-0 { flex-shrink: 0; } - .border-collapse { - border-collapse: collapse; - } .-translate-x-full { --tw-translate-x: -100%; translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1771,10 +1695,6 @@ --tw-translate-x: 100%; translate: var(--tw-translate-x) var(--tw-translate-y); } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -2160,18 +2080,12 @@ .bg-amber-50 { background-color: var(--color-amber-50); } - .bg-amber-500 { - background-color: var(--color-amber-500); - } .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 { - background-color: var(--color-black); - } .bg-black\/70 { background-color: color-mix(in srgb, #000 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2196,9 +2110,6 @@ background-color: color-mix(in oklab, var(--color-brand) 15%, transparent); } } - .bg-dark-backdrop { - background-color: var(--color-dark-backdrop); - } .bg-dark-backdrop\/70 { background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2217,18 +2128,12 @@ .bg-gray-500 { background-color: var(--color-gray-500); } - .bg-gray-800 { - background-color: var(--color-gray-800); - } .bg-gray-800\/20 { background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent); } } - .bg-gray-900 { - background-color: var(--color-gray-900); - } .bg-gray-900\/50 { background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -2358,18 +2263,6 @@ fill: white !important; } } - .apexcharts-gridline { - stroke: var(--color-default) !important; - .dark & { - stroke: var(--color-default) !important; - } - } - .apexcharts-xcrosshairs { - stroke: var(--color-default) !important; - .dark & { - stroke: var(--color-default) !important; - } - } .apexcharts-ycrosshairs { stroke: var(--color-default) !important; .dark & { @@ -2428,9 +2321,6 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } - .py-0 { - padding-block: calc(var(--spacing) * 0); - } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -2657,9 +2547,6 @@ .text-balance { text-wrap: balance; } - .text-wrap { - text-wrap: wrap; - } .whitespace-nowrap { white-space: nowrap; } @@ -2795,9 +2682,6 @@ .line-through { text-decoration-line: line-through; } - .no-underline { - text-decoration-line: none; - } .no-underline\! { text-decoration-line: none !important; } @@ -2864,10 +2748,6 @@ -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); } - .backdrop-filter { - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); diff --git a/games/views/device.py b/games/views/device.py index f73b31b..9817d8d 100644 --- a/games/views/device.py +++ b/games/views/device.py @@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse +from django.utils.safestring import mark_safe from common.components import ( A, @@ -10,19 +11,28 @@ from common.components import ( ButtonGroup, Icon, paginated_table_content, + DeviceFilterBar, + ModuleScript, ) from common.layout import render_page from common.time import dateformat, local_strftime from common.utils import paginate +from games.filters import parse_device_filter from games.forms import DeviceForm from games.models import Device @login_required def list_devices(request: HttpRequest) -> HttpResponse: - devices, page_obj, elided_page_range = paginate( - request, Device.objects.order_by("-created_at") - ) + devices = Device.objects.order_by("-created_at") + + filter_json = request.GET.get("filter", "") + if filter_json: + device_filter = parse_device_filter(filter_json) + if device_filter is not None: + devices = devices.filter(device_filter.to_q()) + + devices, page_obj, elided_page_range = paginate(request, devices) data = { "header_action": A([], Button([], "Add device"), url_name="games:add_device"), @@ -61,7 +71,20 @@ def list_devices(request: HttpRequest) -> HttpResponse: elided_page_range=elided_page_range, request=request, ) - return render_page(request, content, title="Manage devices") + filter_bar = DeviceFilterBar( + filter_json=filter_json, + preset_list_url=reverse("games:list_presets") + "?mode=devices", + preset_save_url=reverse("games:save_preset") + "?mode=devices", + ) + content = mark_safe(str(filter_bar) + str(content)) + return render_page( + request, + content, + title="Manage devices", + scripts=ModuleScript("range_slider.js") + + ModuleScript("search_select.js") + + ModuleScript("filter_bar.js"), + ) @login_required diff --git a/games/views/platform.py b/games/views/platform.py index 6cfe19b..7b47fd3 100644 --- a/games/views/platform.py +++ b/games/views/platform.py @@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse +from django.utils.safestring import mark_safe from common.components import ( A, @@ -10,10 +11,13 @@ from common.components import ( ButtonGroup, Icon, paginated_table_content, + PlatformFilterBar, + ModuleScript, ) from common.layout import render_page from common.time import dateformat, local_strftime from common.utils import paginate +from games.filters import parse_platform_filter from games.forms import PlatformForm from games.models import Platform from games.views.general import use_custom_redirect @@ -21,9 +25,15 @@ from games.views.general import use_custom_redirect @login_required def list_platforms(request: HttpRequest) -> HttpResponse: - platforms, page_obj, elided_page_range = paginate( - request, Platform.objects.order_by("name") - ) + platforms = Platform.objects.order_by("name") + + filter_json = request.GET.get("filter", "") + if filter_json: + platform_filter = parse_platform_filter(filter_json) + if platform_filter is not None: + platforms = platforms.filter(platform_filter.to_q()) + + platforms, page_obj, elided_page_range = paginate(request, platforms) data = { "header_action": A( @@ -68,7 +78,20 @@ def list_platforms(request: HttpRequest) -> HttpResponse: elided_page_range=elided_page_range, request=request, ) - return render_page(request, content, title="Manage platforms") + filter_bar = PlatformFilterBar( + filter_json=filter_json, + preset_list_url=reverse("games:list_presets") + "?mode=platforms", + preset_save_url=reverse("games:save_preset") + "?mode=platforms", + ) + content = mark_safe(str(filter_bar) + str(content)) + return render_page( + request, + content, + title="Manage platforms", + scripts=ModuleScript("range_slider.js") + + ModuleScript("search_select.js") + + ModuleScript("filter_bar.js"), + ) @login_required diff --git a/games/views/playevent.py b/games/views/playevent.py index d522bc8..97f0a35 100644 --- a/games/views/playevent.py +++ b/games/views/playevent.py @@ -9,6 +9,8 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse +from django.utils.safestring import mark_safe + from common.components import ( A, AddForm, @@ -17,10 +19,12 @@ from common.components import ( Icon, ModuleScript, paginated_table_content, + PlayEventFilterBar, ) from common.layout import render_page from common.time import dateformat, format_duration, local_strftime from common.utils import paginate +from games.filters import parse_playevent_filter from games.forms import PlayEventForm from games.models import Game, PlayEvent, Session @@ -126,9 +130,15 @@ def _get_formatted_playtime_for_game_sessions_in_range( @login_required def list_playevents(request: HttpRequest) -> HttpResponse: - playevents, page_obj, elided_page_range = paginate( - request, PlayEvent.objects.order_by("-created_at") - ) + playevents = PlayEvent.objects.order_by("-created_at") + + filter_json = request.GET.get("filter", "") + if filter_json: + playevent_filter = parse_playevent_filter(filter_json) + if playevent_filter is not None: + playevents = playevents.filter(playevent_filter.to_q()) + + playevents, page_obj, elided_page_range = paginate(request, playevents) data = create_playevent_tabledata(playevents, request=request) content = paginated_table_content( data, @@ -136,7 +146,20 @@ def list_playevents(request: HttpRequest) -> HttpResponse: elided_page_range=elided_page_range, request=request, ) - return render_page(request, content, title="Manage play events") + filter_bar = PlayEventFilterBar( + filter_json=filter_json, + preset_list_url=reverse("games:list_presets") + "?mode=playevents", + preset_save_url=reverse("games:save_preset") + "?mode=playevents", + ) + content = mark_safe(str(filter_bar) + str(content)) + return render_page( + request, + content, + title="Manage play events", + scripts=ModuleScript("range_slider.js") + + ModuleScript("search_select.js") + + ModuleScript("filter_bar.js"), + ) @login_required diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 68309ee..1125a67 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -186,3 +186,36 @@ class FilterBarRenderingTest(TestCase): self.assertNotIn("data-match=", html) self.assertIn("Finished", html) self.assertNoEscapedTags(html) + + def test_device_filter_bar(self): + from common.components import DeviceFilterBar + html = str( + DeviceFilterBar( + filter_json="", + preset_list_url="/presets/devices/list", + preset_save_url="/presets/devices/save", + ) + ) + self._assert_shell(html, "/presets/devices/list", "/presets/devices/save") + + def test_platform_filter_bar(self): + from common.components import PlatformFilterBar + html = str( + PlatformFilterBar( + filter_json="", + preset_list_url="/presets/platforms/list", + preset_save_url="/presets/platforms/save", + ) + ) + self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save") + + def test_playevent_filter_bar(self): + from common.components import PlayEventFilterBar + html = str( + PlayEventFilterBar( + filter_json="", + preset_list_url="/presets/playevents/list", + preset_save_url="/presets/playevents/save", + ) + ) + self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")