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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user