Convert onSwap widgets to custom elements (issue #18)

Replaces the four onSwap-based widgets with TypeScript custom elements
following the pattern from PR #16. Each widget gets a class extending
HTMLElement with connectedCallback/disconnectedCallback, typed props via
register_element + gen_element_types codegen, and lives in ts/elements/.

- range-slider: RangeSliderElement; Python uses _RangeSlider builder
- date-range-picker: DateRangePickerElement; Python uses _DateRangePicker builder
- search-select: SearchSelectElement; Python uses _SearchSelect builder;
  data-* attrs become plain attrs (data-name -> name, data-search-url -> search-url, etc.)
- filter-bar: FilterBarElement; props carry preset URLs; onclick/onsubmit
  attrs replaced with data-filter-bar-* sentinel attrs; all window.* globals removed

Deletes ts/range_slider.ts, ts/search_select.ts, ts/date_range_picker.ts,
ts/filter_bar.ts. Updates all tests and e2e pages to use the new element
selectors and script paths (dist/elements/<tag>.js).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 14:22:59 +02:00
parent 4652f1ff55
commit 82416e149d
33 changed files with 2301 additions and 2168 deletions
+2 -2
View File
@@ -145,7 +145,7 @@ class DateRangePickerTest(SimpleTestCase):
max_value="2024-12-31",
)
)
self.assertIn("data-date-range-picker", html)
self.assertIn("<date-range-picker", html)
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
self.assertIn("data-date-range-field", html)
self.assertIn("data-date-range-calendar", html)
@@ -166,7 +166,7 @@ class PurchaseFilterBarDateRangePickerTest(TestCase):
def test_purchased_uses_date_range_picker(self):
html = self.render()
self.assertIn("data-date-range-picker", html)
self.assertIn("<date-range-picker", html)
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
# The hidden ISO inputs keep the names filter_bar.js serializes.
self.assertIn('name="filter-date-purchased-min"', html)
+9 -9
View File
@@ -44,8 +44,8 @@ class FilterBarRenderingTest(TestCase):
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-block", html)
self.assertIn('data-mode="range"', html)
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)
@@ -107,8 +107,8 @@ class FilterBarRenderingTest(TestCase):
# No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html)
# Platform is single-valued: no M2M modifier options in its section.
games_start = html.find('data-name="games"')
platform_start = html.find('data-name="platform"')
games_start = html.find('name="games"')
platform_start = html.find('name="platform"')
platform_section = html[platform_start:]
self.assertNotIn("INCLUDES_ALL", platform_section)
self.assertGreater(games_start, 0)
@@ -150,7 +150,7 @@ class FilterBarRenderingTest(TestCase):
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
)
)
self.assertIn('data-search-select-mode="filter"', html)
self.assertIn('filter-mode="true"', html)
self.assertIn(
'data-search-select-type="include"', html
) # rendered as an include pill
@@ -235,11 +235,11 @@ class FilterBarRenderingTest(TestCase):
)
)
# New search-backed selects
self.assertIn('data-search-url="/api/devices/search"', html)
self.assertIn('data-search-url="/api/platforms/groups"', html)
self.assertIn('search-url="/api/devices/search"', html)
self.assertIn('search-url="/api/platforms/groups"', html)
# New enum selects (purchase type / ownership)
self.assertIn('data-name="purchase_type"', html)
self.assertIn('data-name="purchase_ownership_type"', html)
self.assertIn('name="purchase_type"', html)
self.assertIn('name="purchase_ownership_type"', html)
# Free-text widget for playevent notes (now StringFilter)
self.assertIn('name="filter-playevent_note"', html)
self.assertIn('name="filter-playevent_note-modifier"', html)
+6 -6
View File
@@ -555,8 +555,8 @@ class TestFilterBarRendering:
def test_status_uses_filter_select(self):
html = str(FilterBar())
assert 'data-search-select-mode="filter"' in html
assert 'data-name="status"' in html
assert 'filter-mode="true"' in html
assert 'name="status"' in html
def test_mastered_not_checked_by_default(self):
html = str(FilterBar(filter_json=""))
@@ -602,13 +602,13 @@ class TestFilterBarRendering:
def test_platform_uses_search_url(self):
"""Platform is model-backed: rows are fetched, not pre-rendered."""
html = str(FilterBar())
assert 'data-search-url="/api/platforms/search"' in html
assert 'search-url="/api/platforms/search"' in html
def test_status_has_no_modifiers(self):
"""Non-nullable fields should not show (None) but MUST show (Any)."""
html = str(FilterBar())
status_start = html.find('data-name="status"')
platform_start = html.find('data-name="platform"')
status_start = html.find('name="status"')
platform_start = html.find('name="platform"')
status_section = html[status_start:platform_start]
# Must have (Any) — always available
assert "(Any)" in status_section
@@ -618,7 +618,7 @@ class TestFilterBarRendering:
def test_platform_has_modifiers(self):
"""Nullable ForeignKey fields should show (Any)/(None)."""
html = str(FilterBar())
platform_start = html.find('data-name="platform"')
platform_start = html.find('name="platform"')
platform_section = html[platform_start:]
# Should have at least one modifier option
assert "(Any)" in platform_section or "(None)" in platform_section
+9 -7
View File
@@ -133,14 +133,16 @@ class RealComponentMediaTest(unittest.TestCase):
from common.components import SearchSelect
self.assertEqual(
collect_media(SearchSelect(name="games")).js, ("dist/search_select.js",)
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/search_select.js", collect_media(FilterSelect(field_name="type")).js
"dist/elements/search-select.js",
collect_media(FilterSelect(field_name="type")).js,
)
def test_date_range_picker_declares_its_script(self):
@@ -149,7 +151,7 @@ class RealComponentMediaTest(unittest.TestCase):
media = collect_media(
DateRangePicker(label="Played", input_name_prefix="played")
)
self.assertEqual(media.js, ("dist/date_range_picker.js",))
self.assertEqual(media.js, ("dist/elements/date-range-picker.js",))
def test_range_slider_declares_its_script(self):
from common.components.filters import RangeSlider
@@ -159,7 +161,7 @@ class RealComponentMediaTest(unittest.TestCase):
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
)
)
self.assertEqual(media.js, ("dist/range_slider.js",))
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
@@ -169,9 +171,9 @@ class RealComponentMediaTest(unittest.TestCase):
from common.components import FilterBar
media = collect_media(FilterBar())
self.assertIn("dist/filter_bar.js", media.js)
self.assertIn("dist/search_select.js", media.js)
self.assertIn("dist/range_slider.js", media.js)
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):
+3 -3
View File
@@ -63,9 +63,9 @@ class RenderedPagesTest(TestCase):
"""The games list view passes no scripts= argument; the filter bar's
components declare their JS and Page() collects it."""
html = self.get("games:list_games").content.decode()
self.assertIn("js/dist/filter_bar.js", html)
self.assertIn("js/dist/search_select.js", html)
self.assertIn("js/dist/range_slider.js", html)
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
+18 -19
View File
@@ -64,10 +64,10 @@ class SearchSelectComponentTest(unittest.TestCase):
name="games", search_url="/api/games/search", multi_select=True
)
)
self.assertIn("data-search-select", html)
self.assertIn('data-name="games"', html)
self.assertIn('data-search-url="/api/games/search"', html)
self.assertIn('data-multi="true"', html)
self.assertIn("<search-select", html)
self.assertIn('name="games"', html)
self.assertIn('search-url="/api/games/search"', html)
self.assertIn('multi="true"', html)
def test_multi_selected_renders_pills_and_hidden_inputs(self):
html = str(
@@ -80,9 +80,8 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertIn("data-pill", html)
self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertIn('data-platform="2"', html)
# exactly one submitted value (the hidden input) — the search box has no
# name. The leading space avoids matching the container's data-name.
self.assertEqual(html.count(' name="games"'), 1)
# two occurrences: the <search-select name="games"> tag + the hidden input.
self.assertEqual(html.count(' name="games"'), 2)
def test_single_selected_has_no_pill_and_value_in_search_box(self):
html = str(
@@ -96,13 +95,13 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertIn('value="Game A"', html)
# the value is still submitted via a lone hidden input
self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertEqual(html.count(' name="games"'), 1)
self.assertEqual(html.count(' name="games"'), 2)
def test_search_box_has_no_name(self):
html = str(SearchSelect(name="games"))
self.assertIn("data-search-select-search", html)
# container exposes data-name, never a submittable name on the search box
self.assertEqual(html.count(' name="games"'), 0)
# <search-select name="games"> is the tag; the search box carries no name
self.assertEqual(html.count(' name="games"'), 1)
def test_tuple_options_are_normalized(self):
html = str(SearchSelect(name="t", options=[("1", "One")]))
@@ -149,11 +148,11 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_prefetch_attribute_and_defaults(self):
# Default prefetch is 0 in SearchSelect
html_default = str(SearchSelect(name="t"))
self.assertIn('data-prefetch="0"', html_default)
self.assertIn('prefetch="0"', html_default)
# Custom prefetch is rendered
html_custom = str(SearchSelect(name="t", prefetch=42))
self.assertIn('data-prefetch="42"', html_custom)
self.assertIn('prefetch="42"', html_custom)
class FilterSelectComponentTest(unittest.TestCase):
@@ -164,12 +163,12 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_is_filter_mode_on_shared_shell(self):
html = str(FilterSelect(field_name="type"))
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
self.assertIn("data-search-select", html)
self.assertIn('data-search-select-mode="filter"', html)
self.assertIn('data-name="type"', html)
# No name is submitted — state is read from the DOM into the filter JSON.
self.assertEqual(html.count(' name="type"'), 0)
# FilterSelect is a <search-select> with filter-mode="true".
self.assertIn("<search-select", html)
self.assertIn('filter-mode="true"', html)
self.assertIn('name="type"', html)
# <search-select name="type"> carries the name; state is read from DOM into filter JSON.
self.assertEqual(html.count(' name="type"'), 1)
def test_value_rows_have_include_exclude_buttons(self):
html = str(FilterSelect(field_name="type", options=[("g", "Game")]))
@@ -238,7 +237,7 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertIn(
'data-search-select-modifier-option="NOT_NULL"', html
) # still pinned
self.assertIn('data-prefetch="20"', html)
self.assertIn('prefetch="20"', html)
def test_search_url_pills_use_resolved_labels(self):
# A selected value outside the fetched window still shows its label.