diff --git a/common/components/domain.py b/common/components/domain.py index f4534a3..0e5d598 100644 --- a/common/components/domain.py +++ b/common/components/domain.py @@ -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"" - for value, label in game_statuses - ) - list_items = "\n".join( - f"
  • " - f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}" - f"
  • " - 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""" -
    - {_dropdown_button_html(options_html, list_items)} -
    -""") + +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: diff --git a/common/components/primitives.py b/common/components/primitives.py index b43fa29..24c551c 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -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 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'{conditional_escape(col)}' 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"{columns_html}" '' f"{rows_html}" - f"{pagination_html}" + f"{pagination_html}", + media=media, ) diff --git a/e2e/test_custom_elements_e2e.py b/e2e/test_custom_elements_e2e.py new file mode 100644 index 0000000..1fbf2da --- /dev/null +++ b/e2e/test_custom_elements_e2e.py @@ -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" diff --git a/tests/test_custom_elements.py b/tests/test_custom_elements.py index ff77e2b..b2e26d8 100644 --- a/tests/test_custom_elements.py +++ b/tests/test_custom_elements.py @@ -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("("[data-toggle]"); + const menu = host.querySelector("[data-menu]"); + const label = host.querySelector("[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("[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 = { + [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)); + }); + }); +} diff --git a/ts/elements/game-status-selector.ts b/ts/elements/game-status-selector.ts new file mode 100644 index 0000000..951f6fe --- /dev/null +++ b/ts/elements/game-status-selector.ts @@ -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);