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:
@@ -30,6 +30,7 @@ const appletContainerIds = new Set([
|
|||||||
'id_game_applets_container',
|
'id_game_applets_container',
|
||||||
'id_gk_sections_container',
|
'id_gk_sections_container',
|
||||||
'id_wallet_applets_container',
|
'id_wallet_applets_container',
|
||||||
|
'id_billboard_applets_wrapper',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', (e) => {
|
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||||
|
|||||||
@@ -124,6 +124,71 @@ class ToggleBillboardAppletsTest(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
|
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):
|
class BillscrollViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -28,13 +28,12 @@ def _recent_posts(user, limit=3):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
def _billboard_context(user):
|
||||||
def billboard(request):
|
my_rooms = rooms_for_user(user).order_by("-created_at")
|
||||||
my_rooms = rooms_for_user(request.user).order_by("-created_at")
|
|
||||||
|
|
||||||
recent_room = (
|
recent_room = (
|
||||||
Room.objects.filter(
|
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"))
|
.annotate(last_event=Max("events__timestamp"))
|
||||||
.filter(last_event__isnull=False)
|
.filter(last_event__isnull=False)
|
||||||
@@ -53,14 +52,21 @@ def billboard(request):
|
|||||||
if recent_room else []
|
if recent_room else []
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(request, "apps/billboard/billboard.html", {
|
return {
|
||||||
"my_rooms": my_rooms,
|
"my_rooms": my_rooms,
|
||||||
"recent_room": recent_room,
|
"recent_room": recent_room,
|
||||||
"recent_events": recent_events,
|
"recent_events": recent_events,
|
||||||
"viewer": request.user,
|
"viewer": user,
|
||||||
"applets": applet_context(request.user, "billboard"),
|
"applets": applet_context(user, "billboard"),
|
||||||
"form": LineForm(),
|
"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",
|
"page_class": "page-billboard",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -70,11 +76,11 @@ def toggle_billboard_applets(request):
|
|||||||
checked = request.POST.getlist("applets")
|
checked = request.POST.getlist("applets")
|
||||||
apply_applet_toggle(request.user, "billboard", checked)
|
apply_applet_toggle(request.user, "billboard", checked)
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
return render(request, "apps/billboard/_partials/_applets.html", {
|
return render(
|
||||||
"applets": applet_context(request.user, "billboard"),
|
request,
|
||||||
"form": LineForm(),
|
"apps/billboard/_partials/_applets.html",
|
||||||
"recent_posts": _recent_posts(request.user),
|
_billboard_context(request.user),
|
||||||
})
|
)
|
||||||
return redirect("billboard:billboard")
|
return redirect("billboard:billboard")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
self.founder = User.objects.create(email="founder@test.io")
|
self.founder = User.objects.create(email="founder@test.io")
|
||||||
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
|
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
|
||||||
for slug, name, cols, rows in [
|
for slug, name, cols, rows in [
|
||||||
@@ -248,6 +249,97 @@ class BillboardAppletsTest(FunctionalTest):
|
|||||||
self.browser.execute_script("arguments[0].click()", gear_btn)
|
self.browser.execute_script("arguments[0].click()", gear_btn)
|
||||||
self.wait_for_slow(lambda: self.assertTrue(menu.is_displayed()))
|
self.wait_for_slow(lambda: self.assertTrue(menu.is_displayed()))
|
||||||
|
|
||||||
|
def test_toggling_applets_keeps_content_and_persists_per_applet(self):
|
||||||
|
# Seed an event so Most Recent renders prose, not the empty fallback
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/")
|
||||||
|
|
||||||
|
# All three applets visible; Most Recent shows event prose, My Scrolls shows the room
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_applet_billboard_most_recent")
|
||||||
|
)
|
||||||
|
most_recent = self.browser.find_element(By.ID, "id_applet_billboard_most_recent")
|
||||||
|
self.assertIn("Coin-on-a-String", most_recent.text)
|
||||||
|
self.assertIn(
|
||||||
|
"Arcane Assembly",
|
||||||
|
self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls").text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open gear, uncheck Contacts, click OK
|
||||||
|
gear = self.browser.find_element(By.CSS_SELECTOR, ".billboard-page .gear-btn")
|
||||||
|
self.browser.execute_script("arguments[0].click()", gear)
|
||||||
|
menu = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
|
||||||
|
)
|
||||||
|
contacts_cb = menu.find_element(
|
||||||
|
By.CSS_SELECTOR, "input[value='billboard-my-contacts']"
|
||||||
|
)
|
||||||
|
self.browser.execute_script("arguments[0].click()", contacts_cb)
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
||||||
|
|
||||||
|
# Contacts is hidden; Most Recent + My Scrolls keep their content (bug #2)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(
|
||||||
|
self.browser.find_elements(By.ID, "id_applet_billboard_my_contacts"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
most_recent = self.browser.find_element(By.ID, "id_applet_billboard_most_recent")
|
||||||
|
self.assertIn("Coin-on-a-String", most_recent.text)
|
||||||
|
self.assertIn(
|
||||||
|
"Arcane Assembly",
|
||||||
|
self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls").text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second toggle: hide Most Recent. Contacts must NOT come back (bug #1)
|
||||||
|
gear = self.browser.find_element(By.CSS_SELECTOR, ".billboard-page .gear-btn")
|
||||||
|
self.browser.execute_script("arguments[0].click()", gear)
|
||||||
|
menu = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
|
||||||
|
)
|
||||||
|
# The freshly-rendered menu must reflect DB state (Contacts unchecked)
|
||||||
|
contacts_cb = menu.find_element(
|
||||||
|
By.CSS_SELECTOR, "input[value='billboard-my-contacts']"
|
||||||
|
)
|
||||||
|
self.assertFalse(contacts_cb.is_selected())
|
||||||
|
most_recent_cb = menu.find_element(
|
||||||
|
By.CSS_SELECTOR, "input[value='billboard-most-recent']"
|
||||||
|
)
|
||||||
|
self.browser.execute_script("arguments[0].click()", most_recent_cb)
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
||||||
|
|
||||||
|
# Most Recent gone; Contacts still hidden (the stale-form bug would re-show it)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(
|
||||||
|
self.browser.find_elements(By.ID, "id_applet_billboard_most_recent"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.browser.find_elements(By.ID, "id_applet_billboard_my_contacts"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# And after a hard refresh both stay hidden, menu reflects DB
|
||||||
|
self.browser.refresh()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls")
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.browser.find_elements(By.ID, "id_applet_billboard_my_contacts"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.browser.find_elements(By.ID, "id_applet_billboard_most_recent"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BillscrollAppletsTest(FunctionalTest):
|
class BillscrollAppletsTest(FunctionalTest):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
<div id="id_billboard_applets_wrapper">
|
||||||
<div id="id_billboard_applet_menu" style="display:none;">
|
<div id="id_billboard_applet_menu" style="display:none;">
|
||||||
<form
|
<form
|
||||||
hx-post="{% url "billboard:toggle_applets" %}"
|
hx-post="{% url "billboard:toggle_applets" %}"
|
||||||
hx-target="#id_billboard_applets_container"
|
hx-target="#id_billboard_applets_wrapper"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -25,3 +26,4 @@
|
|||||||
<div id="id_billboard_applets_container">
|
<div id="id_billboard_applets_container">
|
||||||
{% include "apps/applets/_partials/_applets.html" %}
|
{% include "apps/applets/_partials/_applets.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user