feat(filters): replace RangeSlider with Stash-style NumberFilter (#85) (#86)
Django CI/CD / build-and-push (push) Has been cancelled
Django CI/CD / test (push) Has been cancelled

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:
2026-06-21 21:31:10 +02:00
committed by GitHub
parent 34563b26d2
commit 9960a8fc3e
16 changed files with 648 additions and 913 deletions
-1
View File
@@ -22,7 +22,6 @@ def _bar_page(filter_json: str = "") -> str:
<head>
<title>Boolean filter E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
-1
View File
@@ -30,7 +30,6 @@ def _bar_page(filter_json: str = "") -> str:
<head>
<title>Date filter E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
-1
View File
@@ -30,7 +30,6 @@ def _bar_page(filter_json: str = "") -> str:
<head>
<title>Date range picker E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/date-range-picker.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
+159
View File
@@ -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"""<!DOCTYPE html>
<html>
<head>
<title>Number filter E2E</title>
<link rel="stylesheet" href="/static/base.css">
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
<body>
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
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()
-114
View File
@@ -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"""<!DOCTYPE html>
<html>
<head>
<title>Range Slider E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
<body>
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
</body>
</html>"""
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
-1
View File
@@ -17,7 +17,6 @@ def _bar_page(filter_json: str = "") -> str:
<head>
<title>String filter E2E</title>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/elements/range-slider.js" type="module"></script>
<script src="/static/js/dist/elements/search-select.js" type="module"></script>
<script src="/static/js/dist/elements/filter-bar.js" type="module"></script>
</head>
+15 -15
View File
@@ -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(