diff --git a/common/components/__init__.py b/common/components/__init__.py
index f1a5659..e872fbe 100644
--- a/common/components/__init__.py
+++ b/common/components/__init__.py
@@ -42,6 +42,7 @@ from common.components.domain import (
from common.components.filters import (
DeviceFilterBar,
FilterBar,
+ NumberFilter,
PlatformFilterBar,
PlayEventFilterBar,
PurchaseFilterBar,
@@ -176,4 +177,5 @@ __all__ = [
"PlatformFilterBar",
"PlayEventFilterBar",
"StringFilter",
+ "NumberFilter",
]
diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py
index ff87988..5140f46 100644
--- a/common/components/custom_elements.py
+++ b/common/components/custom_elements.py
@@ -146,17 +146,6 @@ register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
_SelectionFields = custom_element_builder("selection-fields")
-class RangeSliderProps(TypedDict):
- min: int
- max: int
- step: int
- mode: str # "range" | "point"
-
-
-register_element("range-slider", "RangeSlider", RangeSliderProps)
-_RangeSlider = custom_element_builder("range-slider")
-
-
class DateRangePickerProps(TypedDict):
pass
diff --git a/common/components/filters.py b/common/components/filters.py
index e8527ea..db96bdb 100644
--- a/common/components/filters.py
+++ b/common/components/filters.py
@@ -2,10 +2,8 @@
from typing import NamedTuple
-from django.db import models
-
from common.components.core import BaseComponent, Element, Node, Safe
-from common.components.custom_elements import _FilterBarElement, _RangeSlider
+from common.components.custom_elements import _FilterBarElement
from common.components.date_range_picker import DateRangePicker
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
from common.components.search_select import (
@@ -35,6 +33,14 @@ class RangeValues(NamedTuple):
max: str
+class NumberValues(NamedTuple):
+ """(value, value2, modifier) parsed from a numeric filter criterion."""
+
+ value: str
+ value2: str
+ modifier: str
+
+
_FILTER_LABEL_CLASS = "text-xs font-medium text-body uppercase tracking-wide"
@@ -99,6 +105,22 @@ def _parse_range(existing: dict, key: str) -> RangeValues:
return RangeValues(str(field.get("value", "")), str(field.get("value2", "")))
+def _parse_number(existing: dict, key: str) -> NumberValues:
+ """Extract (value, value2, modifier) from a numeric filter criterion.
+
+ Backward compatible with old RangeSlider JSON: a stored GREATER_THAN /
+ LESS_THAN / BETWEEN criterion maps straight onto value/value2/modifier.
+ """
+ field = existing.get(key, {})
+ if not isinstance(field, dict):
+ return NumberValues("", "", "EQUALS")
+ return NumberValues(
+ str(field.get("value", "")),
+ str(field.get("value2", "")),
+ str(field.get("modifier") or "EQUALS"),
+ )
+
+
def _parse_bool(existing: dict, key: str) -> bool:
"""Extract a boolean value from a filter criterion."""
field = existing.get(key, {})
@@ -299,204 +321,6 @@ def _filter_boolean_radio(name: str, label: str, value: bool | None) -> Node:
)
-# SVG icons for the mode toggle (shared across all RangeSliders)
-_RANGE_ICON_SVG = (
- '"
-)
-
-_POINT_ICON_SVG = (
- '"
-)
-
-_RANGE_SLIDER_INPUT_CLASS = (
- "w-24 rounded-base border border-default-medium bg-neutral-secondary-medium "
- "text-sm text-heading p-1.5 focus:ring-brand focus:border-brand"
-)
-
-
-def RangeSlider(
- *,
- label: str,
- input_name_prefix: str,
- min_value: str = "",
- max_value: str = "",
- range_min: int,
- range_max: int,
- step: str = "1",
- min_placeholder: str = "",
- max_placeholder: str = "",
-) -> Node:
- """A labelled range slider with number inputs and range/point mode toggle.
-
- Renders a label row (label, two number inputs, toggle button) and a slider
- row (track with one or two custom draggable handles). Defaults to range mode
- (two handles). If min_value and max_value are both set and equal, starts in
- point mode (single handle). The toggle switches between modes.
- """
- min_input_id = f"{input_name_prefix}-min"
- max_input_id = f"{input_name_prefix}-max"
- point_mode = bool(min_value and max_value and min_value == max_value)
- initial_mode = "point" if point_mode else "range"
-
- return _RangeSlider(
- min=range_min,
- max=range_max,
- step=int(step),
- mode=initial_mode,
- class_="mb-4 block",
- )[
- # ── Label row ──
- Div(
- attributes=[("class", "flex items-center gap-2 mb-1")],
- children=[
- # The field label is rendered by the _filter_field wrapper.
- # This composite widget has no single labelable root, so the
- # label carries no `for` (the two inputs are named below).
- Input(
- attributes=[
- ("type", "number"),
- ("name", min_input_id),
- ("id", min_input_id),
- ("value", min_value),
- ("placeholder", min_placeholder),
- (
- "class",
- f"{_RANGE_SLIDER_INPUT_CLASS}"
- + (" hidden" if point_mode else ""),
- ),
- ],
- ),
- Span(
- attributes=[
- (
- "class",
- "range-dash text-body text-sm"
- + (" hidden" if point_mode else ""),
- ),
- ],
- children=["–"],
- ),
- Input(
- attributes=[
- ("type", "number"),
- ("name", max_input_id),
- ("id", max_input_id),
- ("value", max_value),
- ("placeholder", max_placeholder),
- ("class", _RANGE_SLIDER_INPUT_CLASS),
- ],
- ),
- Element(
- "button",
- attributes=[
- ("type", "button"),
- (
- "class",
- "range-mode-toggle p-1 text-body hover:text-heading "
- "rounded cursor-pointer shrink-0",
- ),
- (
- "title",
- "Toggle between range and single value",
- ),
- (
- "aria-label",
- "Toggle between range and single value",
- ),
- ],
- children=[
- Span(
- attributes=[
- (
- "class",
- "range-mode-icon-range"
- + (" hidden" if point_mode else ""),
- ),
- ],
- children=[Safe(_RANGE_ICON_SVG)],
- ),
- Span(
- attributes=[
- (
- "class",
- "range-mode-icon-point"
- + ("" if point_mode else " hidden"),
- ),
- ],
- children=[Safe(_POINT_ICON_SVG)],
- ),
- ],
- ),
- ],
- ),
- # ── Track row ──
- Div(
- attributes=[
- ("class", "relative h-10 w-5/6 select-none mt-1"),
- ("data-range-track", ""),
- ],
- children=[
- Div(
- attributes=[
- (
- "class",
- "absolute top-1/2 -translate-y-1/2 w-full h-2 "
- "rounded-full bg-neutral-quaternary",
- ),
- ],
- ),
- Div(
- attributes=[
- (
- "class",
- "range-track-fill absolute top-1/2 -translate-y-1/2 "
- "h-2 bg-brand rounded-full",
- ),
- ("style", "left:0;width:100%"),
- ],
- ),
- # Min handle (hidden in point mode via JS)
- Div(
- attributes=[
- (
- "class",
- "range-handle range-handle-min absolute top-1/2 "
- "-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
- "border-2 border-white shadow cursor-pointer "
- "hover:scale-110 transition-transform",
- ),
- ("data-target", min_input_id),
- (
- "style",
- "left:0" + (";display:none" if point_mode else ""),
- ),
- ],
- ),
- # Max handle
- Div(
- attributes=[
- (
- "class",
- "range-handle range-handle-max absolute top-1/2 "
- "-translate-y-1/2 w-5 h-5 bg-brand rounded-full "
- "border-2 border-white shadow cursor-pointer "
- "hover:scale-110 transition-transform",
- ),
- ("data-target", max_input_id),
- ("style", "left:100%"),
- ],
- ),
- ],
- ),
- ]
-
-
_DATE_RANGE_INPUT_CLASS = (
"w-full rounded-base border border-default-medium bg-neutral-secondary-medium "
"text-sm text-heading p-1.5 focus:ring-brand focus:border-brand"
@@ -514,11 +338,10 @@ def DateRangeFilter(
) -> Node:
"""A pair of ```` elements representing a date range.
- Mirrors ``RangeSlider`` in shape (two inputs named ``{prefix}-min`` and
- ``{prefix}-max``) but without a slider track — the browser's native date
- picker is the UI. Serialized client-side into a ``DateCriterion`` with
- ``BETWEEN`` / ``GREATER_THAN`` / ``LESS_THAN`` depending on which bound(s)
- the user filled.
+ Two inputs named ``{prefix}-min`` and ``{prefix}-max`` — the browser's
+ native date picker is the UI. Serialized client-side into a ``DateCriterion``
+ with ``BETWEEN`` / ``GREATER_THAN`` / ``LESS_THAN`` depending on which
+ bound(s) the user filled.
"""
min_input_id = f"{input_name_prefix}-min"
max_input_id = f"{input_name_prefix}-max"
@@ -800,71 +623,22 @@ def _game_fields(
"modifier", "EQUALS"
)
- year_min, year_max = _parse_range(existing, "year_released")
- original_year_min, original_year_max = _parse_range(
- existing, "original_year_released"
- )
+ year = _parse_number(existing, "year_released")
+ original_year = _parse_number(existing, "original_year_released")
mastered_value = _parse_bool_nullable(existing, "mastered")
- playtime = existing.get("playtime_hours", {})
- if isinstance(playtime, dict):
- playtime_min = playtime.get("value", "")
- playtime_max = playtime.get("value2", "")
- else:
- playtime_min = ""
- playtime_max = ""
-
- session_count_min, session_count_max = _parse_range(existing, "session_count")
- session_avg_min, session_avg_max = _parse_range(existing, "session_average")
- purchase_count_min, purchase_count_max = _parse_range(existing, "purchase_count")
- playevent_count_min, playevent_count_max = _parse_range(existing, "playevent_count")
- manual_pt_min, manual_pt_max = _parse_range(existing, "manual_playtime_hours")
- calc_pt_min, calc_pt_max = _parse_range(existing, "calculated_playtime_hours")
- price_total_min, price_total_max = _parse_range(existing, "purchase_price_total")
- price_any_min, price_any_max = _parse_range(existing, "purchase_price_any")
+ playtime = _parse_number(existing, "playtime_hours")
+ session_count = _parse_number(existing, "session_count")
+ session_avg = _parse_number(existing, "session_average")
+ purchase_count = _parse_number(existing, "purchase_count")
+ playevent_count = _parse_number(existing, "playevent_count")
+ manual_pt = _parse_number(existing, "manual_playtime_hours")
+ calc_pt = _parse_number(existing, "calculated_playtime_hours")
+ price_total = _parse_number(existing, "purchase_price_total")
+ price_any = _parse_number(existing, "purchase_price_any")
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
purchase_infinite_value = _parse_bool_nullable(existing, "purchase_infinite")
session_emulated_value = _parse_bool_nullable(existing, "session_emulated")
- try:
- year_aggregate = Game.objects.aggregate(
- year_min=models.Min("year_released"), year_max=models.Max("year_released")
- )
- except Exception:
- year_aggregate = {}
- try:
- original_year_aggregate = Game.objects.aggregate(
- year_min=models.Min("original_year_released"),
- year_max=models.Max("original_year_released"),
- )
- except Exception:
- original_year_aggregate = {}
- try:
- playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime"))
- except Exception:
- playtime_aggregate = {}
- try:
- price_aggregate = Purchase.objects.aggregate(
- price_min=models.Min("converted_price"),
- price_max=models.Max("converted_price"),
- )
- except Exception:
- price_aggregate = {}
- year_range_min = max(int(year_aggregate.get("year_min") or 1970), 1970)
- year_range_max = min(int(year_aggregate.get("year_max") or 2030), 2030)
- original_year_range_min = max(
- int(original_year_aggregate.get("year_min") or 1970), 1970
- )
- original_year_range_max = min(
- int(original_year_aggregate.get("year_max") or 2030), 2030
- )
- playtime_range_max = (
- int((playtime_aggregate.get("playtime_max") or 0).total_seconds() / 3600)
- if playtime_aggregate.get("playtime_max")
- else 200
- )
- price_range_min = int(price_aggregate.get("price_min") or 0)
- price_range_max = max(int(price_aggregate.get("price_max") or 100), 1)
-
fields = [
Div(
attributes=[("class", _FILTER_GRID_CLASS)],
@@ -934,152 +708,125 @@ def _game_fields(
),
_filter_field(
"Year",
- RangeSlider(
- label="Year",
+ NumberFilter(
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",
+ value=year.value,
+ value2=year.value2,
+ modifier=year.modifier,
+ placeholder="e.g. 2020",
+ placeholder2="e.g. 2024",
),
),
_filter_field(
"Original Year",
- RangeSlider(
- label="Original Year",
+ NumberFilter(
input_name_prefix="filter-original-year",
- min_value=original_year_min,
- max_value=original_year_max,
- range_min=original_year_range_min,
- range_max=original_year_range_max,
- min_placeholder="e.g. 1985",
- max_placeholder="e.g. 2010",
+ value=original_year.value,
+ value2=original_year.value2,
+ modifier=original_year.modifier,
+ placeholder="e.g. 1985",
+ placeholder2="e.g. 2010",
),
),
_filter_field(
"Total playtime",
- RangeSlider(
- label="Total playtime",
+ NumberFilter(
input_name_prefix="filter-playtime-hours",
- 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",
+ value=playtime.value,
+ value2=playtime.value2,
+ modifier=playtime.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 100",
),
),
_filter_field(
"Manual Playtime (hrs)",
- RangeSlider(
- label="Manual Playtime (hrs)",
+ NumberFilter(
input_name_prefix="filter-manual-playtime-hours",
- min_value=manual_pt_min,
- max_value=manual_pt_max,
- range_min=0,
- range_max=max(playtime_range_max, 4),
- step="1",
- min_placeholder="e.g. 1",
- max_placeholder="e.g. 10",
+ value=manual_pt.value,
+ value2=manual_pt.value2,
+ modifier=manual_pt.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 10",
),
),
_filter_field(
"Calculated Playtime (hrs)",
- RangeSlider(
- label="Calculated Playtime (hrs)",
+ NumberFilter(
input_name_prefix="filter-calculated-playtime-hours",
- min_value=calc_pt_min,
- max_value=calc_pt_max,
- range_min=0,
- range_max=max(playtime_range_max, 4),
- step="1",
- min_placeholder="e.g. 1",
- max_placeholder="e.g. 10",
+ value=calc_pt.value,
+ value2=calc_pt.value2,
+ modifier=calc_pt.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 10",
),
),
_filter_field(
"Session Count",
- RangeSlider(
- label="Session Count",
+ NumberFilter(
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",
+ value=session_count.value,
+ value2=session_count.value2,
+ modifier=session_count.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 50",
),
),
_filter_field(
"Average Session Duration (mins)",
- RangeSlider(
- label="Average Session Duration (mins)",
+ NumberFilter(
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",
+ value=session_avg.value,
+ value2=session_avg.value2,
+ modifier=session_avg.modifier,
+ placeholder="e.g. 10",
+ placeholder2="e.g. 120",
),
),
_filter_field(
"Number of Purchases",
- RangeSlider(
- label="Number of Purchases",
+ NumberFilter(
input_name_prefix="filter-purchase-count",
- min_value=purchase_count_min,
- max_value=purchase_count_max,
- range_min=0,
- range_max=20,
- step="1",
- min_placeholder="e.g. 1",
- max_placeholder="e.g. 5",
+ value=purchase_count.value,
+ value2=purchase_count.value2,
+ modifier=purchase_count.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 5",
),
),
_filter_field(
"Number of Play Events",
- RangeSlider(
- label="Number of Play Events",
+ NumberFilter(
input_name_prefix="filter-playevent-count",
- min_value=playevent_count_min,
- max_value=playevent_count_max,
- range_min=0,
- range_max=20,
- step="1",
- min_placeholder="e.g. 1",
- max_placeholder="e.g. 5",
+ value=playevent_count.value,
+ value2=playevent_count.value2,
+ modifier=playevent_count.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 5",
),
),
_filter_field(
"Total Purchase Price",
- RangeSlider(
- label="Total Purchase Price",
+ NumberFilter(
input_name_prefix="filter-purchase-price-total",
- min_value=price_total_min,
- max_value=price_total_max,
- range_min=price_range_min,
- range_max=price_range_max,
- min_placeholder="0",
- max_placeholder=str(price_range_max),
+ value=price_total.value,
+ value2=price_total.value2,
+ modifier=price_total.modifier,
+ placeholder="0",
+ placeholder2="e.g. 100",
+ step="0.01",
),
),
_filter_field(
"Any Purchase Price",
- RangeSlider(
- label="Any Purchase Price",
+ NumberFilter(
input_name_prefix="filter-purchase-price-any",
- min_value=price_any_min,
- max_value=price_any_max,
- range_min=price_range_min,
- range_max=price_range_max,
- min_placeholder="0",
- max_placeholder=str(price_range_max),
+ value=price_any.value,
+ value2=price_any.value2,
+ modifier=price_any.modifier,
+ placeholder="0",
+ placeholder2="e.g. 100",
+ step="0.01",
),
),
],
@@ -1125,23 +872,11 @@ def _session_fields(existing: dict) -> list:
note_value = existing.get("note", {}).get("value", "")
note_modifier = existing.get("note", {}).get("modifier", "EQUALS")
- dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_hours")
- dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_hours")
- dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_hours")
+ dur_tot = _parse_number(existing, "duration_total_hours")
+ dur_man = _parse_number(existing, "duration_manual_hours")
+ dur_calc = _parse_number(existing, "duration_calculated_hours")
emulated_value = _parse_bool_nullable(existing, "emulated")
is_active_value = _parse_bool_nullable(existing, "is_active")
- try:
- duration_aggregate = Session.objects.aggregate(
- duration_max=models.Max("duration_total")
- )
- duration_range_max = max(
- int((duration_aggregate.get("duration_max") or 0).total_seconds() / 3600)
- if duration_aggregate.get("duration_max")
- else 200,
- 1,
- )
- except Exception:
- duration_range_max = 200
fields = [
Div(
@@ -1176,38 +911,38 @@ def _session_fields(existing: dict) -> list:
),
],
),
- RangeSlider(
- label="Total Duration (hrs)",
- input_name_prefix="filter-duration-total-hours",
- min_value=dur_tot_min,
- max_value=dur_tot_max,
- range_min=0,
- range_max=duration_range_max,
- step="1",
- min_placeholder="e.g. 1",
- max_placeholder="e.g. 10",
+ _filter_field(
+ "Total Duration (hrs)",
+ NumberFilter(
+ input_name_prefix="filter-duration-total-hours",
+ value=dur_tot.value,
+ value2=dur_tot.value2,
+ modifier=dur_tot.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 10",
+ ),
),
- RangeSlider(
- label="Manual Duration (hrs)",
- input_name_prefix="filter-duration-manual-hours",
- min_value=dur_man_min,
- max_value=dur_man_max,
- range_min=0,
- range_max=duration_range_max,
- step="1",
- min_placeholder="e.g. 1",
- max_placeholder="e.g. 10",
+ _filter_field(
+ "Manual Duration (hrs)",
+ NumberFilter(
+ input_name_prefix="filter-duration-manual-hours",
+ value=dur_man.value,
+ value2=dur_man.value2,
+ modifier=dur_man.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 10",
+ ),
),
- RangeSlider(
- label="Calculated Duration (hrs)",
- input_name_prefix="filter-duration-calculated-hours",
- min_value=dur_calc_min,
- max_value=dur_calc_max,
- range_min=0,
- range_max=duration_range_max,
- step="1",
- min_placeholder="e.g. 1",
- max_placeholder="e.g. 10",
+ _filter_field(
+ "Calculated Duration (hrs)",
+ NumberFilter(
+ input_name_prefix="filter-duration-calculated-hours",
+ value=dur_calc.value,
+ value2=dur_calc.value2,
+ modifier=dur_calc.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 10",
+ ),
),
Div(
attributes=[("class", "flex gap-6 mb-4")],
@@ -1236,7 +971,7 @@ def _purchase_fields(existing: dict) -> list:
platform_choice = _filter_get_choice(existing, "platform")
type_choice = _filter_get_choice(existing, "type")
ownership_choice = _filter_get_choice(existing, "ownership_type")
- price_min, price_max = _parse_range(existing, "price")
+ price = _parse_number(existing, "price")
is_refunded_value = _parse_bool_nullable(existing, "is_refunded")
infinite_value = _parse_bool_nullable(existing, "infinite")
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
@@ -1250,25 +985,7 @@ def _purchase_fields(existing: dict) -> list:
)
date_purchased_min, date_purchased_max = _parse_range(existing, "date_purchased")
date_refunded_min, date_refunded_max = _parse_range(existing, "date_refunded")
-
- try:
- price_aggregate = Purchase.objects.aggregate(
- price_min=models.Min("price"), price_max=models.Max("price")
- )
- price_range_min = int(price_aggregate.get("price_min") or 0)
- price_range_max = max(int(price_aggregate.get("price_max") or 100), 1)
- except Exception:
- price_range_min, price_range_max = 0, 100
-
- num_min, num_max = _parse_range(existing, "num_purchases")
- try:
- num_aggregate = Purchase.objects.aggregate(
- num_min=models.Min("num_purchases"), num_max=models.Max("num_purchases")
- )
- num_range_min = max(int(num_aggregate.get("num_min") or 0), 0)
- num_range_max = max(int(num_aggregate.get("num_max") or 10), 1)
- except Exception:
- num_range_min, num_range_max = 0, 10
+ num = _parse_number(existing, "num_purchases")
fields = [
Div(
@@ -1359,29 +1076,25 @@ def _purchase_fields(existing: dict) -> list:
),
_filter_field(
"Price",
- RangeSlider(
- label="Price",
+ NumberFilter(
input_name_prefix="filter-price",
- min_value=price_min,
- max_value=price_max,
- range_min=price_range_min,
- range_max=price_range_max,
- min_placeholder="0.00",
- max_placeholder="100.00",
+ value=price.value,
+ value2=price.value2,
+ modifier=price.modifier,
+ placeholder="0.00",
+ placeholder2="100.00",
+ step="0.01",
),
),
_filter_field(
"Games in purchase",
- RangeSlider(
- label="Games in purchase",
+ NumberFilter(
input_name_prefix="filter-num-purchases",
- min_value=num_min,
- max_value=num_max,
- range_min=num_range_min,
- range_max=num_range_max,
- step="1",
- min_placeholder="e.g. 1",
- max_placeholder="e.g. 5",
+ value=num.value,
+ value2=num.value2,
+ modifier=num.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 5",
),
),
Div(
@@ -1488,7 +1201,7 @@ class PlayEventFilterBar(_FilterBarBase):
def _playevent_fields(existing: dict) -> list:
game_choice = _filter_get_choice(existing, "game")
- days_min, days_max = _parse_range(existing, "days_to_finish")
+ days = _parse_number(existing, "days_to_finish")
started_min, started_max = _parse_range(existing, "started")
ended_min, ended_max = _parse_range(existing, "ended")
@@ -1527,16 +1240,13 @@ def _playevent_fields(existing: dict) -> list:
),
_filter_field(
"Days to Finish",
- RangeSlider(
- label="Days to Finish",
+ NumberFilter(
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",
+ value=days.value,
+ value2=days.value2,
+ modifier=days.modifier,
+ placeholder="e.g. 1",
+ placeholder2="e.g. 30",
),
),
]
@@ -1617,3 +1327,108 @@ def StringFilter(
Input(attributes=input_attrs),
],
)
+
+
+# text-sm + px-3 py-2.5 match every other input (canonical size).
+_NUMBER_FILTER_INPUT_CLASS = (
+ "w-full rounded-base border border-default-medium px-3 py-2.5 text-sm "
+ "bg-neutral-secondary-medium text-body focus:border-brand focus:ring-brand "
+)
+
+
+def NumberFilter(
+ input_name_prefix: str,
+ value: str = "",
+ value2: str = "",
+ modifier: str = "EQUALS",
+ placeholder: str = "",
+ placeholder2: str = "",
+ step: str = "1",
+) -> Node:
+ """Renders a numeric filter with 8 modifier radio options and two inputs.
+
+ Modeled 1:1 on :func:`StringFilter`. Both inputs are disabled for the
+ presence modifiers (IS_NULL/NOT_NULL); the second input is shown only for
+ the range modifiers (BETWEEN/NOT_BETWEEN). Initial state is server-rendered
+ so the widget never flashes before its JS runs.
+ """
+ from common.criteria import Modifier
+
+ if modifier not in [m.value for m in Modifier.for_numbers()]:
+ modifier = "EQUALS"
+
+ options = [
+ ("EQUALS", "is"),
+ ("NOT_EQUALS", "is not"),
+ ("GREATER_THAN", "is greater than"),
+ ("LESS_THAN", "is less than"),
+ ("BETWEEN", "between"),
+ ("NOT_BETWEEN", "not between"),
+ ("IS_NULL", "is null"),
+ ("NOT_NULL", "is not null"),
+ ]
+
+ radio_buttons = [
+ Radio(
+ name=f"{input_name_prefix}-modifier",
+ label=lbl,
+ checked=(modifier == mod_val),
+ value=mod_val,
+ attributes=[
+ ("data-number-modifier-radio", ""),
+ ],
+ )
+ for mod_val, lbl in options
+ ]
+
+ inputs_disabled = modifier in ("IS_NULL", "NOT_NULL")
+ second_shown = modifier in ("BETWEEN", "NOT_BETWEEN")
+ disabled_class = "opacity-50 cursor-not-allowed" if inputs_disabled else ""
+
+ value_attrs = [
+ ("name", input_name_prefix),
+ ("value", value if not inputs_disabled else ""),
+ ("placeholder", placeholder),
+ ("step", step),
+ ("class", _NUMBER_FILTER_INPUT_CLASS + disabled_class),
+ ]
+ if inputs_disabled:
+ value_attrs.append(("disabled", "true"))
+
+ value2_attrs = [
+ ("name", f"{input_name_prefix}-value2"),
+ ("value", value2 if not inputs_disabled else ""),
+ ("placeholder", placeholder2),
+ ("step", step),
+ ("data-number-value2", ""),
+ (
+ "class",
+ _NUMBER_FILTER_INPUT_CLASS
+ + disabled_class
+ + ("" if second_shown else " hidden"),
+ ),
+ ]
+ if inputs_disabled:
+ value2_attrs.append(("disabled", "true"))
+
+ return Div(
+ attributes=[("class", "flex flex-col gap-2 @container")],
+ children=[
+ Div(
+ attributes=[
+ (
+ "class",
+ "grid grid-cols-2 @md:grid-cols-4 gap-2 py-1",
+ )
+ ],
+ children=radio_buttons,
+ ),
+ Div(
+ attributes=[("class", "flex items-center gap-2")],
+ children=[
+ Input(type="number", attributes=value_attrs),
+ Input(type="number", attributes=value2_attrs),
+ ],
+ ),
+ ],
+ )
diff --git a/e2e/test_boolean_filter_e2e.py b/e2e/test_boolean_filter_e2e.py
index 15b86b2..d0721ed 100644
--- a/e2e/test_boolean_filter_e2e.py
+++ b/e2e/test_boolean_filter_e2e.py
@@ -22,7 +22,6 @@ def _bar_page(filter_json: str = "") -> str:
Boolean filter E2E
-
diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py
index dbdeb26..9e7a9da 100644
--- a/e2e/test_date_filter_e2e.py
+++ b/e2e/test_date_filter_e2e.py
@@ -30,7 +30,6 @@ def _bar_page(filter_json: str = "") -> str:
Date filter E2E
-
diff --git a/e2e/test_date_range_picker_e2e.py b/e2e/test_date_range_picker_e2e.py
index 95b1caa..0dc0c73 100644
--- a/e2e/test_date_range_picker_e2e.py
+++ b/e2e/test_date_range_picker_e2e.py
@@ -30,7 +30,6 @@ def _bar_page(filter_json: str = "") -> str:
Date range picker E2E
-
diff --git a/e2e/test_number_filter_e2e.py b/e2e/test_number_filter_e2e.py
new file mode 100644
index 0000000..4ef4c2f
--- /dev/null
+++ b/e2e/test_number_filter_e2e.py
@@ -0,0 +1,159 @@
+"""End-to-end Playwright test for the Stash-style NumberFilter: modifier
+serialization, the between second-input reveal, null-state toggling, and prefill.
+"""
+
+import json
+import urllib.parse
+
+import pytest
+from django.http import HttpResponse
+from django.test import override_settings
+from django.urls import path
+
+from common.components import FilterBar
+
+
+def _bar_page(filter_json: str = "") -> str:
+ return f"""
+
+
+ Number filter E2E
+
+
+
+
+
+
+ {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
+
+"""
+
+
+def empty_bar_view(request):
+ return HttpResponse(_bar_page())
+
+
+def prefilled_bar_view(request):
+ filter_json = json.dumps(
+ {
+ "year_released": {"value": 2000, "value2": 2010, "modifier": "BETWEEN"},
+ "session_count": {"modifier": "IS_NULL"},
+ }
+ )
+ return HttpResponse(_bar_page(filter_json=filter_json))
+
+
+urlpatterns = [
+ path("test-number-filter-empty/", empty_bar_view),
+ path("test-number-filter-prefilled/", prefilled_bar_view),
+]
+
+
+def _filter_from_url(url: str) -> dict:
+ query = urllib.parse.urlparse(url).query
+ params = urllib.parse.parse_qs(query)
+ raw = params.get("filter", [""])[0]
+ return json.loads(raw) if raw else {}
+
+
+def _open(page, url):
+ """Navigate to the bar page and expand the collapsed filter body.
+
+ base.css is loaded so the `hidden` Tailwind class actually hides elements
+ (needed to assert the value2 reveal) — which means the bar starts collapsed
+ and must be opened before its inputs are interactable."""
+ page.goto(url)
+ page.locator("[data-filter-bar-toggle]").click()
+
+
+def _submit(page):
+ with page.expect_navigation():
+ page.evaluate(
+ "document.getElementById('filter-bar-form')"
+ ".dispatchEvent(new Event('submit', {cancelable: true}))"
+ )
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_number_filter_e2e")
+def test_number_filter_defaults_and_greater_than(live_server, page):
+ _open(page, live_server.url + "/test-number-filter-empty/")
+
+ value_input = page.locator('input[name="filter-year"]')
+ value2_input = page.locator('input[name="filter-year-value2"]')
+ assert value_input.is_enabled()
+ # EQUALS is the default; the second input is hidden.
+ assert page.locator(
+ 'input[name="filter-year-modifier"][value="EQUALS"]'
+ ).is_checked()
+ assert value2_input.is_hidden()
+
+ value_input.fill("2015")
+ page.locator('input[name="filter-year-modifier"][value="GREATER_THAN"]').click()
+ _submit(page)
+
+ parsed = _filter_from_url(page.url)
+ assert parsed["year_released"] == {"value": 2015, "modifier": "GREATER_THAN"}
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_number_filter_e2e")
+def test_number_filter_between_reveals_and_serializes(live_server, page):
+ _open(page, live_server.url + "/test-number-filter-empty/")
+
+ value2_input = page.locator('input[name="filter-year-value2"]')
+ assert value2_input.is_hidden()
+
+ page.locator('input[name="filter-year-modifier"][value="BETWEEN"]').click()
+ assert value2_input.is_visible()
+
+ page.locator('input[name="filter-year"]').fill("2000")
+ value2_input.fill("2010")
+ _submit(page)
+
+ parsed = _filter_from_url(page.url)
+ assert parsed["year_released"] == {
+ "value": 2000,
+ "value2": 2010,
+ "modifier": "BETWEEN",
+ }
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_number_filter_e2e")
+def test_number_filter_null_states(live_server, page):
+ _open(page, live_server.url + "/test-number-filter-empty/")
+
+ value_input = page.locator('input[name="filter-year"]')
+ value_input.fill("1999")
+
+ page.locator('input[name="filter-year-modifier"][value="IS_NULL"]').click()
+
+ # Both inputs disable and clear under a presence modifier.
+ assert not value_input.is_enabled()
+ assert value_input.input_value() == ""
+
+ _submit(page)
+ parsed = _filter_from_url(page.url)
+ assert parsed["year_released"] == {"modifier": "IS_NULL"}
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_number_filter_e2e")
+def test_number_filter_prefilled_states(live_server, page):
+ _open(page, live_server.url + "/test-number-filter-prefilled/")
+
+ # year_released: BETWEEN with both bounds, second input visible.
+ assert page.locator('input[name="filter-year"]').input_value() == "2000"
+ assert page.locator('input[name="filter-year-value2"]').input_value() == "2010"
+ assert page.locator('input[name="filter-year-value2"]').is_visible()
+ assert page.locator(
+ 'input[name="filter-year-modifier"][value="BETWEEN"]'
+ ).is_checked()
+
+ # session_count: IS_NULL — value input disabled, modifier checked.
+ session_input = page.locator('input[name="filter-session-count"]')
+ assert not session_input.is_enabled()
+ assert page.locator(
+ 'input[name="filter-session-count-modifier"][value="IS_NULL"]'
+ ).is_checked()
diff --git a/e2e/test_range_slider_e2e.py b/e2e/test_range_slider_e2e.py
deleted file mode 100644
index 1285975..0000000
--- a/e2e/test_range_slider_e2e.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
-
-import pytest
-from django.http import HttpResponse
-from django.test import override_settings
-from django.urls import path
-
-from common.components import FilterBar
-
-
-def _bar_page(filter_json: str = "") -> str:
- return f"""
-
-
- Range Slider E2E
-
-
-
-
-
-
- {FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
-
-"""
-
-
-def empty_bar_view(request):
- return HttpResponse(_bar_page())
-
-
-urlpatterns = [
- path("test-range-slider/", empty_bar_view),
-]
-
-
-@pytest.mark.django_db
-@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
-def test_range_slider_crossover_min_higher_than_max(live_server, page):
- page.goto(live_server.url + "/test-range-slider/")
-
- # 1. Start with known state: Min is empty, Max is empty
- min_input = page.locator('input[name="filter-session-count-min"]')
- max_input = page.locator('input[name="filter-session-count-max"]')
-
- # 2. Type "20" into max input
- max_input.fill("20")
-
- # 3. Type "50" into min input (which is higher than 20)
- min_input.fill("50")
-
- # 4. Max input should have automatically synchronized/snapped to 50
- assert max_input.input_value() == "50"
-
-
-@pytest.mark.django_db
-@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
-def test_range_slider_crossover_max_less_than_min(live_server, page):
- page.goto(live_server.url + "/test-range-slider/")
-
- min_input = page.locator('input[name="filter-session-count-min"]')
- max_input = page.locator('input[name="filter-session-count-max"]')
-
- # 1. Type "50" into min input
- min_input.fill("50")
-
- # 2. Type "30" into max input (which is less than 50)
- max_input.fill("30")
-
- # 3. Min input should have automatically synchronized/snapped to 30
- assert min_input.input_value() == "30"
-
-
-@pytest.mark.django_db
-@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
-def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
- page.goto(live_server.url + "/test-range-slider/")
-
- min_input = page.locator('input[name="filter-session-count-min"]')
- max_input = page.locator('input[name="filter-session-count-max"]')
-
- # 1. Type value higher than dataMax (100 is max, type "150")
- max_input.fill("150")
- max_input.blur() # triggers "change" event
-
- assert max_input.input_value() == "100"
-
- # 2. Type value lower than dataMin (0 is min, type "-20")
- min_input.fill("-20")
- min_input.blur() # triggers "change" event
-
- assert min_input.input_value() == "0"
-
-
-@pytest.mark.django_db
-@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
-def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
- page.goto(live_server.url + "/test-range-slider/")
-
- # Locate handles
- max_handle = page.locator(
- '.range-handle-max[data-target="filter-session-count-max"]'
- )
-
- # Initially, max_input is empty, so handle should sit at 100% (far right)
- style = max_handle.get_attribute("style")
- assert "left:100%" in style or "left: 100%" in style
-
- # Set min to 50
- min_input = page.locator('input[name="filter-session-count-min"]')
- min_input.fill("50")
-
- # Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
- style = max_handle.get_attribute("style")
- assert "left:100%" in style or "left: 100%" in style
diff --git a/e2e/test_string_filter_e2e.py b/e2e/test_string_filter_e2e.py
index dd0af39..7005ca6 100644
--- a/e2e/test_string_filter_e2e.py
+++ b/e2e/test_string_filter_e2e.py
@@ -17,7 +17,6 @@ def _bar_page(filter_json: str = "") -> str:
String filter E2E
-
diff --git a/e2e/test_widgets_e2e.py b/e2e/test_widgets_e2e.py
index a240a97..fd598cc 100644
--- a/e2e/test_widgets_e2e.py
+++ b/e2e/test_widgets_e2e.py
@@ -1,4 +1,4 @@
-"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
+"""Browser tests for widget JavaScript (search_select.js, filter-bar.js,
add_purchase.js) and their onSwap() initialization lifecycle.
These run a real Chromium via pytest-playwright against pytest-django's
@@ -70,21 +70,21 @@ def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
expect(pill).to_contain_text("Finished")
-def test_range_slider_mode_toggle_fires_exactly_once(
+def test_number_filter_between_reveals_second_input(
authenticated_page: Page, live_server
):
- """One click on the mode toggle flips the slider from range to point mode
- exactly once. Double-bound listeners (the old force-re-init bug) would
- flip it twice, leaving data-mode unchanged."""
+ """Selecting the BETWEEN modifier on a NumberFilter reveals its second
+ (value2) input — proof that setupNumberFilters wired the modifier radios on
+ the initial page load."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
open_filter_bar(page)
- slider = page.locator("range-slider").first
- expect(slider).to_have_attribute("mode", "range")
+ value2 = page.locator('input[name="filter-year-value2"]')
+ expect(value2).to_be_hidden()
- slider.locator(".range-mode-toggle").click()
- expect(slider).to_have_attribute("mode", "point")
+ page.locator('input[name="filter-year-modifier"][value="BETWEEN"]').check()
+ expect(value2).to_be_visible()
def test_widgets_initialize_inside_htmx_swapped_content(
@@ -94,8 +94,8 @@ def test_widgets_initialize_inside_htmx_swapped_content(
The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
uninitialized DOM. The swapped-in FilterSelect must open its panel and the
- swapped-in slider must toggle exactly once, proving the htmx:load half of
- onSwap and the once-per-element guard."""
+ swapped-in NumberFilter must reveal its second input on BETWEEN, proving the
+ htmx:load half of onSwap and the once-per-element guard."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
@@ -111,10 +111,10 @@ def test_widgets_initialize_inside_htmx_swapped_content(
widget.locator("[data-search-select-search]").click()
expect(widget.locator("[data-search-select-options]")).to_be_visible()
- slider = page.locator("range-slider").first
- expect(slider).to_have_attribute("mode", "range")
- slider.locator(".range-mode-toggle").click()
- expect(slider).to_have_attribute("mode", "point")
+ value2 = page.locator('input[name="filter-year-value2"]')
+ expect(value2).to_be_hidden()
+ page.locator('input[name="filter-year-modifier"][value="BETWEEN"]').check()
+ expect(value2).to_be_visible()
def test_add_purchase_type_toggles_disabled_fields(
diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py
index d2ba793..a178221 100644
--- a/tests/test_filter_bars.py
+++ b/tests/test_filter_bars.py
@@ -1,11 +1,9 @@
"""Characterization tests locking the rendered output of the three filter bars.
-The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) is the
-target of a dedup + module split + RangeSlider component extraction. These tests
-pin the structural contract — form/input ids, the hidden ``filter`` field,
-preset wiring, the filter_json round-trip, no double-escaping, and the
-Flowbite-styled native range slider unification — so that refactor stays
-behaviour-preserving.
+The FilterBar family (FilterBar / SessionFilterBar / PurchaseFilterBar) pins the
+structural contract — form/input ids, the hidden ``filter`` field, preset wiring,
+the filter_json round-trip, no double-escaping, and the Stash-style NumberFilter /
+StringFilter modifier widgets — so refactors stay behaviour-preserving.
"""
import json
@@ -14,6 +12,7 @@ from django.test import TestCase
from common.components import (
FilterBar,
+ NumberFilter,
PurchaseFilterBar,
SessionFilterBar,
)
@@ -41,23 +40,20 @@ class FilterBarRenderingTest(TestCase):
self.assertIn(save_url, html) # preset save URL wired in
self.assertNoEscapedTags(html)
- def _assert_range_slider(self, html):
- """Every filter bar must use the RangeSlider component with custom
- draggable handles, a track fill, and mode-toggle button."""
- self.assertIn(" found — should use custom div handles",
- )
+ def _assert_number_filter(self, html, field_prefix):
+ """Every filter bar must use the Stash-style NumberFilter: a modifier
+ radio grid plus two number inputs (value + value2), with no legacy
+ RangeSlider custom element left behind."""
+ self.assertIn("data-number-modifier-radio", html)
+ self.assertIn(f'name="{field_prefix}-modifier"', html)
+ self.assertIn('value="BETWEEN"', html)
+ self.assertIn('value="IS_NULL"', html)
+ self.assertIn(f'name="{field_prefix}"', html)
+ self.assertIn(f'name="{field_prefix}-value2"', html)
+ self.assertIn("data-number-value2", html)
+ # The old slider element must be fully gone.
+ self.assertNotIn("]*checked="true"')
+ self.assertNotRegex(html, r'value="INCLUDES"')
diff --git a/tests/test_filters.py b/tests/test_filters.py
index a8e8334..7ffdd18 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -74,6 +74,45 @@ class TestIntCriterion:
year_released__gte=2020, year_released__lte=2024
)
+ def test_not_between(self):
+ c = IntCriterion(value=2020, value2=2024, modifier=Modifier.NOT_BETWEEN)
+ assert c.to_q("year_released") == Q(year_released__lt=2020) | Q(
+ year_released__gt=2024
+ )
+
+ def test_greater_than(self):
+ c = IntCriterion(value=10, modifier=Modifier.GREATER_THAN)
+ assert c.to_q("session_count") == Q(session_count__gt=10)
+
+ def test_less_than(self):
+ c = IntCriterion(value=10, modifier=Modifier.LESS_THAN)
+ assert c.to_q("session_count") == Q(session_count__lt=10)
+
+ def test_is_null(self):
+ c = IntCriterion(modifier=Modifier.IS_NULL)
+ assert c.to_q("year_released") == Q(year_released__isnull=True)
+
+ def test_not_null(self):
+ c = IntCriterion(modifier=Modifier.NOT_NULL)
+ assert c.to_q("year_released") == Q(year_released__isnull=False)
+
+ def test_round_trip_json_between(self):
+ """value/value2/modifier survive dict → dataclass → dict unchanged."""
+ original = IntCriterion(value=2020, value2=2024, modifier=Modifier.BETWEEN)
+ as_dict = original.to_json()
+ assert as_dict == {
+ "value": 2020,
+ "value2": 2024,
+ "modifier": Modifier.BETWEEN,
+ }
+ assert IntCriterion.from_json(as_dict) == original
+
+ def test_round_trip_json_is_null(self):
+ original = IntCriterion(modifier=Modifier.IS_NULL)
+ restored = IntCriterion.from_json(original.to_json())
+ assert restored == original
+ assert restored.to_q("year_released") == Q(year_released__isnull=True)
+
class TestBoolCriterion:
def test_equals_true(self):
diff --git a/tests/test_node_tree.py b/tests/test_node_tree.py
index 3a41261..d862de5 100644
--- a/tests/test_node_tree.py
+++ b/tests/test_node_tree.py
@@ -153,27 +153,16 @@ class RealComponentMediaTest(unittest.TestCase):
)
self.assertEqual(media.js, ("dist/elements/date-range-picker.js",))
- def test_range_slider_declares_its_script(self):
- from common.components.filters import RangeSlider
-
- media = collect_media(
- RangeSlider(
- label="Year", input_name_prefix="year", range_min=2000, range_max=2025
- )
- )
- self.assertEqual(media.js, ("dist/elements/range-slider.js",))
-
def test_filter_bar_collects_chrome_and_widget_media(self):
"""A FilterBar's media merges its own chrome script with the scripts that
- bubble up from the FilterSelect and RangeSlider widgets it contains —
- exactly the set the view used to thread by hand. (FilterBar wraps its DB
- aggregates in try/except, so it builds without a database.)"""
+ bubble up from the FilterSelect widgets it contains — exactly the set the
+ view used to thread by hand. (NumberFilter/StringFilter declare no media;
+ their behavior lives in the always-present filter-bar element.)"""
from common.components import FilterBar
media = collect_media(FilterBar())
self.assertIn("dist/elements/filter-bar.js", media.js)
self.assertIn("dist/elements/search-select.js", media.js)
- self.assertIn("dist/elements/range-slider.js", media.js)
class HtpyStyleSugarTest(unittest.TestCase):
diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py
index c8dec68..e4c43c7 100644
--- a/tests/test_rendered_pages.py
+++ b/tests/test_rendered_pages.py
@@ -66,7 +66,6 @@ class RenderedPagesTest(TestCase):
html = self.get("games:list_games").content.decode()
self.assertIn("js/dist/elements/filter-bar.js", html)
self.assertIn("js/dist/elements/search-select.js", html)
- self.assertIn("js/dist/elements/range-slider.js", html)
def test_stats_page_auto_loads_datepicker(self):
"""YearPicker declares the datepicker UMD bundle as media; the stats
diff --git a/ts/elements/filter-bar.ts b/ts/elements/filter-bar.ts
index aa0dfee..2992dbf 100644
--- a/ts/elements/filter-bar.ts
+++ b/ts/elements/filter-bar.ts
@@ -23,13 +23,6 @@ interface DeselectableRadio extends HTMLInputElement {
wasChecked?: boolean;
}
-interface RangeField {
- prefix: string;
- key: string;
- ignoreZeroZero?: boolean;
- convert?: (value: number) => number;
-}
-
function criterion(value: unknown, value2: unknown, modifier: string): Criterion {
const result: Criterion = { value, modifier };
if (value2 !== null && value2 !== undefined && value2 !== "") {
@@ -166,7 +159,7 @@ function buildFilterJSON(form: HTMLElement): Record {
}
});
- const rangeFields: RangeField[] = [
+ const numberFields = [
{ prefix: "filter-year", key: "year_released" },
{ prefix: "filter-original-year", key: "original_year_released" },
{ prefix: "filter-session-count", key: "session_count" },
@@ -183,19 +176,25 @@ function buildFilterJSON(form: HTMLElement): Record {
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
- { prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true },
+ { prefix: "filter-playtime-hours", key: "playtime_hours" },
];
- rangeFields.forEach((rangeField) => {
- let valueMin = numberValue(form, rangeField.prefix + "-min");
- let valueMax = numberValue(form, rangeField.prefix + "-max");
- if (rangeField.convert) {
- if (valueMin !== "") valueMin = rangeField.convert(valueMin);
- if (valueMax !== "") valueMax = rangeField.convert(valueMax);
+ numberFields.forEach((numberField) => {
+ const modifierElement = form.querySelector(
+ `[name="${numberField.prefix}-modifier"]:checked`,
+ );
+ const modifier = modifierElement ? modifierElement.value : "EQUALS";
+ if (modifier === "IS_NULL" || modifier === "NOT_NULL") {
+ filter[numberField.key] = { modifier };
+ return;
}
- if (rangeField.ignoreZeroZero && valueMin === 0 && valueMax === 0) return;
- const result = buildRangeCriterion(valueMin, valueMax);
- if (result !== null) filter[rangeField.key] = result;
+ const value = numberValue(form, numberField.prefix);
+ if (modifier === "BETWEEN" || modifier === "NOT_BETWEEN") {
+ const value2 = numberValue(form, numberField.prefix + "-value2");
+ if (value !== "") filter[numberField.key] = criterion(value, value2, modifier);
+ return;
+ }
+ if (value !== "") filter[numberField.key] = criterion(value, null, modifier);
});
const dateRangeFields = [
@@ -279,13 +278,48 @@ function toggleStringFilterInput(radio: HTMLInputElement): void {
}
function setupStringFilters(root: HTMLElement): void {
- root
- .querySelectorAll("input[data-string-modifier-radio]")
- .forEach((radio) => {
- radio.addEventListener("change", function (this: HTMLInputElement) {
- toggleStringFilterInput(this);
- });
- });
+ // Delegated on the persistent custom element (see setupNumberFilters) so the
+ // modifier radios keep working after an htmx swap of the inner #filter-bar.
+ root.addEventListener("change", (event) => {
+ const target = event.target as Element;
+ if (target.matches("input[data-string-modifier-radio]")) {
+ toggleStringFilterInput(target as HTMLInputElement);
+ }
+ });
+}
+
+function toggleNumberFilterInput(radio: HTMLInputElement): void {
+ const container = radio.closest(".flex-col");
+ if (!container) return;
+ const inputs = container.querySelectorAll('input[type="number"]');
+ const value2 = container.querySelector("[data-number-value2]");
+ const checkedRadio = container.querySelector('input[type="radio"]:checked');
+ const modifier = checkedRadio ? checkedRadio.value : "";
+ const isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
+ const isBetween = modifier === "BETWEEN" || modifier === "NOT_BETWEEN";
+ inputs.forEach((input) => {
+ if (isPresence) {
+ input.disabled = true;
+ input.value = "";
+ input.classList.add("opacity-50", "cursor-not-allowed");
+ } else {
+ input.disabled = false;
+ input.classList.remove("opacity-50", "cursor-not-allowed");
+ }
+ });
+ if (value2) value2.classList.toggle("hidden", isPresence || !isBetween);
+}
+
+function setupNumberFilters(root: HTMLElement): void {
+ // Delegated on the persistent custom element so the modifier radios keep
+ // working after the inner #filter-bar body is htmx-swapped (connectedCallback
+ // does not re-run for inner swaps — a direct per-radio listener would be lost).
+ root.addEventListener("change", (event) => {
+ const target = event.target as Element;
+ if (target.matches("input[data-number-modifier-radio]")) {
+ toggleNumberFilterInput(target as HTMLInputElement);
+ }
+ });
}
function setupPresetDeleteHandlers(container: HTMLElement): void {
@@ -442,6 +476,7 @@ class FilterBarElement extends HTMLElement {
injectSearchInput(form);
setupDeselectableRadios(this);
setupStringFilters(this);
+ setupNumberFilters(this);
if (presetListUrl) loadPresets(this, presetListUrl);
}
}
diff --git a/ts/elements/range-slider.ts b/ts/elements/range-slider.ts
deleted file mode 100644
index 949f16d..0000000
--- a/ts/elements/range-slider.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-/**
- * Range slider — custom draggable handles (no native ).
- *
- * Supports two modes, toggled via the .range-mode-toggle button:
- * range (default) — two handles, min ≤ max constraint
- * point — single handle, sets both number inputs to the same value
- *
- * Handles track-fill positioning and sync between handles and the connected
- * number inputs (linked via data-target attributes on the handles).
- * Behavior is wired in connectedCallback; the typed props (min, max, step, mode)
- * come from the server via readRangeSliderProps.
- */
-import { readRangeSliderProps } from "../generated/props.js";
-
-class RangeSliderElement extends HTMLElement {
- private onMouseMove: ((event: MouseEvent) => void) | null = null;
- private onMouseUp: (() => void) | null = null;
-
- connectedCallback(): void {
- const { min: dataMin, max: dataMax, step, mode: initialMode } =
- readRangeSliderProps(this);
- let mode = initialMode;
-
- const track = this.querySelector("[data-range-track]");
- const trackFill = this.querySelector(".range-track-fill");
- const minHandle = this.querySelector(".range-handle-min");
- const maxHandle = this.querySelector(".range-handle-max");
- if (!track || !minHandle || !maxHandle) return;
-
- const minTarget = document.getElementById(
- minHandle.getAttribute("data-target") ?? ""
- ) as HTMLInputElement | null;
- const maxTarget = document.getElementById(
- maxHandle.getAttribute("data-target") ?? ""
- ) as HTMLInputElement | null;
-
- // ── Helpers ──
-
- function valueToPercent(value: number): number {
- return ((value - dataMin) / (dataMax - dataMin)) * 100;
- }
- function percentToValue(percent: number): number {
- const raw = dataMin + (percent / 100) * (dataMax - dataMin);
- return Math.round(raw / step) * step;
- }
- function clamp(value: number, low: number, high: number): number {
- return Math.max(low, Math.min(high, value));
- }
-
- function getTargetValue(
- target: HTMLInputElement | null,
- defaultValue: number
- ): number {
- if (!target || target.value === "") return defaultValue;
- const parsed = parseInt(target.value, 10);
- return isNaN(parsed) ? defaultValue : parsed;
- }
- function setTargetValue(
- target: HTMLInputElement | null,
- value: number | string
- ): void {
- if (target) target.value = String(value);
- }
-
- // ── Track fill positioning ──
-
- function updateTrackFill(): void {
- if (!trackFill) return;
- const minValue = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
- const maxValue = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
- if (mode === "point") {
- trackFill.style.left = "0%";
- trackFill.style.width = valueToPercent(maxValue) + "%";
- } else {
- let leftPercent = valueToPercent(minValue);
- let rightPercent = valueToPercent(maxValue);
- if (leftPercent > rightPercent) {
- const temp = leftPercent;
- leftPercent = rightPercent;
- rightPercent = temp;
- }
- const widthPercent = rightPercent - leftPercent;
- trackFill.style.left = leftPercent + "%";
- trackFill.style.width = widthPercent + "%";
- }
- }
-
- function updateHandles(): void {
- const minValue = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
- const maxValue = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
- minHandle!.style.left = valueToPercent(minValue) + "%";
- maxHandle!.style.left = valueToPercent(maxValue) + "%";
- updateTrackFill();
- }
-
- // ── Dragging ──
-
- const makeDraggable = (handle: HTMLElement, isMin: boolean): void => {
- handle.addEventListener("mousedown", (event) => {
- event.preventDefault();
- const rect = track.getBoundingClientRect();
-
- const onMove = (moveEvent: MouseEvent): void => {
- const percent = ((moveEvent.clientX - rect.left) / rect.width) * 100;
- const value = percentToValue(clamp(percent, 0, 100));
-
- if (mode === "point") {
- setTargetValue(minTarget, value);
- setTargetValue(maxTarget, value);
- if (minTarget) minTarget.dispatchEvent(new Event("input", { bubbles: true }));
- if (maxTarget) maxTarget.dispatchEvent(new Event("input", { bubbles: true }));
- } else if (isMin) {
- setTargetValue(
- minTarget,
- clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
- );
- if (minTarget) minTarget.dispatchEvent(new Event("input", { bubbles: true }));
- } else {
- setTargetValue(
- maxTarget,
- clamp(value, getTargetValue(minTarget, dataMin), dataMax)
- );
- if (maxTarget) maxTarget.dispatchEvent(new Event("input", { bubbles: true }));
- }
- updateHandles();
- };
-
- const onUp = (): void => {
- document.removeEventListener("mousemove", onMove);
- document.removeEventListener("mouseup", onUp);
- this.onMouseMove = null;
- this.onMouseUp = null;
- };
-
- this.onMouseMove = onMove;
- this.onMouseUp = onUp;
- document.addEventListener("mousemove", onMove);
- document.addEventListener("mouseup", onUp);
- onMove(event);
- });
- };
-
- makeDraggable(minHandle, true);
- makeDraggable(maxHandle, false);
-
- // ── Sync from number inputs back to handles ──
-
- function syncFromInputs(event?: Event): void {
- if (mode === "point") {
- const source =
- (event?.target as HTMLInputElement | null) || minTarget || maxTarget;
- const value = source ? source.value : "";
- setTargetValue(minTarget, value);
- setTargetValue(maxTarget, value);
- } else if (event && event.target) {
- const minValue = getTargetValue(minTarget, dataMin);
- const maxValue = getTargetValue(maxTarget, dataMax);
- if (event.target === minTarget) {
- if (minValue > maxValue) {
- setTargetValue(maxTarget, minValue);
- }
- } else if (event.target === maxTarget) {
- if (maxValue < minValue) {
- setTargetValue(minTarget, maxValue);
- }
- }
- }
- updateHandles();
- }
-
- function enforceStrictBounds(event: Event): void {
- const target = event.target as HTMLInputElement | null;
- if (target) {
- const value = parseInt(target.value, 10);
- if (!isNaN(value)) {
- const clamped = clamp(value, dataMin, dataMax);
- if (clamped !== value) {
- setTargetValue(target, clamped);
- target.dispatchEvent(new Event("input", { bubbles: true }));
- }
- }
- }
- }
-
- if (minTarget) {
- minTarget.addEventListener("input", syncFromInputs);
- minTarget.addEventListener("change", enforceStrictBounds);
- }
- if (maxTarget) {
- maxTarget.addEventListener("input", syncFromInputs);
- maxTarget.addEventListener("change", enforceStrictBounds);
- }
-
- // ── Mode toggle ──
-
- const toggleButton = this.querySelector(".range-mode-toggle");
- if (toggleButton) {
- toggleButton.addEventListener("click", () => {
- const newMode = mode === "range" ? "point" : "range";
- this.setAttribute("mode", newMode);
-
- // Swap toggle icons
- const iconRange = toggleButton.querySelector(".range-mode-icon-range");
- const iconPoint = toggleButton.querySelector(".range-mode-icon-point");
- if (iconRange) iconRange.classList.toggle("hidden");
- if (iconPoint) iconPoint.classList.toggle("hidden");
-
- const dashSpan = this.querySelector(".range-dash");
- if (newMode === "point") {
- minHandle.style.display = "none";
- setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
- if (minTarget) minTarget.classList.add("hidden");
- if (dashSpan) dashSpan.classList.add("hidden");
- } else {
- minHandle.style.display = "";
- if (minTarget) minTarget.classList.remove("hidden");
- if (dashSpan) dashSpan.classList.remove("hidden");
- }
- mode = newMode;
- updateHandles();
- });
- }
-
- // ── Initial position ──
- updateHandles();
- }
-
- disconnectedCallback(): void {
- if (this.onMouseMove) {
- document.removeEventListener("mousemove", this.onMouseMove);
- this.onMouseMove = null;
- }
- if (this.onMouseUp) {
- document.removeEventListener("mouseup", this.onMouseUp);
- this.onMouseUp = null;
- }
- }
-}
-
-customElements.define("range-slider", RangeSliderElement);