feat(session): in-place row swap for finish/reset with OOB navbar
Delete stale _session_row_fragment; end_session and reset_session_start return the canonical row plus an OOB navbar-playtime fragment. Clone keeps HX-Refresh since it changes row count. Fixes #53. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+20
-89
@@ -5,10 +5,9 @@ from django.db.models import Q
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.middleware.csrf import get_token
|
from django.middleware.csrf import get_token
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.template.defaultfilters import date as date_filter
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
@@ -29,8 +28,8 @@ from common.components import (
|
|||||||
TableRow,
|
TableRow,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.primitives import Span, Td, Tr
|
from common.layout import NavbarPlaytime, render_page
|
||||||
from common.layout import render_page
|
from games.views.general import model_counts
|
||||||
from common.time import (
|
from common.time import (
|
||||||
dateformat,
|
dateformat,
|
||||||
local_strftime,
|
local_strftime,
|
||||||
@@ -309,84 +308,16 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _session_row_fragment(session: Session) -> SafeText:
|
def _row_with_navbar(request: HttpRequest, session: Session) -> HttpResponse:
|
||||||
"""A single session <tr> (the old list_sessions.html#session-row partial),
|
device_list = Device.objects.order_by("name")
|
||||||
returned by the inline end/clone-session HTMX endpoints."""
|
counts = model_counts(request)
|
||||||
name_link = A(
|
fragment = Fragment(
|
||||||
href=reverse("games:view_game", args=[session.game.id]),
|
session_row(session, device_list, get_token(request)),
|
||||||
attributes=[
|
NavbarPlaytime(
|
||||||
(
|
counts["today_played"], counts["last_7_played"], oob=True
|
||||||
"class",
|
),
|
||||||
"underline decoration-slate-500 sm:decoration-2 inline-block "
|
|
||||||
"truncate max-w-20char group-hover:absolute group-hover:max-w-none "
|
|
||||||
"group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 "
|
|
||||||
"group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 "
|
|
||||||
"group-hover:rounded-xs group-hover:outline-dashed "
|
|
||||||
"group-hover:outline-purple-400 group-hover:outline-4 "
|
|
||||||
"group-hover:decoration-purple-900 group-hover:text-purple-100",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
children=[session.game.name],
|
|
||||||
)
|
)
|
||||||
name_td = Td(
|
return HttpResponse(str(fragment))
|
||||||
attributes=[
|
|
||||||
(
|
|
||||||
"class",
|
|
||||||
"px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top "
|
|
||||||
"w-24 h-12 group",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
children=[
|
|
||||||
Span(
|
|
||||||
attributes=[("class", "inline-block relative")],
|
|
||||||
children=[name_link],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
start_td = Td(
|
|
||||||
attributes=[
|
|
||||||
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
|
|
||||||
],
|
|
||||||
children=[date_filter(session.timestamp_start, "d/m/Y H:i")],
|
|
||||||
)
|
|
||||||
|
|
||||||
if not session.timestamp_end:
|
|
||||||
end_url = reverse("games:list_sessions_end_session", args=[session.id])
|
|
||||||
end_inner: SafeText | str = A(
|
|
||||||
href=end_url,
|
|
||||||
attributes=[
|
|
||||||
("hx-get", end_url),
|
|
||||||
("hx-target", "closest tr"),
|
|
||||||
("hx-swap", "outerHTML"),
|
|
||||||
("hx-indicator", "#indicator"),
|
|
||||||
(
|
|
||||||
"onClick",
|
|
||||||
"document.querySelector('#last-session-start')"
|
|
||||||
".classList.remove('invisible')",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
children=[
|
|
||||||
Span(
|
|
||||||
attributes=[("class", "text-yellow-300")],
|
|
||||||
children=["Finish now?"],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
elif session.duration_manual:
|
|
||||||
end_inner = "--"
|
|
||||||
else:
|
|
||||||
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
|
|
||||||
end_td = Td(
|
|
||||||
attributes=[
|
|
||||||
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
|
|
||||||
],
|
|
||||||
children=[end_inner],
|
|
||||||
)
|
|
||||||
duration_td = Td(
|
|
||||||
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
|
|
||||||
children=[session.duration_formatted()],
|
|
||||||
)
|
|
||||||
return Tr(children=[name_td, start_td, end_td, duration_td])
|
|
||||||
|
|
||||||
|
|
||||||
def clone_session_by_id(session_id: int) -> Session:
|
def clone_session_by_id(session_id: int) -> Session:
|
||||||
@@ -404,9 +335,13 @@ def clone_session_by_id(session_id: int) -> Session:
|
|||||||
def new_session_from_existing_session(
|
def new_session_from_existing_session(
|
||||||
request: HttpRequest, session_id: int
|
request: HttpRequest, session_id: int
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
session = clone_session_by_id(session_id)
|
clone_session_by_id(session_id)
|
||||||
if request.htmx:
|
if request.htmx:
|
||||||
return HttpResponse(_session_row_fragment(session))
|
# Clone adds a new row whose position depends on sort + pagination,
|
||||||
|
# which a single-row swap cannot place — refresh the list instead.
|
||||||
|
response = HttpResponse(status=204)
|
||||||
|
response["HX-Refresh"] = "true"
|
||||||
|
return response
|
||||||
return redirect("games:list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
@@ -416,7 +351,7 @@ def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||||||
session.timestamp_end = timezone.now()
|
session.timestamp_end = timezone.now()
|
||||||
session.save()
|
session.save()
|
||||||
if request.htmx:
|
if request.htmx:
|
||||||
return HttpResponse(_session_row_fragment(session))
|
return _row_with_navbar(request, session)
|
||||||
return redirect("games:list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
@@ -426,11 +361,7 @@ def reset_session_start(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||||||
session.timestamp_start = timezone.now()
|
session.timestamp_start = timezone.now()
|
||||||
session.save()
|
session.save()
|
||||||
if request.htmx:
|
if request.htmx:
|
||||||
# The list table is rebuilt server-side per request; a full refresh
|
return _row_with_navbar(request, session)
|
||||||
# avoids swapping in a row fragment whose layout could drift from it.
|
|
||||||
response = HttpResponse(status=204)
|
|
||||||
response["HX-Refresh"] = "true"
|
|
||||||
return response
|
|
||||||
return redirect("games:list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ class RenderedPagesTest(TestCase):
|
|||||||
|
|
||||||
def test_reset_session_start_to_now_via_htmx(self):
|
def test_reset_session_start_to_now_via_htmx(self):
|
||||||
# The inline "reset start" endpoint sets timestamp_start to now and
|
# The inline "reset start" endpoint sets timestamp_start to now and
|
||||||
# asks htmx to refresh the list (the table is rebuilt server-side).
|
# returns an in-place row swap plus an OOB navbar update.
|
||||||
running = Session.objects.create(
|
running = Session.objects.create(
|
||||||
game=self.game,
|
game=self.game,
|
||||||
timestamp_start=datetime(2020, 1, 1, 10, 0, tzinfo=ZONEINFO),
|
timestamp_start=datetime(2020, 1, 1, 10, 0, tzinfo=ZONEINFO),
|
||||||
@@ -277,7 +277,11 @@ class RenderedPagesTest(TestCase):
|
|||||||
reverse("games:list_sessions_reset_session_start", args=[running.id]),
|
reverse("games:list_sessions_reset_session_start", args=[running.id]),
|
||||||
HTTP_HX_REQUEST="true",
|
HTTP_HX_REQUEST="true",
|
||||||
)
|
)
|
||||||
self.assertEqual(resp["HX-Refresh"], "true")
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
body = resp.content.decode()
|
||||||
|
self.assertIn(f'id="session-row-{running.id}"', body)
|
||||||
|
self.assertIn('id="navbar-playtime"', body)
|
||||||
|
self.assertNotIn("HX-Refresh", resp.headers)
|
||||||
running.refresh_from_db()
|
running.refresh_from_db()
|
||||||
self.assertGreaterEqual(running.timestamp_start, before)
|
self.assertGreaterEqual(running.timestamp_start, before)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from games.models import Device, Game, Platform, Session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(client, django_user_model):
|
||||||
|
user = django_user_model.objects.create_user(username="u", password="p")
|
||||||
|
client.force_login(user)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def running_session(db):
|
||||||
|
platform = Platform.objects.create(name="PC")
|
||||||
|
game = Game.objects.create(name="Hades", platform=platform)
|
||||||
|
device = Device.objects.create(name="Deck")
|
||||||
|
return Session.objects.create(
|
||||||
|
game=game, device=device, timestamp_start=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_session_htmx_returns_row_and_oob_navbar(auth_client, running_session):
|
||||||
|
url = reverse("games:list_sessions_end_session", args=[running_session.pk])
|
||||||
|
response = auth_client.get(url, HTTP_HX_REQUEST="true")
|
||||||
|
body = response.content.decode()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert f'id="session-row-{running_session.pk}"' in body
|
||||||
|
assert 'id="navbar-playtime"' in body
|
||||||
|
assert 'hx-swap-oob="true"' in body
|
||||||
|
running_session.refresh_from_db()
|
||||||
|
assert running_session.timestamp_end is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_session_start_htmx_returns_row_no_refresh_header(
|
||||||
|
auth_client, running_session
|
||||||
|
):
|
||||||
|
original_start = running_session.timestamp_start
|
||||||
|
url = reverse(
|
||||||
|
"games:list_sessions_reset_session_start", args=[running_session.pk]
|
||||||
|
)
|
||||||
|
response = auth_client.get(url, HTTP_HX_REQUEST="true")
|
||||||
|
body = response.content.decode()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert f'id="session-row-{running_session.pk}"' in body
|
||||||
|
assert 'id="navbar-playtime"' in body
|
||||||
|
assert "HX-Refresh" not in response.headers
|
||||||
|
running_session.refresh_from_db()
|
||||||
|
assert running_session.timestamp_start > original_start
|
||||||
|
|
||||||
|
|
||||||
|
def test_clone_htmx_returns_hx_refresh(auth_client, running_session):
|
||||||
|
url = reverse(
|
||||||
|
"games:list_sessions_start_session_from_session",
|
||||||
|
args=[running_session.pk],
|
||||||
|
)
|
||||||
|
before = Session.objects.count()
|
||||||
|
response = auth_client.get(url, HTTP_HX_REQUEST="true")
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert response.headers.get("HX-Refresh") == "true"
|
||||||
|
assert Session.objects.count() == before + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_session_non_htmx_redirects(auth_client, running_session):
|
||||||
|
url = reverse("games:list_sessions_end_session", args=[running_session.pk])
|
||||||
|
response = auth_client.get(url)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.url == reverse("games:list_sessions")
|
||||||
Reference in New Issue
Block a user