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