SessionDeviceSelector: custom element; delete Alpine dropdown helper
This commit is contained in:
@@ -102,3 +102,13 @@ class GameStatusSelectorProps(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
|
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDeviceSelectorProps(TypedDict):
|
||||||
|
session_id: int
|
||||||
|
csrf: str
|
||||||
|
|
||||||
|
|
||||||
|
register_element(
|
||||||
|
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
|
||||||
|
)
|
||||||
|
|||||||
+33
-74
@@ -262,79 +262,38 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
|||||||
|
|
||||||
|
|
||||||
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
||||||
"""Alpine.js dropdown to change a session's device."""
|
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
|
||||||
device_id = session.device_id or "null"
|
from common.components import custom_element
|
||||||
device_name = (session.device.name if session.device else "Unknown").replace(
|
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")],
|
||||||
)
|
)
|
||||||
|
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||||
list_items = "\n".join(
|
dropdown = Div(
|
||||||
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
|
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||||
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
)[toggle, menu]
|
||||||
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
return custom_element(
|
||||||
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
|
"session-device-selector",
|
||||||
for d in session_devices
|
SessionDeviceSelectorProps(session_id=session.id, csrf=csrf_token),
|
||||||
)
|
children=[Div(class_="flex gap-2 items-center")[dropdown]],
|
||||||
|
|
||||||
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>"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,36 @@ def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_s
|
|||||||
expect(host).to_be_attached()
|
expect(host).to_be_attached()
|
||||||
host.locator("[data-toggle]").click()
|
host.locator("[data-toggle]").click()
|
||||||
expect(host.locator("[data-menu]")).to_be_visible()
|
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()
|
expect(host.locator("[data-menu]")).to_be_hidden()
|
||||||
game.refresh_from_db()
|
game.refresh_from_db()
|
||||||
assert game.status == "f"
|
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
|
||||||
|
|||||||
@@ -75,3 +75,21 @@ class GameStatusSelectorRenderTest(unittest.TestCase):
|
|||||||
self.assertIn('data-value="u"', html)
|
self.assertIn('data-value="u"', html)
|
||||||
self.assertNotIn("x-data", html) # no Alpine left
|
self.assertNotIn("x-data", html) # no Alpine left
|
||||||
self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js)
|
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)
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user