Numeric range filters could only express BETWEEN/GREATER_THAN/LESS_THAN via the RangeSlider widget — no way to match NULL/missing values (the original ask in #32) or exact/not-between. The criteria backend already supported all 8 numeric modifiers + value2, so this is a UI swap. - Add NumberFilter component, modeled 1:1 on StringFilter: an 8-modifier radio grid plus two number inputs, with the second input revealed only for BETWEEN/NOT_BETWEEN and both disabled for IS_NULL/NOT_NULL. Initial state is server-rendered so the widget never flashes. - Migrate all 17 numeric range fields (game/session/purchase/playevent) to NumberFilter; drop the now-dead min/max aggregate queries. - filter-bar.ts: serialize numberFields by modifier (mirroring textFields) and wire the modifier radios via event delegation on the persistent custom element so they survive htmx swaps of the inner bar body. Apply the same delegation fix to the existing string filters. - Remove RangeSlider entirely: component, range-slider.ts, its custom element registration/props, and the range-slider e2e tests. Backward compatible: old slider filters stored {value, value2, modifier}, the same JSON shape NumberFilter reads, so saved presets keep working. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+102
-36
@@ -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 <div> handles, a track fill, and mode-toggle button."""
|
||||
self.assertIn("<range-slider", html)
|
||||
self.assertIn('mode="range"', html)
|
||||
self.assertIn("range-mode-toggle", html)
|
||||
self.assertIn("range-mode-icon-range", html)
|
||||
self.assertIn("range-mode-icon-point", html)
|
||||
self.assertIn("range-track-fill", html)
|
||||
self.assertIn("range-handle-min", html)
|
||||
self.assertIn("range-handle-max", html)
|
||||
# No native range inputs
|
||||
self.assertNotIn(
|
||||
'<input type="range"',
|
||||
html,
|
||||
"native <input type=range> 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("<range-slider", html)
|
||||
self.assertNotIn("range-mode-toggle", html)
|
||||
|
||||
def test_game_filter_bar(self):
|
||||
html = str(
|
||||
@@ -68,7 +64,7 @@ class FilterBarRenderingTest(TestCase):
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/games/list", "/presets/games/save")
|
||||
self._assert_range_slider(html)
|
||||
self._assert_number_filter(html, "filter-year")
|
||||
|
||||
def test_session_filter_bar(self):
|
||||
html = str(
|
||||
@@ -79,7 +75,7 @@ class FilterBarRenderingTest(TestCase):
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/sessions/list", "/presets/sessions/save")
|
||||
self._assert_range_slider(html)
|
||||
self._assert_number_filter(html, "filter-duration-total-hours")
|
||||
|
||||
def test_purchase_filter_bar(self):
|
||||
html = str(
|
||||
@@ -90,7 +86,7 @@ class FilterBarRenderingTest(TestCase):
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
|
||||
self._assert_range_slider(html)
|
||||
self._assert_number_filter(html, "filter-price")
|
||||
|
||||
def test_purchase_filter_bar_games_has_m2m_modifiers(self):
|
||||
"""The many-to-many games field surfaces (All)/(Only) pseudo-options
|
||||
@@ -277,8 +273,8 @@ class FilterBarRenderingTest(TestCase):
|
||||
)
|
||||
|
||||
def test_playevent_filter_bar_labels_days_to_finish_slider(self):
|
||||
"""The Days to Finish range slider must be wrapped in a labelled field —
|
||||
RangeSlider does not render its own label, so a bare slider shows none."""
|
||||
"""The Days to Finish NumberFilter must be wrapped in a labelled field —
|
||||
NumberFilter does not render its own label, so a bare widget shows none."""
|
||||
from common.components import PlayEventFilterBar
|
||||
|
||||
html = str(
|
||||
@@ -309,14 +305,16 @@ class FilterBarRenderingTest(TestCase):
|
||||
# Free-text widget for playevent notes (now StringFilter)
|
||||
self.assertIn('name="filter-playevent_note"', html)
|
||||
self.assertIn('name="filter-playevent_note-modifier"', html)
|
||||
# New range slider input prefixes
|
||||
self.assertIn('name="filter-purchase-count-min"', html)
|
||||
self.assertIn('name="filter-playevent-count-min"', html)
|
||||
self.assertIn('name="filter-manual-playtime-hours-min"', html)
|
||||
self.assertIn('name="filter-calculated-playtime-hours-min"', html)
|
||||
self.assertIn('name="filter-original-year-min"', html)
|
||||
self.assertIn('name="filter-purchase-price-total-min"', html)
|
||||
self.assertIn('name="filter-purchase-price-any-min"', html)
|
||||
# New NumberFilter input prefixes (value input named by bare prefix)
|
||||
self.assertIn('name="filter-purchase-count"', html)
|
||||
self.assertIn('name="filter-purchase-count-value2"', html)
|
||||
self.assertIn('name="filter-playevent-count"', html)
|
||||
self.assertIn('name="filter-manual-playtime-hours"', html)
|
||||
self.assertIn('name="filter-calculated-playtime-hours"', html)
|
||||
self.assertIn('name="filter-original-year"', html)
|
||||
self.assertIn('name="filter-purchase-price-total"', html)
|
||||
self.assertIn('name="filter-purchase-price-any"', html)
|
||||
self.assertIn('name="filter-purchase-count-modifier"', html)
|
||||
# New boolean checkboxes
|
||||
self.assertIn('name="filter-purchase-refunded"', html)
|
||||
self.assertIn('name="filter-purchase-infinite"', html)
|
||||
@@ -428,3 +426,71 @@ class FilterBarRenderingTest(TestCase):
|
||||
self.assertIn('name="filter-refunded"', purchase_html)
|
||||
self.assertIn('value="true"', purchase_html)
|
||||
self.assertIn('value="false"', purchase_html)
|
||||
|
||||
|
||||
class NumberFilterRenderTest(TestCase):
|
||||
"""Render-level contract for the Stash-style NumberFilter component."""
|
||||
|
||||
def test_renders_all_eight_modifier_radios(self):
|
||||
html = str(NumberFilter(input_name_prefix="filter-year"))
|
||||
for modifier in (
|
||||
"EQUALS",
|
||||
"NOT_EQUALS",
|
||||
"GREATER_THAN",
|
||||
"LESS_THAN",
|
||||
"BETWEEN",
|
||||
"NOT_BETWEEN",
|
||||
"IS_NULL",
|
||||
"NOT_NULL",
|
||||
):
|
||||
self.assertIn(f'value="{modifier}"', html)
|
||||
self.assertIn("data-number-modifier-radio", html)
|
||||
|
||||
def test_renders_two_number_inputs(self):
|
||||
html = str(NumberFilter(input_name_prefix="filter-year"))
|
||||
self.assertIn('type="number"', html)
|
||||
self.assertIn('name="filter-year"', html)
|
||||
self.assertIn('name="filter-year-value2"', html)
|
||||
self.assertIn("data-number-value2", html)
|
||||
|
||||
def test_default_modifier_hides_second_input_and_enables_inputs(self):
|
||||
html = str(NumberFilter(input_name_prefix="filter-year"))
|
||||
# value2 is hidden for the default EQUALS modifier.
|
||||
self.assertRegex(html, r'data-number-value2="" class="[^"]*\bhidden\b')
|
||||
# Inputs are not disabled by default.
|
||||
self.assertNotIn("disabled", html)
|
||||
|
||||
def test_between_shows_second_input_and_prefills_values(self):
|
||||
html = str(
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-year",
|
||||
value="2000",
|
||||
value2="2010",
|
||||
modifier="BETWEEN",
|
||||
)
|
||||
)
|
||||
self.assertIn('value="2000"', html)
|
||||
self.assertIn('value="2010"', html)
|
||||
# The second input must NOT carry the hidden class under BETWEEN.
|
||||
self.assertNotRegex(html, r'data-number-value2="" class="[^"]*\bhidden\b')
|
||||
|
||||
def test_presence_modifier_disables_and_clears_inputs(self):
|
||||
html = str(
|
||||
NumberFilter(
|
||||
input_name_prefix="filter-year",
|
||||
value="2000",
|
||||
value2="2010",
|
||||
modifier="IS_NULL",
|
||||
)
|
||||
)
|
||||
self.assertIn("disabled", html)
|
||||
self.assertIn("cursor-not-allowed", html)
|
||||
# Values are cleared while disabled.
|
||||
self.assertNotIn('value="2000"', html)
|
||||
self.assertNotIn('value="2010"', html)
|
||||
|
||||
def test_invalid_modifier_falls_back_to_equals(self):
|
||||
html = str(NumberFilter(input_name_prefix="filter-year", modifier="INCLUDES"))
|
||||
# EQUALS is the only checked radio when an invalid modifier is given.
|
||||
self.assertRegex(html, r'value="EQUALS"[^>]*checked="true"')
|
||||
self.assertNotRegex(html, r'value="INCLUDES"')
|
||||
|
||||
@@ -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):
|
||||
|
||||
+3
-14
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user