feat: implement frontend filter bars and integration across all list views
This commit is contained in:
@@ -63,6 +63,9 @@ from common.components.filters import (
|
|||||||
FilterBar,
|
FilterBar,
|
||||||
PurchaseFilterBar,
|
PurchaseFilterBar,
|
||||||
SessionFilterBar,
|
SessionFilterBar,
|
||||||
|
DeviceFilterBar,
|
||||||
|
PlatformFilterBar,
|
||||||
|
PlayEventFilterBar,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -115,4 +118,7 @@ __all__ = [
|
|||||||
"FilterBar",
|
"FilterBar",
|
||||||
"PurchaseFilterBar",
|
"PurchaseFilterBar",
|
||||||
"SessionFilterBar",
|
"SessionFilterBar",
|
||||||
|
"DeviceFilterBar",
|
||||||
|
"PlatformFilterBar",
|
||||||
|
"PlayEventFilterBar",
|
||||||
]
|
]
|
||||||
|
|||||||
+216
-10
@@ -664,6 +664,11 @@ def FilterBar(
|
|||||||
playtime_min = ""
|
playtime_min = ""
|
||||||
playtime_max = ""
|
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:
|
try:
|
||||||
year_aggregate = Game.objects.aggregate(
|
year_aggregate = Game.objects.aggregate(
|
||||||
year_min=models.Min("year_released"), year_max=models.Max("year_released")
|
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")],
|
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||||
children=[
|
children=[
|
||||||
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
|
_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(
|
RangeSlider(
|
||||||
@@ -735,6 +742,28 @@ def FilterBar(
|
|||||||
min_placeholder="e.g. 1",
|
min_placeholder="e.g. 1",
|
||||||
max_placeholder="e.g. 100",
|
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)
|
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")
|
game_choice = _filter_get_choice(existing, "game")
|
||||||
device_choice = _filter_get_choice(existing, "device")
|
device_choice = _filter_get_choice(existing, "device")
|
||||||
|
|
||||||
duration_min, duration_max = _parse_range(existing, "duration_minutes")
|
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
||||||
duration_min = _filter_mins_to_hrs(duration_min)
|
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
||||||
duration_max = _filter_mins_to_hrs(duration_max)
|
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
|
||||||
emulated_value = _parse_bool(existing, "emulated")
|
emulated_value = _parse_bool(existing, "emulated")
|
||||||
is_active_value = _parse_bool(existing, "is_active")
|
is_active_value = _parse_bool(existing, "is_active")
|
||||||
try:
|
try:
|
||||||
@@ -800,14 +829,37 @@ def SessionFilterBar(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Duration",
|
label="Total Duration (mins)",
|
||||||
input_name_prefix="filter-playtime",
|
input_name_prefix="filter-duration-total-minutes",
|
||||||
min_value=duration_min,
|
min_value=dur_tot_min,
|
||||||
max_value=duration_max,
|
max_value=dur_tot_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=duration_range_max,
|
range_max=duration_range_max * 60, # Range sliders use minutes now
|
||||||
min_placeholder="e.g. 0.5",
|
step="1",
|
||||||
max_placeholder="e.g. 10",
|
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(
|
Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
@@ -836,6 +888,11 @@ def PurchaseFilterBar(
|
|||||||
ownership_choice = _filter_get_choice(existing, "ownership_type")
|
ownership_choice = _filter_get_choice(existing, "ownership_type")
|
||||||
price_min, price_max = _parse_range(existing, "price")
|
price_min, price_max = _parse_range(existing, "price")
|
||||||
is_refunded_value = _parse_bool(existing, "is_refunded")
|
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:
|
try:
|
||||||
price_aggregate = Purchase.objects.aggregate(
|
price_aggregate = Purchase.objects.aggregate(
|
||||||
price_min=models.Min("price"), price_max=models.Max("price")
|
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")],
|
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||||
children=[
|
children=[
|
||||||
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
|
_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(
|
RangeSlider(
|
||||||
@@ -934,3 +1025,118 @@ def PurchaseFilterBar(
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
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)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -501,6 +501,8 @@ class FilterPreset(models.Model):
|
|||||||
("sessions", "Sessions"),
|
("sessions", "Sessions"),
|
||||||
("purchases", "Purchases"),
|
("purchases", "Purchases"),
|
||||||
("playevents", "Play Events"),
|
("playevents", "Play Events"),
|
||||||
|
("devices", "Devices"),
|
||||||
|
("platforms", "Platforms"),
|
||||||
]
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|||||||
@@ -293,85 +293,27 @@
|
|||||||
--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;
|
||||||
}
|
}
|
||||||
@@ -881,18 +823,12 @@
|
|||||||
.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%);
|
||||||
}
|
}
|
||||||
@@ -914,9 +850,6 @@
|
|||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -1626,15 +1559,9 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -1752,9 +1679,6 @@
|
|||||||
.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);
|
||||||
@@ -1771,10 +1695,6 @@
|
|||||||
--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);
|
||||||
@@ -2160,18 +2080,12 @@
|
|||||||
.bg-amber-50 {
|
.bg-amber-50 {
|
||||||
background-color: var(--color-amber-50);
|
background-color: var(--color-amber-50);
|
||||||
}
|
}
|
||||||
.bg-amber-500 {
|
|
||||||
background-color: var(--color-amber-500);
|
|
||||||
}
|
|
||||||
.bg-amber-500\/15 {
|
.bg-amber-500\/15 {
|
||||||
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
|
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, 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-amber-500) 15%, transparent);
|
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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)) {
|
||||||
@@ -2196,9 +2110,6 @@
|
|||||||
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
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)) {
|
||||||
@@ -2217,18 +2128,12 @@
|
|||||||
.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)) {
|
||||||
@@ -2358,18 +2263,6 @@
|
|||||||
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 & {
|
||||||
@@ -2428,9 +2321,6 @@
|
|||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -2657,9 +2547,6 @@
|
|||||||
.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;
|
||||||
}
|
}
|
||||||
@@ -2795,9 +2682,6 @@
|
|||||||
.line-through {
|
.line-through {
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
}
|
}
|
||||||
.no-underline {
|
|
||||||
text-decoration-line: none;
|
|
||||||
}
|
|
||||||
.no-underline\! {
|
.no-underline\! {
|
||||||
text-decoration-line: none !important;
|
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,);
|
-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));
|
||||||
|
|||||||
+27
-4
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
@@ -10,19 +11,28 @@ from common.components import (
|
|||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Icon,
|
Icon,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
|
DeviceFilterBar,
|
||||||
|
ModuleScript,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, local_strftime
|
||||||
from common.utils import paginate
|
from common.utils import paginate
|
||||||
|
from games.filters import parse_device_filter
|
||||||
from games.forms import DeviceForm
|
from games.forms import DeviceForm
|
||||||
from games.models import Device
|
from games.models import Device
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_devices(request: HttpRequest) -> HttpResponse:
|
def list_devices(request: HttpRequest) -> HttpResponse:
|
||||||
devices, page_obj, elided_page_range = paginate(
|
devices = Device.objects.order_by("-created_at")
|
||||||
request, 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 = {
|
data = {
|
||||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
"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,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
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
|
@login_required
|
||||||
|
|||||||
+27
-4
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
@@ -10,10 +11,13 @@ from common.components import (
|
|||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Icon,
|
Icon,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
|
PlatformFilterBar,
|
||||||
|
ModuleScript,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, local_strftime
|
||||||
from common.utils import paginate
|
from common.utils import paginate
|
||||||
|
from games.filters import parse_platform_filter
|
||||||
from games.forms import PlatformForm
|
from games.forms import PlatformForm
|
||||||
from games.models import Platform
|
from games.models import Platform
|
||||||
from games.views.general import use_custom_redirect
|
from games.views.general import use_custom_redirect
|
||||||
@@ -21,9 +25,15 @@ from games.views.general import use_custom_redirect
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_platforms(request: HttpRequest) -> HttpResponse:
|
def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||||
platforms, page_obj, elided_page_range = paginate(
|
platforms = Platform.objects.order_by("name")
|
||||||
request, 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 = {
|
data = {
|
||||||
"header_action": A(
|
"header_action": A(
|
||||||
@@ -68,7 +78,20 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
elided_page_range=elided_page_range,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
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
|
@login_required
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
@@ -17,10 +19,12 @@ from common.components import (
|
|||||||
Icon,
|
Icon,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
|
PlayEventFilterBar,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, format_duration, local_strftime
|
from common.time import dateformat, format_duration, local_strftime
|
||||||
from common.utils import paginate
|
from common.utils import paginate
|
||||||
|
from games.filters import parse_playevent_filter
|
||||||
from games.forms import PlayEventForm
|
from games.forms import PlayEventForm
|
||||||
from games.models import Game, PlayEvent, Session
|
from games.models import Game, PlayEvent, Session
|
||||||
|
|
||||||
@@ -126,9 +130,15 @@ def _get_formatted_playtime_for_game_sessions_in_range(
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_playevents(request: HttpRequest) -> HttpResponse:
|
def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||||
playevents, page_obj, elided_page_range = paginate(
|
playevents = PlayEvent.objects.order_by("-created_at")
|
||||||
request, 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)
|
data = create_playevent_tabledata(playevents, request=request)
|
||||||
content = paginated_table_content(
|
content = paginated_table_content(
|
||||||
data,
|
data,
|
||||||
@@ -136,7 +146,20 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
|||||||
elided_page_range=elided_page_range,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
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
|
@login_required
|
||||||
|
|||||||
@@ -186,3 +186,36 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
self.assertNotIn("data-match=", html)
|
self.assertNotIn("data-match=", html)
|
||||||
self.assertIn("Finished", html)
|
self.assertIn("Finished", html)
|
||||||
self.assertNoEscapedTags(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")
|
||||||
|
|||||||
Reference in New Issue
Block a user