From 48644037f65c6562906a23cf6ed91bfc48c218bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 13 Jun 2026 21:12:46 +0200 Subject: [PATCH] SessionDeviceSelector: custom element; delete Alpine dropdown helper --- common/components/custom_elements.py | 10 +++ common/components/domain.py | 107 ++++++++----------------- e2e/test_custom_elements_e2e.py | 31 ++++++- tests/test_custom_elements.py | 18 +++++ ts/elements/session-device-selector.ts | 17 ++++ 5 files changed, 108 insertions(+), 75 deletions(-) create mode 100644 ts/elements/session-device-selector.ts diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py index 61eec33..48a95ae 100644 --- a/common/components/custom_elements.py +++ b/common/components/custom_elements.py @@ -102,3 +102,13 @@ class GameStatusSelectorProps(TypedDict): register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps) + + +class SessionDeviceSelectorProps(TypedDict): + session_id: int + csrf: str + + +register_element( + "session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps +) diff --git a/common/components/domain.py b/common/components/domain.py index 0e5d598..3baeadb 100644 --- a/common/components/domain.py +++ b/common/components/domain.py @@ -262,79 +262,38 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node: def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node: - """Alpine.js dropdown to change a session's device.""" - device_id = session.device_id or "null" - device_name = (session.device.name if session.device else "Unknown").replace( - "'", "\\'" + """Light-DOM custom element; behavior in ts/elements/session-device-selector.ts.""" + from common.components import custom_element + from common.components.core import Element + from common.components.custom_elements import SessionDeviceSelectorProps + from common.components.primitives import Li, Ul + + current_name = session.device.name if session.device else "Unknown" + options = [ + Li()[ + Element( + "button", + [ + ("type", "button"), + ("data-option", ""), + ("data-value", str(device.id)), + ], + children=[device.name], + ) + ] + for device in session_devices + ] + toggle = Element( + "button", + [("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)], + [Span(data_label="")[current_name], Icon("arrowdown")], ) - - list_items = "\n".join( - f'
  • {d.name}
  • " - for d in session_devices - ) - - return Safe(f""" -
    - { - _dropdown_button_html( - '' + str(Icon("arrowdown")), list_items - ) - } -
    -""") - - -def _dropdown_button_html(button_content: str, list_items: str) -> str: - """Shared dropdown button + list structure for Alpine.js selectors.""" - return ( - '
    ' - '" - "
    " + menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]] + dropdown = Div( + data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative" + )[toggle, menu] + return custom_element( + "session-device-selector", + SessionDeviceSelectorProps(session_id=session.id, csrf=csrf_token), + children=[Div(class_="flex gap-2 items-center")[dropdown]], ) diff --git a/e2e/test_custom_elements_e2e.py b/e2e/test_custom_elements_e2e.py index 1fbf2da..c5c8782 100644 --- a/e2e/test_custom_elements_e2e.py +++ b/e2e/test_custom_elements_e2e.py @@ -28,7 +28,36 @@ def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_s expect(host).to_be_attached() host.locator("[data-toggle]").click() expect(host.locator("[data-menu]")).to_be_visible() - host.locator('[data-option][data-value="f"]').click() + with page.expect_response( + lambda r: "/status" in r.url and r.request.method == "PATCH" + ): + host.locator('[data-option][data-value="f"]').click() expect(host.locator("[data-menu]")).to_be_hidden() game.refresh_from_db() assert game.status == "f" + + +@pytest.mark.django_db +def test_session_device_selector_patches(authenticated_page: Page, live_server): + from games.models import Device, Game, Platform, Session + + platform = Platform.objects.create(name="PC", icon="pc") + game = Game.objects.create(name="Test Game", platform=platform) + desktop = Device.objects.create(name="Desktop") + deck = Device.objects.create(name="Deck") + session = Session.objects.create( + game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00" + ) + + page = authenticated_page + page.goto(f"{live_server.url}{reverse('games:list_sessions')}") + + host = page.locator("session-device-selector").first + expect(host).to_be_attached() + host.locator("[data-toggle]").click() + with page.expect_response( + lambda r: "/device" in r.url and r.request.method == "PATCH" + ): + host.locator(f'[data-option][data-value="{deck.id}"]').click() + session.refresh_from_db() + assert session.device_id == deck.id diff --git a/tests/test_custom_elements.py b/tests/test_custom_elements.py index b2e26d8..23710dc 100644 --- a/tests/test_custom_elements.py +++ b/tests/test_custom_elements.py @@ -75,3 +75,21 @@ class GameStatusSelectorRenderTest(unittest.TestCase): self.assertIn('data-value="u"', html) self.assertNotIn("x-data", html) # no Alpine left self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js) + + +class SessionDeviceSelectorRenderTest(unittest.TestCase): + def test_emits_tag_and_options(self): + from types import SimpleNamespace + + from common.components import SessionDeviceSelector, render + + session = SimpleNamespace(id=4, device=SimpleNamespace(name="Desktop")) + devices = [ + SimpleNamespace(id=1, name="Desktop"), + SimpleNamespace(id=2, name="Deck"), + ] + html = render(SessionDeviceSelector(session, devices, "tok")) + self.assertIn("