GameStatusSelector: custom element + typed contract (retire Alpine)

The Game status dropdown is now a <game-status-selector> light-DOM custom
element: the Python builder emits the tag + kebab attrs htpy-style, behavior
lives in ts/elements/{dropdown,game-status-selector}.ts wired by the native
connectedCallback, and GameStatusSelectorProps is the codegen'd contract. The
~70-line inline-Alpine f-string is gone.

Also fix SimpleTable to collect and re-attach the media of its row/header
nodes: it stringifies cells into the table markup, which silently dropped each
cell component's declared Media — so a <game-status-selector> in a cell never
got its <script> emitted. Now Page() emits it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:09:52 +02:00
parent 0f0dfc48fb
commit 04552aa8f6
6 changed files with 184 additions and 51 deletions
+49 -48
View File
@@ -209,55 +209,56 @@ def PurchasePrice(purchase) -> Node:
) )
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node: _SELECTOR_MENU_CLASS = (
"""Alpine.js dropdown to change a game's status.""" "absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
options_html = "\n".join( "font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
f"<template x-if=\"status == '{value}'\">" "border border-gray-200 dark:border-gray-700"
f"{GameStatus(status=value, children=[label], display='flex')}" )
f"</template>" _SELECTOR_TOGGLE_CLASS = (
for value, label in game_statuses "relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
) "rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
list_items = "\n".join( "dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); 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': status === '{value}'}}\">"
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
f"</a></li>"
for value, label in game_statuses
)
return Safe(f"""
<div class="flex gap-2 items-center" def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
x-data="{{ """Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
status: '{game.status}', from common.components import custom_element
status_display: '{game.get_status_display()}', from common.components.core import Element
open: false, from common.components.custom_elements import GameStatusSelectorProps
saving: false, from common.components.primitives import Li, Ul
setStatus(newStatus, newStatusDisplay) {{
this.status = newStatus; options = [
this.status_display = newStatusDisplay; Li()[
this.saving = true; Element(
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{ "button",
method: 'PATCH', [("type", "button"), ("data-option", ""), ("data-value", str(value))],
headers: {{ GameStatus(status=value, children=[label], display="flex"),
'Content-Type': 'application/json', )
'X-CSRFToken': '{csrf_token}' ]
}}, for value, label in game_statuses
body: JSON.stringify({{ status: newStatus }}) ]
}}) current_label = Span(data_label="")[
.then(() => {{ GameStatus(
document.body.dispatchEvent(new CustomEvent('status-changed')); status=game.status,
}}) children=[game.get_status_display()],
.catch(() => {{ display="flex",
console.error('Failed to update status'); )
}}) ]
.finally(() => this.saving = false); toggle = Element(
}} "button",
}}"> [("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
{_dropdown_button_html(options_html, list_items)} [current_label, Icon("arrowdown")],
</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(
"game-status-selector",
GameStatusSelectorProps(game_id=game.id, status=game.status, csrf=csrf_token),
children=[Div(class_="flex gap-2 items-center")[dropdown]],
)
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node: def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
+16 -3
View File
@@ -25,6 +25,7 @@ from common.components.core import (
Safe, Safe,
as_attributes, as_attributes,
as_children, as_children,
collect_media,
randomid, randomid,
) )
from common.icons import get_icon from common.icons import get_icon
@@ -988,15 +989,26 @@ def SimpleTable(
columns = columns or [] columns = columns or []
rows = rows or [] rows = rows or []
# Rows/header are stringified into the table markup, so their components'
# declared Media would be lost; collect it from the nodes first and attach
# it to the returned node so Page() still emits each cell component's JS
# (e.g. a <game-status-selector> in a cell).
media = Media()
header_html = "" header_html = ""
if header_action: if header_action:
header_html = str(TableHeader(children=[header_action])) header_node = TableHeader(children=[header_action])
header_html = str(header_node)
media = media + collect_media(header_node)
columns_html = "".join( columns_html = "".join(
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>' f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
for col in columns for col in columns
) )
rows_html = "".join(str(TableRow(data=row)) for row in rows) row_nodes = [TableRow(data=row) for row in rows]
rows_html = "".join(str(node) for node in row_nodes)
for node in row_nodes:
media = media + collect_media(node)
pagination_html = "" pagination_html = ""
if page_obj and elided_page_range: if page_obj and elided_page_range:
@@ -1012,7 +1024,8 @@ def SimpleTable(
f"<tr>{columns_html}</tr></thead>" f"<tr>{columns_html}</tr></thead>"
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">' '<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
f"{rows_html}</tbody></table></div>" f"{rows_html}</tbody></table></div>"
f"{pagination_html}</div>" f"{pagination_html}</div>",
media=media,
) )
+34
View File
@@ -0,0 +1,34 @@
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
@pytest.mark.django_db
def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_server):
from games.models import Game, Platform
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform, status="u")
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
host = page.locator("game-status-selector").first
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()
expect(host.locator("[data-menu]")).to_be_hidden()
game.refresh_from_db()
assert game.status == "f"
+19
View File
@@ -56,3 +56,22 @@ class RegistryTest(unittest.TestCase):
register_element("x-reg-test", "XRegTest", SampleProps) register_element("x-reg-test", "XRegTest", SampleProps)
self.assertEqual(len(ELEMENT_REGISTRY), before + 1) self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test") self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
class GameStatusSelectorRenderTest(unittest.TestCase):
def test_emits_tag_props_and_media(self):
from types import SimpleNamespace
from common.components import GameStatusSelector, collect_media, render
game = SimpleNamespace(id=7, status="f", get_status_display=lambda: "Finished")
node = GameStatusSelector(game, [("u", "Unplayed"), ("f", "Finished")], "tok")
html = render(node)
self.assertIn("<game-status-selector", html)
self.assertIn('game-id="7"', html)
self.assertIn('status="f"', html)
self.assertIn('csrf="tok"', html)
self.assertIn("data-option", html)
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)
+50
View File
@@ -0,0 +1,50 @@
export interface DropdownConfig {
patchUrl: string;
bodyKey: string; // server field name, e.g. "status" or "device_id"
event: string; // dispatched on document.body after a successful PATCH
csrf: string;
numericValue?: boolean; // parse the option value as a number
}
// Wires a light-DOM value-selector dropdown that lives inside `host`.
// Markup hooks (rendered server-side): [data-toggle], [data-menu],
// [data-label], and one or more [data-option][data-value].
export function initDropdown(host: HTMLElement, config: DropdownConfig): void {
const toggle = host.querySelector<HTMLElement>("[data-toggle]");
const menu = host.querySelector<HTMLElement>("[data-menu]");
const label = host.querySelector<HTMLElement>("[data-label]");
if (!toggle || !menu || !label) return;
const close = () => {
menu.hidden = true;
};
toggle.addEventListener("click", (event) => {
event.stopPropagation();
menu.hidden = !menu.hidden;
});
document.addEventListener("click", (event) => {
if (!host.contains(event.target as Node)) close();
});
host.querySelectorAll<HTMLElement>("[data-option]").forEach((option) => {
option.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const raw = option.dataset.value ?? "";
label.innerHTML = option.innerHTML;
close();
const body: Record<string, unknown> = {
[config.bodyKey]: config.numericValue ? Number(raw) : raw,
};
window
.fetchWithHtmxTriggers(config.patchUrl, {
method: "PATCH",
headers: { "Content-Type": "application/json", "X-CSRFToken": config.csrf },
body: JSON.stringify(body),
})
.then(() => document.body.dispatchEvent(new CustomEvent(config.event)))
.catch(() => console.error("Failed to update", config.patchUrl));
});
});
}
+16
View File
@@ -0,0 +1,16 @@
import { readGameStatusSelectorProps } from "../generated/props.js";
import { initDropdown } from "./dropdown.js";
class GameStatusSelectorElement extends HTMLElement {
connectedCallback(): void {
const props = readGameStatusSelectorProps(this);
initDropdown(this, {
patchUrl: `/api/games/${props.gameId}/status`,
bodyKey: "status",
event: "status-changed",
csrf: props.csrf,
});
}
}
customElements.define("game-status-selector", GameStatusSelectorElement);