SessionDeviceSelector: custom element; delete Alpine dropdown helper

This commit is contained in:
2026-06-13 21:12:46 +02:00
parent 04552aa8f6
commit 48644037f6
5 changed files with 108 additions and 75 deletions
+10
View File
@@ -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
)
+33 -74
View File
@@ -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'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
for d in session_devices
)
return Safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
originalDeviceId: {device_id},
originalDeviceName: '{device_name}',
deviceId: {device_id},
deviceName: '{device_name}',
open: false,
saving: false,
setDevice(newDeviceId, newDeviceName) {{
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ device_id: newDeviceId }})
}})
.then((res) => {{
document.body.dispatchEvent(new CustomEvent('device-changed'));
}})
.catch(() => {{
this.deviceName = this.originalDeviceName;
this.deviceId = this.originalDeviceId;
console.error('Failed to update device');
}})
.finally(() => this.saving = false);
}}
}}">
{
_dropdown_button_html(
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
)
}
</div>
""")
def _dropdown_button_html(button_content: str, list_items: str) -> str:
"""Shared dropdown button + list structure for Alpine.js selectors."""
return (
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
'<button type="button" @click="open = !open" '
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
"rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
'dark:focus:text-white align-middle hover:cursor-pointer">'
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
'<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm '
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border "
'border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">'
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
f"{list_items}"
"</ul>"
"</div>"
"</button>"
"</div>"
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]],
)
+30 -1
View File
@@ -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
+18
View File
@@ -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("<session-device-selector", html)
self.assertIn('session-id="4"', html)
self.assertIn('data-value="2"', html)
self.assertNotIn("x-data", html)
+17
View File
@@ -0,0 +1,17 @@
import { readSessionDeviceSelectorProps } from "../generated/props.js";
import { initDropdown } from "./dropdown.js";
class SessionDeviceSelectorElement extends HTMLElement {
connectedCallback(): void {
const props = readSessionDeviceSelectorProps(this);
initDropdown(this, {
patchUrl: `/api/session/${props.sessionId}/device`,
bodyKey: "device_id",
event: "device-changed",
csrf: props.csrf,
numericValue: true,
});
}
}
customElements.define("session-device-selector", SessionDeviceSelectorElement);