From 1258c529d250e2638964d6cdb943858421ffab52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sat, 13 Jun 2026 21:15:49 +0200 Subject: [PATCH] played-row: custom element; delete @@TOKEN@@ template + Alpine --- common/components/custom_elements.py | 9 ++ e2e/test_custom_elements_e2e.py | 21 +++++ games/views/game.py | 125 ++++++++++++++------------- tests/test_rendered_pages.py | 10 ++- ts/elements/play-event-row.ts | 42 +++++++++ 5 files changed, 145 insertions(+), 62 deletions(-) create mode 100644 ts/elements/play-event-row.ts diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py index 48a95ae..0b42c7c 100644 --- a/common/components/custom_elements.py +++ b/common/components/custom_elements.py @@ -112,3 +112,12 @@ class SessionDeviceSelectorProps(TypedDict): register_element( "session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps ) + + +class PlayEventRowProps(TypedDict): + game_id: int + csrf: str + api_create_url: str + + +register_element("play-event-row", "PlayEventRow", PlayEventRowProps) diff --git a/e2e/test_custom_elements_e2e.py b/e2e/test_custom_elements_e2e.py index c5c8782..9488d40 100644 --- a/e2e/test_custom_elements_e2e.py +++ b/e2e/test_custom_elements_e2e.py @@ -61,3 +61,24 @@ def test_session_device_selector_patches(authenticated_page: Page, live_server): host.locator(f'[data-option][data-value="{deck.id}"]').click() session.refresh_from_db() assert session.device_id == deck.id + + +@pytest.mark.django_db +def test_play_event_row_increments(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) + + page = authenticated_page + page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}") + + host = page.locator("play-event-row").first + expect(host).to_be_attached() + host.locator("[data-toggle]").click() + with page.expect_response( + lambda r: "playevent" in r.url.lower() and r.request.method == "POST" + ): + host.locator("[data-add-play]").click() + expect(host.locator("[data-count]")).to_have_text("1") + assert game.playevents.count() == 1 diff --git a/games/views/game.py b/games/views/game.py index 1ef8713..6e7756a 100644 --- a/games/views/game.py +++ b/games/views/game.py @@ -39,7 +39,6 @@ from common.components import ( paginated_table_content, ) from common.components.primitives import Li, P, Span, Strong -from common.icons import get_icon from common.layout import render_page from common.time import ( dateformat, @@ -340,69 +339,73 @@ _STAT_SVGS = { "playrange": '', } -_PLAYED_ROW_TEMPLATE = """
- Played -
- - - - -
-
""" +_PLAYED_BTN = ( + "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 " + "hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " + "dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer" +) +_PLAYED_MENU = ( + "absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium " + "bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border " + "border-gray-200 dark:border-gray-700" +) def _played_row(game: Game, request: HttpRequest) -> Node: - """The 'Played N times' control with its Alpine.js dropdown.""" - replacements = { - "@@PLAYED_COUNT@@": str(game.playevents.count()), - "@@ADD_PE@@": reverse("games:add_playevent"), - "@@ARROWDOWN@@": get_icon("arrowdown"), - "@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]), - "@@API_CREATE@@": reverse("api-1.0.0:create_playevent"), - "@@CSRF@@": get_token(request), - "@@GAME_ID@@": str(game.id), - } - html = _PLAYED_ROW_TEMPLATE - for token, value in replacements.items(): - html = html.replace(token, value) - return Safe(html) + """'Played N times' control as a custom element (ts/elements/play-event-row.ts).""" + from common.components import Element, custom_element + from common.components.custom_elements import PlayEventRowProps + + played = game.playevents.count() + + count_button = A(href=reverse("games:add_playevent"))[ + Element( + "button", + [("type", "button"), ("class", _PLAYED_BTN + " rounded-s-lg")], + [Span(data_count="")[str(played)], " times"], + ) + ] + menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[ + Ul()[ + Li(attributes=[("class", "px-4 py-2")])[ + A(href=reverse("games:add_playevent_for_game", args=[game.id]))[ + "Add playthrough..." + ] + ], + Li(attributes=[("class", "px-4 py-2 cursor-pointer")])[ + Element( + "button", + [("type", "button"), ("data-add-play", "")], + children=["Played times +1"], + ) + ], + ] + ] + toggle = Element( + "button", + [ + ("type", "button"), + ("data-toggle", ""), + ("class", _PLAYED_BTN + " rounded-e-lg relative"), + ], + [Icon("arrowdown"), menu], + ) + group = Div(class_="inline-flex rounded-md shadow-2xs relative")[ + count_button, toggle + ] + return custom_element( + "play-event-row", + PlayEventRowProps( + game_id=game.id, + csrf=get_token(request), + api_create_url=reverse("api-1.0.0:create_playevent"), + ), + children=[ + Div(class_="flex gap-2 items-center")[ + Span(class_="uppercase")["Played"], group + ] + ], + ) def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText: diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py index 8f7b8ab..73bb7ce 100644 --- a/tests/test_rendered_pages.py +++ b/tests/test_rendered_pages.py @@ -168,7 +168,7 @@ class RenderedPagesTest(TestCase): "Platform", 'id="history-container"', "status-changed from:body", - "createPlayEvent", # the played-row Alpine dropdown script + "")) + def test_view_game_uses_play_event_row_element(self): + game = Game.objects.create(name="Played Game", platform=self.platform) + html = self.get("games:view_game", game.id).content.decode() + self.assertIn("("[data-toggle]"); + const menu = this.querySelector("[data-menu]"); + const count = this.querySelector("[data-count]"); + const addPlay = this.querySelector("[data-add-play]"); + if (!toggle || !menu) return; + + const close = () => { + menu.hidden = true; + }; + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + menu.hidden = !menu.hidden; + }); + document.addEventListener("click", (event) => { + if (!this.contains(event.target as Node)) close(); + }); + + addPlay?.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (count) count.textContent = String(Number(count.textContent) + 1); + close(); + window + .fetchWithHtmxTriggers(props.apiCreateUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "X-CSRFToken": props.csrf }, + body: JSON.stringify({ game_id: props.gameId }), + }) + .catch(() => { + if (count) count.textContent = String(Number(count.textContent) - 1); + console.error("Failed to record play"); + }); + }); + } +} + +customElements.define("play-event-row", PlayEventRowElement);