9960a8fc3e
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>
213 lines
7.0 KiB
Python
213 lines
7.0 KiB
Python
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
|
|
|
|
These cover the new machinery directly: rendering, escaping, media bubbling.
|
|
"""
|
|
|
|
import unittest
|
|
|
|
from django.utils.safestring import mark_safe
|
|
|
|
from common.components import (
|
|
BaseComponent,
|
|
Element,
|
|
Fragment,
|
|
Media,
|
|
Node,
|
|
Safe,
|
|
collect_media,
|
|
render,
|
|
)
|
|
|
|
|
|
class ElementRenderTest(unittest.TestCase):
|
|
def test_renders_tag_attrs_children(self):
|
|
element = Element("div", [("class", "test")], "hello")
|
|
self.assertEqual(render(element), '<div class="test">hello</div>')
|
|
|
|
def test_plain_string_children_escaped(self):
|
|
self.assertEqual(
|
|
render(Element("span", children=["<b>"])), "<span><b></span>"
|
|
)
|
|
|
|
def test_safe_node_child_passes_through(self):
|
|
self.assertEqual(
|
|
render(Element("span", children=[Safe("<b>x</b>")])),
|
|
"<span><b>x</b></span>",
|
|
)
|
|
|
|
def test_safetext_child_is_escaped(self):
|
|
# A string child is always escaped — even a mark_safe/SafeText one.
|
|
# Trusted markup must be a Safe node, not a safe string.
|
|
self.assertEqual(
|
|
render(Element("span", children=[mark_safe("<b>x</b>")])),
|
|
"<span><b>x</b></span>",
|
|
)
|
|
|
|
def test_node_children_render_safely(self):
|
|
inner = Element("b", children=["x"])
|
|
self.assertEqual(
|
|
render(Element("span", children=[inner])), "<span><b>x</b></span>"
|
|
)
|
|
|
|
|
|
class SafeAndFragmentTest(unittest.TestCase):
|
|
def test_safe_passes_html_through(self):
|
|
self.assertEqual(render(Safe("<i>raw</i>")), "<i>raw</i>")
|
|
|
|
def test_fragment_concatenates(self):
|
|
frag = Fragment(
|
|
Element("span", children=["a"]), Element("span", children=["b"])
|
|
)
|
|
self.assertEqual(render(frag), "<span>a</span><span>b</span>")
|
|
|
|
def test_fragment_skips_empty_children(self):
|
|
frag = Fragment("", None, Element("span", children=["a"]))
|
|
self.assertEqual(render(frag), "<span>a</span>")
|
|
|
|
def test_fragment_escapes_plain_strings(self):
|
|
self.assertEqual(render(Fragment("<x>", Safe("<y>"))), "<x><y>")
|
|
|
|
|
|
class MediaTest(unittest.TestCase):
|
|
def test_merge_dedups_preserving_order(self):
|
|
merged = Media(js=["a.js", "b.js"]) + Media(js=["b.js", "c.js"])
|
|
self.assertEqual(merged.js, ("a.js", "b.js", "c.js"))
|
|
|
|
def test_external_kept_separate(self):
|
|
merged = Media(js=["a.js"]) + Media(js_external=["umd.js"])
|
|
self.assertEqual(merged.js, ("a.js",))
|
|
self.assertEqual(merged.js_external, ("umd.js",))
|
|
|
|
def test_sum_with_radd(self):
|
|
merged = sum([Media(js=["a.js"]), Media(js=["b.js"])], Media())
|
|
self.assertEqual(merged.js, ("a.js", "b.js"))
|
|
|
|
def test_falsy_when_empty(self):
|
|
self.assertFalse(Media())
|
|
self.assertTrue(Media(js=["a.js"]))
|
|
|
|
|
|
class MediaCollectionTest(unittest.TestCase):
|
|
def test_bubbles_through_element_children(self):
|
|
class Widget(BaseComponent):
|
|
media = Media(js=["widget.js"])
|
|
|
|
def render(self) -> Node:
|
|
return Element("div", children=["x"])
|
|
|
|
tree = Element("section", children=[Element("div", children=[Widget()])])
|
|
self.assertEqual(collect_media(tree).js, ("widget.js",))
|
|
|
|
def test_bubbles_through_fragment(self):
|
|
class Widget(BaseComponent):
|
|
media = Media(js=["w.js"])
|
|
|
|
def render(self) -> Node:
|
|
return Element("div")
|
|
|
|
self.assertEqual(collect_media(Fragment(Widget(), Element("p"))).js, ("w.js",))
|
|
|
|
def test_component_merges_own_and_subtree_media(self):
|
|
class Inner(BaseComponent):
|
|
media = Media(js=["inner.js"])
|
|
|
|
def render(self) -> Node:
|
|
return Element("span")
|
|
|
|
class Outer(BaseComponent):
|
|
media = Media(js=["outer.js"])
|
|
|
|
def render(self) -> Node:
|
|
return Element("div", children=[Inner()])
|
|
|
|
self.assertEqual(collect_media(Outer()).js, ("outer.js", "inner.js"))
|
|
|
|
def test_bare_string_has_no_media(self):
|
|
self.assertFalse(collect_media("just a string"))
|
|
|
|
|
|
class RealComponentMediaTest(unittest.TestCase):
|
|
"""Phase 3: JS-bearing components declare media that bubbles up the tree."""
|
|
|
|
def test_search_select_declares_its_script(self):
|
|
from common.components import SearchSelect
|
|
|
|
self.assertEqual(
|
|
collect_media(SearchSelect(name="games")).js,
|
|
("dist/elements/search-select.js",),
|
|
)
|
|
|
|
def test_filter_select_declares_its_script(self):
|
|
from common.components import FilterSelect
|
|
|
|
self.assertIn(
|
|
"dist/elements/search-select.js",
|
|
collect_media(FilterSelect(field_name="type")).js,
|
|
)
|
|
|
|
def test_date_range_picker_declares_its_script(self):
|
|
from common.components import DateRangePicker
|
|
|
|
media = collect_media(
|
|
DateRangePicker(label="Played", input_name_prefix="played")
|
|
)
|
|
self.assertEqual(media.js, ("dist/elements/date-range-picker.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 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)
|
|
|
|
|
|
class HtpyStyleSugarTest(unittest.TestCase):
|
|
def test_getitem_sets_children(self):
|
|
from common.components import Div, Span
|
|
|
|
self.assertEqual(
|
|
render(Div(class_="card")[Span()["hi"]]),
|
|
'<div class="card"><span>hi</span></div>',
|
|
)
|
|
|
|
def test_getitem_multiple_children(self):
|
|
from common.components import Div
|
|
|
|
self.assertEqual(render(Div()["a", "b"]), "<div>a\nb</div>")
|
|
|
|
def test_kwargs_class_underscore_becomes_class(self):
|
|
from common.components import Div
|
|
|
|
self.assertIn('class="x"', render(Div(class_="x")))
|
|
|
|
def test_kwargs_inner_underscore_becomes_hyphen(self):
|
|
from common.components import Div
|
|
|
|
self.assertIn('hx-get="/y"', render(Div(hx_get="/y")))
|
|
|
|
def test_kwargs_true_renders_bare_attr(self):
|
|
from common.components import Div
|
|
|
|
self.assertIn('hidden="hidden"', render(Div(hidden=True)))
|
|
|
|
def test_kwargs_false_and_none_omitted(self):
|
|
from common.components import Div
|
|
|
|
html = render(Div(hidden=False, title=None))
|
|
self.assertNotIn("hidden", html)
|
|
self.assertNotIn("title", html)
|
|
|
|
def test_getitem_preserves_media(self):
|
|
from common.components import Div, Media, collect_media
|
|
|
|
node = Div(class_="x").with_media(Media(js=("a.js",)))["child"]
|
|
self.assertEqual(collect_media(node).js, ("a.js",))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|