diff --git a/src/apps/applets/static/apps/applets/applets.js b/src/apps/applets/static/apps/applets/applets.js index 33af8a8..23d7bb4 100644 --- a/src/apps/applets/static/apps/applets/applets.js +++ b/src/apps/applets/static/apps/applets/applets.js @@ -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) => { diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 44eecf7..a36dc17 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -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): diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 5bab098..10c33db 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -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") diff --git a/src/functional_tests/test_billboard.py b/src/functional_tests/test_billboard.py index 095c415..94cbfa0 100644 --- a/src/functional_tests/test_billboard.py +++ b/src/functional_tests/test_billboard.py @@ -198,6 +198,7 @@ class BillboardAppletsTest(FunctionalTest): def setUp(self): super().setUp() + self.browser.set_window_size(800, 1200) self.founder = User.objects.create(email="founder@test.io") self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder) for slug, name, cols, rows in [ @@ -248,6 +249,97 @@ class BillboardAppletsTest(FunctionalTest): self.browser.execute_script("arguments[0].click()", gear_btn) 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): """ diff --git a/src/templates/apps/billboard/_partials/_applets.html b/src/templates/apps/billboard/_partials/_applets.html index 68efcba..778558e 100644 --- a/src/templates/apps/billboard/_partials/_applets.html +++ b/src/templates/apps/billboard/_partials/_applets.html @@ -1,27 +1,29 @@ - -
- {% include "apps/applets/_partials/_applets.html" %} +
+ +
+ {% include "apps/applets/_partials/_applets.html" %} +