billboard applets: single-root wrapper for HTMX swap; full context on toggle — TDD

- _applets.html wraps menu + container in one #id_billboard_applets_wrapper div; form's hx-target is now the wrapper, so OK no longer leaves a stale duplicate menu in the DOM (which previously caused the next OK to revert prior toggles)
- toggle_billboard_applets passes full context (recent_room, recent_events, viewer, my_rooms) via factored _billboard_context helper, so Most Recent + My Scrolls keep their content after a toggle instead of falling through to the empty fallback
- applets.js: register id_billboard_applets_wrapper as an applet container so post-swap menu cleanup runs
- BillboardAppletsTest: portrait viewport in setUp; FT covers content preservation, no-revert on second toggle, & post-refresh state
- 4 new ITs: Most Recent renders Coin-on-a-String after toggle; My Scrolls renders room name; response has single menu div; second toggle preserves prior hidden state

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-03 17:15:26 -04:00
parent 536a558f26
commit 75fcc5b34d
5 changed files with 205 additions and 39 deletions

View File

@@ -30,6 +30,7 @@ const appletContainerIds = new Set([
'id_game_applets_container',
'id_gk_sections_container',
'id_wallet_applets_container',
'id_billboard_applets_wrapper',
]);
document.body.addEventListener('htmx:afterSwap', (e) => {

View File

@@ -124,6 +124,71 @@ class ToggleBillboardAppletsTest(TestCase):
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
def test_htmx_toggle_response_renders_most_recent_with_real_events(self):
# Seed a room + event so Most Recent should render prose, not the empty fallback.
room = Room.objects.create(name="Sound Chamber", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": [
"billboard-my-scrolls",
"billboard-my-contacts",
"billboard-most-recent",
]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Coin-on-a-String")
# And My Scrolls renders the room name (needs my_rooms in context).
self.assertContains(response, "Sound Chamber")
def test_htmx_toggle_response_has_single_applet_menu_div(self):
# The response is hx-swapped into the page; if it contains both the menu
# div and the applets-container div, the original menu remains and the
# next gear-click resurrects stale form state. Response must contain the
# menu exactly once (the wrapper) — never two siblings of the same id.
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["billboard-my-scrolls"]},
HTTP_HX_REQUEST="true",
)
body = response.content.decode("utf-8")
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
def test_second_toggle_preserves_prior_hidden_state(self):
# First toggle: hide Contacts only.
self.client.post(
reverse("billboard:toggle_applets"),
{"applets": [
"new-post", "my-posts",
"billboard-my-scrolls",
"billboard-most-recent",
]},
HTTP_HX_REQUEST="true",
)
# Second toggle: hide Most Recent additionally — Contacts must stay hidden.
self.client.post(
reverse("billboard:toggle_applets"),
{"applets": [
"new-post", "my-posts",
"billboard-my-scrolls",
]},
HTTP_HX_REQUEST="true",
)
from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="billboard-my-contacts")
most_recent = Applet.objects.get(slug="billboard-most-recent")
self.assertFalse(
UserApplet.objects.get(user=self.user, applet=contacts).visible
)
self.assertFalse(
UserApplet.objects.get(user=self.user, applet=most_recent).visible
)
class BillscrollViewTest(TestCase):
def setUp(self):

View File

@@ -28,13 +28,12 @@ def _recent_posts(user, limit=3):
)
@login_required(login_url="/")
def billboard(request):
my_rooms = rooms_for_user(request.user).order_by("-created_at")
def _billboard_context(user):
my_rooms = rooms_for_user(user).order_by("-created_at")
recent_room = (
Room.objects.filter(
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
Q(owner=user) | Q(gate_slots__gamer=user)
)
.annotate(last_event=Max("events__timestamp"))
.filter(last_event__isnull=False)
@@ -53,14 +52,21 @@ def billboard(request):
if recent_room else []
)
return render(request, "apps/billboard/billboard.html", {
return {
"my_rooms": my_rooms,
"recent_room": recent_room,
"recent_events": recent_events,
"viewer": request.user,
"applets": applet_context(request.user, "billboard"),
"viewer": user,
"applets": applet_context(user, "billboard"),
"form": LineForm(),
"recent_posts": _recent_posts(request.user),
"recent_posts": _recent_posts(user),
}
@login_required(login_url="/")
def billboard(request):
return render(request, "apps/billboard/billboard.html", {
**_billboard_context(request.user),
"page_class": "page-billboard",
})
@@ -70,11 +76,11 @@ def toggle_billboard_applets(request):
checked = request.POST.getlist("applets")
apply_applet_toggle(request.user, "billboard", checked)
if request.headers.get("HX-Request"):
return render(request, "apps/billboard/_partials/_applets.html", {
"applets": applet_context(request.user, "billboard"),
"form": LineForm(),
"recent_posts": _recent_posts(request.user),
})
return render(
request,
"apps/billboard/_partials/_applets.html",
_billboard_context(request.user),
)
return redirect("billboard:billboard")