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:
+49
-48
@@ -209,55 +209,56 @@ def PurchasePrice(purchase) -> Node:
|
||||
)
|
||||
|
||||
|
||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
||||
"""Alpine.js dropdown to change a game's status."""
|
||||
options_html = "\n".join(
|
||||
f"<template x-if=\"status == '{value}'\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex')}"
|
||||
f"</template>"
|
||||
for value, label in game_statuses
|
||||
)
|
||||
list_items = "\n".join(
|
||||
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
|
||||
)
|
||||
_SELECTOR_MENU_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"
|
||||
)
|
||||
_SELECTOR_TOGGLE_CLASS = (
|
||||
"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 "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||
)
|
||||
|
||||
return Safe(f"""
|
||||
<div class="flex gap-2 items-center"
|
||||
x-data="{{
|
||||
status: '{game.status}',
|
||||
status_display: '{game.get_status_display()}',
|
||||
open: false,
|
||||
saving: false,
|
||||
setStatus(newStatus, newStatusDisplay) {{
|
||||
this.status = newStatus;
|
||||
this.status_display = newStatusDisplay;
|
||||
this.saving = true;
|
||||
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
|
||||
method: 'PATCH',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{csrf_token}'
|
||||
}},
|
||||
body: JSON.stringify({{ status: newStatus }})
|
||||
}})
|
||||
.then(() => {{
|
||||
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
||||
}})
|
||||
.catch(() => {{
|
||||
console.error('Failed to update status');
|
||||
}})
|
||||
.finally(() => this.saving = false);
|
||||
}}
|
||||
}}">
|
||||
{_dropdown_button_html(options_html, list_items)}
|
||||
</div>
|
||||
""")
|
||||
|
||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
||||
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
|
||||
from common.components import custom_element
|
||||
from common.components.core import Element
|
||||
from common.components.custom_elements import GameStatusSelectorProps
|
||||
from common.components.primitives import Li, Ul
|
||||
|
||||
options = [
|
||||
Li()[
|
||||
Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-option", ""), ("data-value", str(value))],
|
||||
GameStatus(status=value, children=[label], display="flex"),
|
||||
)
|
||||
]
|
||||
for value, label in game_statuses
|
||||
]
|
||||
current_label = Span(data_label="")[
|
||||
GameStatus(
|
||||
status=game.status,
|
||||
children=[game.get_status_display()],
|
||||
display="flex",
|
||||
)
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||
[current_label, Icon("arrowdown")],
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -25,6 +25,7 @@ from common.components.core import (
|
||||
Safe,
|
||||
as_attributes,
|
||||
as_children,
|
||||
collect_media,
|
||||
randomid,
|
||||
)
|
||||
from common.icons import get_icon
|
||||
@@ -988,15 +989,26 @@ def SimpleTable(
|
||||
columns = columns 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 = ""
|
||||
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(
|
||||
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
||||
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 = ""
|
||||
if page_obj and elided_page_range:
|
||||
@@ -1012,7 +1024,8 @@ def SimpleTable(
|
||||
f"<tr>{columns_html}</tr></thead>"
|
||||
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
||||
f"{rows_html}</tbody></table></div>"
|
||||
f"{pagination_html}</div>"
|
||||
f"{pagination_html}</div>",
|
||||
media=media,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -56,3 +56,22 @@ class RegistryTest(unittest.TestCase):
|
||||
register_element("x-reg-test", "XRegTest", SampleProps)
|
||||
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
|
||||
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)
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user