diff --git a/src/apps/billboard/tests/integrated/test_buds.py b/src/apps/billboard/tests/integrated/test_buds.py index 257c057..fa2c3e1 100644 --- a/src/apps/billboard/tests/integrated/test_buds.py +++ b/src/apps/billboard/tests/integrated/test_buds.py @@ -142,6 +142,19 @@ class AddBudViewTest(TestCase): response = self.client.get(reverse("billboard:add_bud")) self.assertEqual(response.status_code, 405) + def test_add_returns_at_handle_and_title_for_tooltip_row(self): + """The async-appended My Buds row mirrors _my_buds_item.html, so the + payload must carry the bud's at_handle + active_title_display to fill + the data-tt-* attrs — without them the new row's tooltip renders empty + (the entries above it, server-rendered, have them). Regression + 2026-05-29.""" + alice = User.objects.create(email="alice@test.io", username="alice") + body = self.client.post( + reverse("billboard:add_bud"), data={"recipient": "alice"}, + ).json() + self.assertEqual(body["bud"]["at_handle"], "@alice") + self.assertEqual(body["bud"]["title"], alice.active_title_display) + def test_add_resolves_username_too_not_just_email(self): """Phase 2: recipient field accepts usernames as well as emails.""" alice = User.objects.create(email="alice@test.io", username="alice") diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index a908c0f..df86c91 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -700,11 +700,16 @@ def add_bud(request): already_present = candidate in request.user.buds.all() if not already_present: request.user.buds.add(candidate) + from apps.lyric.templatetags.lyric_extras import at_handle display = candidate.username or candidate.email bud = { - "id": str(candidate.id), - "username": display, - "email": candidate.email, + "id": str(candidate.id), + "username": display, + "email": candidate.email, + # at_handle + title feed the async row's data-tt-* attrs so its + # tooltip matches the server-rendered rows (regression 2026-05-29). + "at_handle": at_handle(candidate), + "title": candidate.active_title_display, } recipient_display = display recipient_user_id = str(candidate.id) diff --git a/src/functional_tests/test_bill_my_buds.py b/src/functional_tests/test_bill_my_buds.py index ac7f06a..feadc33 100644 --- a/src/functional_tests/test_bill_my_buds.py +++ b/src/functional_tests/test_bill_my_buds.py @@ -64,6 +64,47 @@ class MyBudsPageTest(FunctionalTest): [], )) + def test_async_added_entry_matches_server_row_and_populates_tooltip(self): + """Regression 2026-05-29: the async-appended row used to be a stripped + `@handle`-only `
  • ` — no anchor, no ` the `, no data-tt-* + attrs — so its tooltip rendered empty (vs. the server-rendered rows + above it). The appended row must mirror _my_buds_item.html exactly.""" + self.browser.get(self.live_server_url + "/billboard/my-buds/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")).click() + recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) + recipient.send_keys("alice@test.io") + self.browser.find_element( + By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm" + ).click() + + sel = f".bud-entry[data-bud-id='{self.alice.id}']" + row = self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, sel)) + # Carries both classes (styling + tooltip-lock both key on these). + cls = (row.get_attribute("class") or "").split() + self.assertIn("applet-list-entry", cls) + self.assertIn("bud-entry", cls) + # data-tt-* attrs the portal reads on row-lock. + self.assertEqual(row.get_attribute("data-tt-title"), "@alice") + self.assertEqual(row.get_attribute("data-tt-description"), "Earthman") + self.assertEqual(row.get_attribute("data-tt-email"), "alice@test.io") + # Anchor routes into the bud landing page; trailing ` the <Title>`. + anchor = row.find_element(By.CSS_SELECTOR, ".bud-name a") + self.assertIn(f"/billboard/buds/{self.alice.id}/", anchor.get_attribute("href")) + self.assertIn("the Earthman", row.find_element( + By.CSS_SELECTOR, ".bud-row-title" + ).text) + # Click the row (NOT the anchor) → portal locks + populates non-empty. + row.find_element(By.CSS_SELECTOR, ".bud-row-title").click() + portal = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_tooltip_portal.active" + )) + self.assertEqual( + portal.find_element(By.CSS_SELECTOR, ".tt-title").text, "@alice" + ) + self.assertEqual( + portal.find_element(By.CSS_SELECTOR, ".tt-description").text, "Earthman" + ) + def test_no_autocomplete_suggestions_on_my_buds_page(self): """The bud-autocomplete pool is request.user.buds — surfacing buds you've already added on the page where you ADD new buds is just diff --git a/src/templates/apps/billboard/_partials/_bud_add_panel.html b/src/templates/apps/billboard/_partials/_bud_add_panel.html index d05fc8e..6bf8888 100644 --- a/src/templates/apps/billboard/_partials/_bud_add_panel.html +++ b/src/templates/apps/billboard/_partials/_bud_add_panel.html @@ -19,18 +19,41 @@ var empty = list.querySelector('.applet-list-entry--empty'); if (empty) empty.remove(); + // Mirror _my_buds_item.html EXACTLY so the async row behaves like a + // server-rendered one: both classes (styling + tooltip-lock key on + // them), the data-tt-* attrs the portal reads on row-lock, the + // bud-page anchor, and the trailing ` the <Title>`. Earlier this + // built a bare `@handle` <li> → empty tooltip + dead row. + // `at_handle` comes from the server (the client can't replicate the + // truncate_email fallback for username-less buds); fall back to a + // naive `@`-prefix only if the payload predates that field. + var handle = bud.at_handle || (function () { + var d = bud.username || ''; + return d.indexOf('@') >= 0 ? d : '@' + d; + }()); + var title = bud.title || ''; + var li = document.createElement('li'); - li.className = 'bud-entry'; + li.className = 'applet-list-entry bud-entry'; li.dataset.budId = bud.id; + li.dataset.ttTitle = handle; + li.dataset.ttDescription = title; + li.dataset.ttEmail = bud.email || ''; + li.dataset.ttShoptalk = ''; // fresh bud — no BudshipNote yet + var name = document.createElement('span'); name.className = 'bud-name'; - // Mirror the `at_handle` template filter: prefix `@` on plain - // usernames; leave the value untouched if it already contains - // `@` (the server falls back to email when username is unset, - // and emails don't take the prefix). - var display = bud.username || ''; - name.textContent = display.indexOf('@') >= 0 ? display : '@' + display; + var a = document.createElement('a'); + a.href = '/billboard/buds/' + bud.id + '/'; + a.textContent = handle; + name.appendChild(a); li.appendChild(name); + + var rowTitle = document.createElement('span'); + rowTitle.className = 'bud-row-title'; + rowTitle.textContent = ' the ' + title; + li.appendChild(rowTitle); + var buffer = list.querySelector('.bud-entry-buffer'); if (buffer) list.insertBefore(li, buffer); else list.appendChild(li);