played-row: custom element; delete @@TOKEN@@ template + Alpine
This commit is contained in:
@@ -112,3 +112,12 @@ class SessionDeviceSelectorProps(TypedDict):
|
|||||||
register_element(
|
register_element(
|
||||||
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
|
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayEventRowProps(TypedDict):
|
||||||
|
game_id: int
|
||||||
|
csrf: str
|
||||||
|
api_create_url: str
|
||||||
|
|
||||||
|
|
||||||
|
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
|
||||||
|
|||||||
@@ -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()
|
host.locator(f'[data-option][data-value="{deck.id}"]').click()
|
||||||
session.refresh_from_db()
|
session.refresh_from_db()
|
||||||
assert session.device_id == deck.id
|
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
|
||||||
|
|||||||
+64
-61
@@ -39,7 +39,6 @@ from common.components import (
|
|||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.primitives import Li, P, Span, Strong
|
from common.components.primitives import Li, P, Span, Strong
|
||||||
from common.icons import get_icon
|
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import (
|
from common.time import (
|
||||||
dateformat,
|
dateformat,
|
||||||
@@ -340,69 +339,73 @@ _STAT_SVGS = {
|
|||||||
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
|
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
|
||||||
}
|
}
|
||||||
|
|
||||||
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
|
_PLAYED_BTN = (
|
||||||
<span class="uppercase">Played</span>
|
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
|
||||||
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
|
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||||
<a href="@@ADD_PE@@">
|
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-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:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
)
|
||||||
<span x-text="played"></span> times
|
_PLAYED_MENU = (
|
||||||
</button>
|
"absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
|
||||||
</a>
|
"bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
|
||||||
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-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:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
"border-gray-200 dark:border-gray-700"
|
||||||
@@ARROWDOWN@@
|
)
|
||||||
<div
|
|
||||||
class="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"
|
|
||||||
x-show="open"
|
|
||||||
>
|
|
||||||
<ul
|
|
||||||
class=""
|
|
||||||
>
|
|
||||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
|
||||||
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
x-on:click="createPlayEvent"
|
|
||||||
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
|
||||||
>
|
|
||||||
Played times +1
|
|
||||||
</li>
|
|
||||||
<script>
|
|
||||||
function createPlayEvent() {
|
|
||||||
this.played++;
|
|
||||||
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
|
||||||
fetchWithHtmxTriggers('@@API_CREATE@@', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
|
|
||||||
body: '{"game_id": @@GAME_ID@@}'
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
this.played--;
|
|
||||||
console.error('Failed to record play');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _played_row(game: Game, request: HttpRequest) -> Node:
|
def _played_row(game: Game, request: HttpRequest) -> Node:
|
||||||
"""The 'Played N times' control with its Alpine.js dropdown."""
|
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
|
||||||
replacements = {
|
from common.components import Element, custom_element
|
||||||
"@@PLAYED_COUNT@@": str(game.playevents.count()),
|
from common.components.custom_elements import PlayEventRowProps
|
||||||
"@@ADD_PE@@": reverse("games:add_playevent"),
|
|
||||||
"@@ARROWDOWN@@": get_icon("arrowdown"),
|
played = game.playevents.count()
|
||||||
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
|
|
||||||
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
|
count_button = A(href=reverse("games:add_playevent"))[
|
||||||
"@@CSRF@@": get_token(request),
|
Element(
|
||||||
"@@GAME_ID@@": str(game.id),
|
"button",
|
||||||
}
|
[("type", "button"), ("class", _PLAYED_BTN + " rounded-s-lg")],
|
||||||
html = _PLAYED_ROW_TEMPLATE
|
[Span(data_count="")[str(played)], " times"],
|
||||||
for token, value in replacements.items():
|
)
|
||||||
html = html.replace(token, value)
|
]
|
||||||
return Safe(html)
|
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:
|
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ class RenderedPagesTest(TestCase):
|
|||||||
"Platform",
|
"Platform",
|
||||||
'id="history-container"',
|
'id="history-container"',
|
||||||
"status-changed from:body",
|
"status-changed from:body",
|
||||||
"createPlayEvent", # the played-row Alpine dropdown script
|
"<play-event-row", # the played-row custom element
|
||||||
'hx-target="#global-modal-container"', # delete trigger
|
'hx-target="#global-modal-container"', # delete trigger
|
||||||
"Purchases",
|
"Purchases",
|
||||||
"Sessions",
|
"Sessions",
|
||||||
@@ -179,6 +179,14 @@ class RenderedPagesTest(TestCase):
|
|||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
self.assertEqual(html.count("<div"), html.count("</div>"))
|
self.assertEqual(html.count("<div"), html.count("</div>"))
|
||||||
|
|
||||||
|
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("<play-event-row", html)
|
||||||
|
self.assertIn('game-id="', html)
|
||||||
|
self.assertNotIn("@@", html) # token-replace hack gone
|
||||||
|
self.assertNotIn("createPlayEvent", html) # the old Alpine fn is gone
|
||||||
|
|
||||||
def test_view_game_empty_sections(self):
|
def test_view_game_empty_sections(self):
|
||||||
"""A game with no sessions/purchases/etc shows the empty messages."""
|
"""A game with no sessions/purchases/etc shows the empty messages."""
|
||||||
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
|
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { readPlayEventRowProps } from "../generated/props.js";
|
||||||
|
|
||||||
|
class PlayEventRowElement extends HTMLElement {
|
||||||
|
connectedCallback(): void {
|
||||||
|
const props = readPlayEventRowProps(this);
|
||||||
|
const toggle = this.querySelector<HTMLElement>("[data-toggle]");
|
||||||
|
const menu = this.querySelector<HTMLElement>("[data-menu]");
|
||||||
|
const count = this.querySelector<HTMLElement>("[data-count]");
|
||||||
|
const addPlay = this.querySelector<HTMLElement>("[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);
|
||||||
Reference in New Issue
Block a user