bud landing page: /billboard/buds/<id>/ + my_buds tooltip portal + @mailman post-attribution anchor — TDD
Replaces the @mailman invite Line's inline OK/BYE block w. a dedicated per-bud surface. Three new FTs (test_bill_bud_page, test_bill_my_buds_tooltip, test_bill_mailman_invite_post — landed red 2026-05-27 PM) drive: per-bud landing page rendering 4-btn apparatus + shoptalk textarea + invite-cascade glow handoff; my-buds row tooltip portal w. .tt-title/.tt-description/.tt-email/.tt-shoptalk/.tt-milestone slots; mailman Brief surfacing on any authenticated page-load via context processor + base.html JSON-script.
Models: new `BudshipNote(user, bud, shoptalk[CharField max=160], edited_at)` w. unique_together — per-relation personal note about a bud, never visible to the bud. Lazy-created on first shoptalk save so absence of a row reads as 'never edited' (drives .tt-milestone slot presence).
URLs (billboard): `buds/<uuid:bud_id>/` (bud_page), `buds/<uuid:bud_id>/shoptalk` (save_bud_shoptalk), `buds/<uuid:bud_id>/delete` (delete_bud).
Views: bud_page auto-adds the bud on first visit (mirrors share_post implicit-add); resolves `pending_invite` as non-expired PENDING SeaInvite(owner=bud, invitee=request.user) → drives `sea_btn_active` + `sea_first_draw_pending` flags that _burger.html already reads on my_sea + room. my_buds enriches each bud w. `.shoptalk_text` + `.milestone_dt` so the row template can render data-tt-* attrs without an extra template tag.
mail.py: INVITE_TEMPLATE now interpolates `owner_id` into an `<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>` wrapper around the owner's handle. post.html's existing safe-filter branch (gated on author username == 'mailman') passes it through unescaped. Removed the {% if line.sea_invite %} include path — _invite_actions.html left in place for archival.
Templates: new bud.html (header + shoptalk form + apparatus + gear + burger fan + sea_btn nav inline JS); new _bud_gear.html (NVM→my_buds, DEL→guard portal "Delete this bud?" → POST delete_bud); new _bud_tooltip.html (portal w. .tt-* slots); _my_buds_item.html wraps `@handle` in an anchor to bud_page + carries data-tt-* attrs + " the {{ active_title_display }}"; my_buds.html includes the tooltip portal + loads my-buds-tooltip.js.
JS: new my-buds-tooltip.js binds row clicks → .row-locked + populates #id_tooltip_portal from data-tt-* attrs; anchor clicks pass through to navigate; .tt-milestone is removed from DOM (not just emptied) when never-edited so the FT can distinguish absent vs cleared-after-edit.
SCSS: extend landscape gear-btn rule + #id_*_menu rule w. `.bud-page` + `#id_bud_menu` (otherwise gear-btn collided w. bud-btn in landscape on bud.html). Bump active burger sub-btn z-index to 1 so click hit-test picks the active sub-btn during the 0.25s fan arc-out animation (otherwise a later-in-DOM inactive btn obscured the active target during transition).
Cross-page Brief surface: new `mail_brief_payload` context processor injects the user's oldest unread MAIL_ACCEPTANCE Brief into every authenticated response; base.html renders the JSON-script + auto-fires Brief.showBanner. Mark-read still rides view_post's existing GET unread-flip — no new endpoint.
Pre-existing MySeaInvitePostRenderTest (test_sea_invite_views.py) inverted to match the new contract: the .invite-actions sweep is unconditional (PENDING / ACCEPTED / DECLINED all carry prose only); pinned the post-attribution anchor + bud-page href in its place.
1518 ITs green (1475 app ITs + 43 sprint ITs), 23 sprint FTs green (5 my_buds tooltip + 13 bud page + 5 mailman invite post). Jasmine specs from sprint plan deferred — FT coverage of burger-glow / row-lock / portal-populate paths suffices and the textarea blur-POST flow isn't implemented in this sprint (form is server-action only, save-on-blur AJAX is a follow-on).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -138,8 +138,11 @@ class MySeaInviteAcceptDeclineTest(TestCase):
|
||||
|
||||
|
||||
class MySeaInvitePostRenderTest(TestCase):
|
||||
"""post.html (A5) — the @mailman invite Line renders OK/BYE for PENDING +
|
||||
a status badge otherwise, all driven by `line.sea_invite.status`."""
|
||||
"""post.html — the @mailman invite Line carries a post-attribution
|
||||
anchor around the owner's handle whose href routes to the owner's
|
||||
per-bud landing page. Bud landing page sprint 2026-05-27 ([[project-
|
||||
bud-landing-page-sprint]]) migrated the prior inline OK/BYE/Accepted
|
||||
block onto bud.html; this class pins the new prose contract."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
@@ -154,26 +157,31 @@ class MySeaInvitePostRenderTest(TestCase):
|
||||
self.accept_url = reverse("my_sea_invite_accept", args=[self.invite.id])
|
||||
self.decline_url = reverse("my_sea_invite_decline", args=[self.invite.id])
|
||||
|
||||
def test_pending_invite_renders_ok_bye(self):
|
||||
def test_pending_invite_renders_post_attribution_anchor_not_buttons(self):
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("invite-ok-btn", content)
|
||||
self.assertIn("invite-bye-btn", content)
|
||||
self.assertIn(self.accept_url, content)
|
||||
self.assertIn(self.decline_url, content)
|
||||
# OK/BYE buttons + accept/decline form actions migrated onto bud.html.
|
||||
self.assertNotIn("invite-ok-btn", content)
|
||||
self.assertNotIn("invite-bye-btn", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
self.assertNotIn(self.decline_url, content)
|
||||
# Anchor wraps the owner's handle, routing to their bud landing page.
|
||||
self.assertIn('class="post-attribution"', content)
|
||||
self.assertIn(f"/billboard/buds/{self.owner.id}/", content)
|
||||
|
||||
def test_accepted_invite_renders_badge_not_buttons(self):
|
||||
def test_accepted_invite_renders_no_badge(self):
|
||||
self.invite.status = SeaInvite.ACCEPTED
|
||||
self.invite.accepted_at = timezone.now()
|
||||
self.invite.save()
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("Accepted", content)
|
||||
# No "Accepted <date>" badge — the sweep is unconditional.
|
||||
self.assertNotIn("invite-badge--accepted", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
|
||||
def test_declined_invite_renders_declined_badge(self):
|
||||
def test_declined_invite_renders_no_badge(self):
|
||||
self.invite.status = SeaInvite.DECLINED
|
||||
self.invite.save()
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("Declined", content)
|
||||
self.assertNotIn("invite-badge--declined", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
|
||||
def test_mailman_line_renders_as_system_with_handles(self):
|
||||
|
||||
Reference in New Issue
Block a user