buddy btn sprint scaffolding: TDD spec + partial template + SCSS + page_class — 15/16 FTs green, 1 captured-red for post-compaction handoff
Pre-compaction handoff for the bottom-left handshake btn that replaces the inline share form on post.html. The full spec lives in functional_tests/test_buddy_btn.py — running it as a red-first TDD checklist for the next agent (or future Disco) to pick up after compaction.
Scaffolding landed:
- functional_tests/test_buddy_btn.py — 16 tests across 6 classes covering presence-only-on-post.html (B1–B3), bottom-left fixed positioning matching kit-btn dimensions, slide-out panel structure (closed/open width, OK btn = .btn-confirm, recipient input left-padding clears the glyph), kit↔buddy mutual-exclusion via opacity, click-outside + Escape dismiss + clear, OK→async share creates Brief + appends Line + chips the recipient + closes the panel + clears the field, and post.html / my_posts.html body class picks up the aperture marker (page-billboard).
- templates/apps/billboard/_partials/_buddy_panel.html — the partial: <button id="id_buddy_btn"><i class="fa-solid fa-handshake"></i></button> + #id_buddy_panel housing #id_recipient and #id_buddy_ok (.btn.btn-confirm). Inline JS mirrors the game-kit.js click/escape/click-outside pattern, toggles html.buddy-open + .active on the btn, intercepts OK to POST share-post w. Accept:application/json (reuses C3.b shape — line_text + brief.to_banner_dict() + recipient_display), appends the chip + line in-DOM, Brief.showBanner shows the slide-down banner, _close clears the input.
- templates/apps/billboard/post.html — drops the inline #id_share_form / #id_recipient / SHARE-primary block + its JS; includes the buddy panel partial at the end of {% block content %}.
- billboard.views.view_post + my_posts now set page_class="page-billboard" so the body class hooks into the aperture SCSS group (the user noted post.html wasn't in that group; this brings it in).
- static_src/scss/_buddy.scss — new partial: #id_buddy_btn fixed bottom-left mirror of #id_kit_btn (3rem circle, secUser border, .active state, transition: opacity 0.15s); #id_buddy_panel slide-out spans calc(100vw - 3rem) (1.5rem each side w. landscape sidebar carve-outs), transform: scaleX(0)→1 from left center on html.buddy-open, opacity 0→1, the recipient input gets padding 0 1rem 0 3.5rem so the glyph doesn't overlap. Mutual exclusion: html.buddy-open #id_kit_btn → opacity:0; html:has(#id_kit_bag_dialog[open]) #id_buddy_btn → opacity:0 (uses :has() per project convention; no JS-side kit-open class needed).
- core.scss imports buddy after game-kit.
15/16 FTs green; the lone red is BuddyBtnOkSubmitsAsyncShareTest.test_ok_creates_brief_appends_line_and_chip — server flow works (Brief is created, recipient chip + line append in DOM both visible in the screendump), only the .note-banner injection isn't surfacing on post.html. Likely cause: note.js inserts after the first <h2>, but post.html's only h2 is the rotated navbar header which is position:absolute, so the banner's geometry parents to that and falls outside the visible aperture. Two clean follow-ups for the post-compaction agent: (a) make Brief.showBanner pick a different anchor when h2.parentElement is position:absolute, or (b) define a #id_brief_banner_anchor in base.html under the page content and have showBanner prefer it.
Also pending for post-compaction: update functional_tests.post_page.PostPage.share_post_with() to drive the new buddy-btn flow (click btn → type → click OK → wait for chip) so the legacy test_sharing FT keeps working — currently it still operates on the inline form selectors that no longer exist.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -263,7 +263,11 @@ def view_post(request, post_id):
|
|||||||
Brief.objects.filter(
|
Brief.objects.filter(
|
||||||
owner=request.user, post=our_post, is_unread=True,
|
owner=request.user, post=our_post, is_unread=True,
|
||||||
).update(is_unread=False)
|
).update(is_unread=False)
|
||||||
return render(request, "apps/billboard/post.html", {"post": our_post, "form": form})
|
return render(request, "apps/billboard/post.html", {
|
||||||
|
"post": our_post,
|
||||||
|
"form": form,
|
||||||
|
"page_class": "page-billboard",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def my_posts(request, user_id):
|
def my_posts(request, user_id):
|
||||||
@@ -272,7 +276,10 @@ def my_posts(request, user_id):
|
|||||||
return redirect("/")
|
return redirect("/")
|
||||||
if request.user.id != owner.id:
|
if request.user.id != owner.id:
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
return render(request, "apps/billboard/my_posts.html", {"owner": owner})
|
return render(request, "apps/billboard/my_posts.html", {
|
||||||
|
"owner": owner,
|
||||||
|
"page_class": "page-billboard",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def share_post(request, post_id):
|
def share_post(request, post_id):
|
||||||
|
|||||||
381
src/functional_tests/test_buddy_btn.py
Normal file
381
src/functional_tests/test_buddy_btn.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
"""FT spec for the Buddy btn sprint — post.html bottom-left handshake button.
|
||||||
|
|
||||||
|
Written red BEFORE implementation as a TDD handoff so the post-compaction
|
||||||
|
agent (or future Disco) can land the feature without losing intent. Run:
|
||||||
|
|
||||||
|
python src/manage.py test functional_tests.test_buddy_btn
|
||||||
|
|
||||||
|
All tests should be RED initially. Implementation lands when they go green.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────
|
||||||
|
SPEC SUMMARY
|
||||||
|
────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
• A new #id_buddy_btn (<i class="fa-solid fa-handshake">) sits bottom-left
|
||||||
|
of the viewport — mirror of #id_kit_btn (bottom-right). Shares the same
|
||||||
|
fixed/circular/secUser-bordered look + .active-state styling.
|
||||||
|
• Lives in a partial template, e.g. apps/billboard/_partials/_buddy_panel.html,
|
||||||
|
included only by post.html (NOT the global base.html — buddy is post-only).
|
||||||
|
• Replaces the inline share form on post.html: typing the recipient now
|
||||||
|
happens in a slide-out under the buddy btn.
|
||||||
|
• Click #id_buddy_btn → recipient field grows L→R under it, spanning
|
||||||
|
`100vw - 3rem` (1.5rem padding each side). The field is vertically
|
||||||
|
centred on the buddy btn's centre, w. healthy left padding so the typed
|
||||||
|
text + placeholder don't overlap the btn glyph.
|
||||||
|
• An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the
|
||||||
|
field (replaces the legacy big SHARE .btn-primary).
|
||||||
|
• While the recipient field is open: html.buddy-open is set; #id_kit_btn
|
||||||
|
quickly eases to opacity 0. Symmetric: when html.kit-open is set,
|
||||||
|
#id_buddy_btn eases to opacity 0.
|
||||||
|
• Click OK → POST share-post async (existing C3.b endpoint), clears the
|
||||||
|
field, closes the slide-out, slide-down Brief banner appears.
|
||||||
|
• Click outside the field/btn → closes the slide-out, clears the field
|
||||||
|
(no Brief). Reopening shows the placeholder, not the prior typed value.
|
||||||
|
• Escape key closes the slide-out (same as kit btn pattern).
|
||||||
|
• post.html + my_posts.html should be aperture-styled (body.page-billboard
|
||||||
|
or new body.page-post if needed; the user noted post.html isn't
|
||||||
|
currently in the base/applet scss aperture group).
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────
|
||||||
|
IMPLEMENTATION CHECKLIST (post-compaction)
|
||||||
|
────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
1. Create templates/apps/billboard/_partials/_buddy_panel.html w. the btn
|
||||||
|
+ slide-out form + inline JS.
|
||||||
|
2. Edit templates/apps/billboard/post.html:
|
||||||
|
- Drop the inline `<form id=id_share_form>` + #id_recipient + SHARE btn
|
||||||
|
block (the share JS moves into _buddy_panel.html).
|
||||||
|
- Add `{% include "apps/billboard/_partials/_buddy_panel.html" %}`.
|
||||||
|
3. Edit billboard.views.view_post (or my_posts) context:
|
||||||
|
- "page_class": "page-billboard" (or new page-post) so the body class
|
||||||
|
picks up the aperture SCSS.
|
||||||
|
4. SCSS — add to _game-kit.scss neighbour or new _buddy.scss:
|
||||||
|
- #id_buddy_btn: position fixed bottom-left, mirror of #id_kit_btn
|
||||||
|
(3rem circle, secUser border, .active state, etc.)
|
||||||
|
- #id_buddy_panel (the slide-out wrapper): position fixed,
|
||||||
|
left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when
|
||||||
|
kit btn visible — but mutual-exclusion makes that moot), bottom-
|
||||||
|
aligned w. the btn centre, transition transform/width L→R.
|
||||||
|
- html.buddy-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; }
|
||||||
|
- html.kit-open #id_buddy_btn { opacity: 0; transition: opacity 0.15s; }
|
||||||
|
- The OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on
|
||||||
|
the trailing edge of the slide-out.
|
||||||
|
5. JS in _buddy_panel.html:
|
||||||
|
- Mirror game-kit.js click/escape/click-outside pattern.
|
||||||
|
- Toggle html.buddy-open + #id_buddy_btn.active.
|
||||||
|
- On submit/OK: fetch POST share-post w. Accept:application/json,
|
||||||
|
reuse the C3.b response handling (line append, banner via
|
||||||
|
Brief.showBanner, recipient_display chip append).
|
||||||
|
- On dismiss-without-OK: clear input.value.
|
||||||
|
6. post.html + my_posts.html: add the body class hook so the aperture
|
||||||
|
SCSS engages (probably page-billboard already, just need to confirm).
|
||||||
|
7. Update functional_tests.post_page.PostPage.share_post_with() to
|
||||||
|
drive the buddy-btn flow (click btn → type → click OK → wait for chip).
|
||||||
|
8. Re-run test_sharing.SharingTest — should still pass once the page-
|
||||||
|
object mirrors the new flow.
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────────────────────────
|
||||||
|
KNOWN AT TIME OF WRITING
|
||||||
|
────────────────────────────────────────────────────────────────────────────
|
||||||
|
- #id_kit_btn lives in templates/core/base.html (line ~55), styled in
|
||||||
|
static_src/scss/_game-kit.scss; toggled by apps/dashboard/static/.../game-kit.js.
|
||||||
|
- The C3.b share-post async endpoint accepts Accept: application/json,
|
||||||
|
returns {brief, line_text, recipient_display}; intercepted by the
|
||||||
|
inline JS in post.html's existing #id_share_form. That JS moves into
|
||||||
|
_buddy_panel.html.
|
||||||
|
- body.page-billboard class is set by billboard:billboard view; post.html
|
||||||
|
needs it (or its own class) added in billboard.views.view_post.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.billboard.models import Brief, Line, Post
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from .base import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_a_post(user):
|
||||||
|
"""Create a Post w. one Line so view_post renders w/o redirect."""
|
||||||
|
p = Post.objects.create(owner=user)
|
||||||
|
Line.objects.create(post=p, text="seed line")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
class BuddyBtnPresenceTest(FunctionalTest):
|
||||||
|
"""The buddy btn is post-only — present on post.html, absent elsewhere."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-posts",
|
||||||
|
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
|
||||||
|
)
|
||||||
|
self.gamer = User.objects.create(email="buddy@test.io")
|
||||||
|
self.post = _seed_a_post(self.gamer)
|
||||||
|
self.create_pre_authenticated_session("buddy@test.io")
|
||||||
|
|
||||||
|
# ── B1 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_buddy_btn_renders_on_post_html(self):
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake")
|
||||||
|
self.assertIsNotNone(icon)
|
||||||
|
|
||||||
|
# ── B2 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_buddy_btn_absent_on_dashboard(self):
|
||||||
|
self.browser.get(self.live_server_url + "/")
|
||||||
|
# Allow page to settle
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
|
||||||
|
self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn"))
|
||||||
|
|
||||||
|
# ── B3 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_buddy_btn_absent_on_billboard_index(self):
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
|
||||||
|
self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn"))
|
||||||
|
|
||||||
|
|
||||||
|
class BuddyBtnPositionTest(FunctionalTest):
|
||||||
|
"""The btn sits bottom-left, mirror of #id_kit_btn's bottom-right."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="buddy@test.io")
|
||||||
|
self.post = _seed_a_post(self.gamer)
|
||||||
|
self.create_pre_authenticated_session("buddy@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
|
def test_buddy_btn_is_fixed_bottom_left(self):
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
cs = self.browser.execute_script(
|
||||||
|
"var s = getComputedStyle(arguments[0]); "
|
||||||
|
"return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};",
|
||||||
|
btn,
|
||||||
|
)
|
||||||
|
self.assertEqual(cs["position"], "fixed")
|
||||||
|
# bottom + left are non-auto/0; right is auto (or large) so the btn
|
||||||
|
# hugs the bottom-left corner.
|
||||||
|
self.assertNotEqual(cs["bottom"], "auto")
|
||||||
|
self.assertNotEqual(cs["left"], "auto")
|
||||||
|
|
||||||
|
def test_buddy_btn_size_matches_kit_btn(self):
|
||||||
|
"""Same circular-3rem look — visually a mirror pair."""
|
||||||
|
btn = self.browser.find_element(By.ID, "id_buddy_btn")
|
||||||
|
kit = self.browser.find_element(By.ID, "id_kit_btn")
|
||||||
|
b_box = btn.size
|
||||||
|
k_box = kit.size
|
||||||
|
self.assertEqual(b_box["width"], k_box["width"])
|
||||||
|
self.assertEqual(b_box["height"], k_box["height"])
|
||||||
|
|
||||||
|
|
||||||
|
class BuddyBtnSlideOutTest(FunctionalTest):
|
||||||
|
"""Click the buddy btn → recipient field + OK btn slide out under it."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="buddy@test.io")
|
||||||
|
self.post = _seed_a_post(self.gamer)
|
||||||
|
self.create_pre_authenticated_session("buddy@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
|
def test_recipient_field_hidden_until_click(self):
|
||||||
|
"""Pre-click: the field is in DOM but visually closed (e.g. width 0
|
||||||
|
or transform scaleX(0)) — assertion checks it doesn't take its full
|
||||||
|
viewport-spanning width yet."""
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
# The field+OK panel is rendered (so JS can transition it) but should
|
||||||
|
# be in a closed state — assert the panel container exists and the
|
||||||
|
# input is not displayed at full width (a CSS-driven slide-out).
|
||||||
|
panel = self.browser.find_element(By.ID, "id_buddy_panel")
|
||||||
|
# Before click, panel visible-width should be < viewport / 2 (closed)
|
||||||
|
viewport_w = self.browser.execute_script("return window.innerWidth;")
|
||||||
|
self.assertLess(panel.size["width"], viewport_w / 2)
|
||||||
|
|
||||||
|
def test_click_buddy_btn_reveals_recipient_field_and_ok_btn(self):
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
btn.click()
|
||||||
|
|
||||||
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
|
self.assertTrue(recipient.is_displayed())
|
||||||
|
|
||||||
|
# OK btn is .btn.btn-confirm tacked onto the panel — not a big SHARE
|
||||||
|
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||||
|
self.assertEqual(ok.text.strip().upper(), "OK")
|
||||||
|
# Buddy btn picks up .active when open (mirror kit-btn pattern)
|
||||||
|
self.assertIn("active", btn.get_attribute("class"))
|
||||||
|
|
||||||
|
def test_panel_spans_almost_full_viewport_when_open(self):
|
||||||
|
"""When open, the panel spans 100vw - 3rem (1.5rem each side)."""
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
btn.click()
|
||||||
|
panel = self.browser.find_element(By.ID, "id_buddy_panel")
|
||||||
|
# Wait for the slide-out transition to settle
|
||||||
|
self.wait_for(lambda: self.assertGreater(
|
||||||
|
panel.size["width"],
|
||||||
|
self.browser.execute_script("return window.innerWidth;") * 0.7,
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_recipient_input_has_left_padding_so_glyph_doesnt_overlap(self):
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
btn.click()
|
||||||
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
|
pad = self.browser.execute_script(
|
||||||
|
"return parseFloat(getComputedStyle(arguments[0]).paddingLeft);",
|
||||||
|
recipient,
|
||||||
|
)
|
||||||
|
# At least 2.5rem (40px-ish) so the buddy glyph (3rem circle) doesn't
|
||||||
|
# overlap the placeholder/typed text.
|
||||||
|
self.assertGreaterEqual(pad, 32)
|
||||||
|
|
||||||
|
|
||||||
|
class BuddyKitMutualExclusionTest(FunctionalTest):
|
||||||
|
"""When kit btn is active, buddy btn fades to 0 — and vice-versa."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="buddy@test.io")
|
||||||
|
self.post = _seed_a_post(self.gamer)
|
||||||
|
self.create_pre_authenticated_session("buddy@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
|
def test_buddy_active_fades_kit_btn(self):
|
||||||
|
buddy = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
kit = self.browser.find_element(By.ID, "id_kit_btn")
|
||||||
|
buddy.click()
|
||||||
|
self.wait_for(lambda: self.assertEqual(
|
||||||
|
self.browser.execute_script(
|
||||||
|
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
|
||||||
|
kit,
|
||||||
|
),
|
||||||
|
0.0,
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_kit_active_fades_buddy_btn(self):
|
||||||
|
kit = self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
|
||||||
|
buddy = self.browser.find_element(By.ID, "id_buddy_btn")
|
||||||
|
kit.click()
|
||||||
|
self.wait_for(lambda: self.assertEqual(
|
||||||
|
self.browser.execute_script(
|
||||||
|
"return parseFloat(getComputedStyle(arguments[0]).opacity);",
|
||||||
|
buddy,
|
||||||
|
),
|
||||||
|
0.0,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class BuddyBtnDismissTest(FunctionalTest):
|
||||||
|
"""Click outside / Escape closes the panel; field is cleared; reopening
|
||||||
|
shows the placeholder, not the previously-typed value."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.gamer = User.objects.create(email="buddy@test.io")
|
||||||
|
self.post = _seed_a_post(self.gamer)
|
||||||
|
self.create_pre_authenticated_session("buddy@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
|
def _open_and_type(self, text):
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
btn.click()
|
||||||
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
|
recipient.send_keys(text)
|
||||||
|
return btn, recipient
|
||||||
|
|
||||||
|
def test_click_outside_dismisses_and_clears(self):
|
||||||
|
btn, recipient = self._open_and_type("alice@test.io")
|
||||||
|
# Click somewhere far from the panel + btn
|
||||||
|
body = self.browser.find_element(By.TAG_NAME, "body")
|
||||||
|
self.browser.execute_script(
|
||||||
|
"var e = new MouseEvent('click', {bubbles:true, clientX: 100, clientY: 100});"
|
||||||
|
"document.querySelector('h2').dispatchEvent(e);"
|
||||||
|
)
|
||||||
|
self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class")))
|
||||||
|
# Reopening: input value should be cleared
|
||||||
|
btn.click()
|
||||||
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
|
self.assertEqual(recipient.get_attribute("value"), "")
|
||||||
|
|
||||||
|
def test_escape_dismisses_and_clears(self):
|
||||||
|
btn, recipient = self._open_and_type("alice@test.io")
|
||||||
|
recipient.send_keys(Keys.ESCAPE)
|
||||||
|
self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class")))
|
||||||
|
btn.click()
|
||||||
|
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
|
self.assertEqual(recipient.get_attribute("value"), "")
|
||||||
|
|
||||||
|
|
||||||
|
class BuddyBtnOkSubmitsAsyncShareTest(FunctionalTest):
|
||||||
|
"""OK → POST share-post (Accept:application/json) → Brief banner +
|
||||||
|
recipient chip appended; field clears; panel closes."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.sharer = User.objects.create(email="buddy@test.io")
|
||||||
|
self.recipient = User.objects.create(email="alice@test.io")
|
||||||
|
self.post = _seed_a_post(self.sharer)
|
||||||
|
self.create_pre_authenticated_session("buddy@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
|
||||||
|
def test_ok_creates_brief_appends_line_and_chip(self):
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn"))
|
||||||
|
btn.click()
|
||||||
|
recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
|
||||||
|
recipient_input.send_keys("alice@test.io")
|
||||||
|
ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm")
|
||||||
|
ok.click()
|
||||||
|
|
||||||
|
# 1. Brief is created server-side
|
||||||
|
self.wait_for(lambda: self.assertEqual(
|
||||||
|
Brief.objects.filter(owner=self.sharer, kind=Brief.KIND_SHARE_INVITE).count(),
|
||||||
|
1,
|
||||||
|
))
|
||||||
|
# 2. Banner appears
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner"))
|
||||||
|
# 3. .post-recipient chip appended
|
||||||
|
self.wait_for(lambda: self.assertTrue(
|
||||||
|
self.browser.find_elements(By.CSS_SELECTOR, ".post-recipient")
|
||||||
|
))
|
||||||
|
# 4. Field cleared
|
||||||
|
recipient_input = self.browser.find_element(By.ID, "id_recipient")
|
||||||
|
self.assertEqual(recipient_input.get_attribute("value"), "")
|
||||||
|
# 5. Panel closed (btn no longer .active)
|
||||||
|
self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class")))
|
||||||
|
|
||||||
|
|
||||||
|
class PostHtmlAperturePageClassTest(FunctionalTest):
|
||||||
|
"""post.html and my_posts.html should pick up the aperture body class
|
||||||
|
so they share the navbar/footer scroll-lock pattern w. sky.html etc."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-posts",
|
||||||
|
defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
|
||||||
|
)
|
||||||
|
self.gamer = User.objects.create(email="buddy@test.io")
|
||||||
|
self.post = _seed_a_post(self.gamer)
|
||||||
|
self.create_pre_authenticated_session("buddy@test.io")
|
||||||
|
|
||||||
|
def test_post_html_body_carries_billboard_or_post_page_class(self):
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
|
||||||
|
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
|
||||||
|
cls = body.get_attribute("class")
|
||||||
|
self.assertTrue(
|
||||||
|
"page-billboard" in cls or "page-post" in cls,
|
||||||
|
f"post.html body class missing aperture marker: {cls!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_my_posts_html_body_carries_billboard_or_post_page_class(self):
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/users/{self.gamer.id}/")
|
||||||
|
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
|
||||||
|
cls = body.get_attribute("class")
|
||||||
|
self.assertTrue(
|
||||||
|
"page-billboard" in cls or "page-post" in cls,
|
||||||
|
f"my_posts.html body class missing aperture marker: {cls!r}",
|
||||||
|
)
|
||||||
120
src/static_src/scss/_buddy.scss
Normal file
120
src/static_src/scss/_buddy.scss
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// ── Buddy btn (bottom-left mirror of #id_kit_btn) ─────────────────────────
|
||||||
|
//
|
||||||
|
// Lives on post.html only — slide-out recipient field for the share-post
|
||||||
|
// async flow. Mutually exclusive w. #id_kit_btn (bottom-right): when one is
|
||||||
|
// active (.active class on btn + html.{kit|buddy}-open class on root), the
|
||||||
|
// other quickly fades to opacity 0.
|
||||||
|
//
|
||||||
|
// Spec: functional_tests/test_buddy_btn.py.
|
||||||
|
|
||||||
|
#id_buddy_btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
left: 1rem;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (min-width: 1800px) {
|
||||||
|
left: 2.5rem; // mirror the doubled 8rem sidebar centring
|
||||||
|
}
|
||||||
|
|
||||||
|
z-index: 318;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(var(--secUser), 1);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(var(--priUser), 1);
|
||||||
|
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: rgba(var(--quaUser), 1);
|
||||||
|
border-color: rgba(var(--quaUser), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide-out panel: collapsed by default; opens to span ~viewport - 3rem.
|
||||||
|
#id_buddy_panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0.5rem; // align bottom edge w. buddy btn
|
||||||
|
left: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
height: 3rem; // match buddy btn height for vertical-centre alignment
|
||||||
|
z-index: 317;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// Closed state — collapse leftward into the buddy btn
|
||||||
|
transform-origin: left center;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transition: transform 0.2s ease-out, opacity 0.15s ease;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
left: calc(4rem + 0.5rem); // clear the navbar sidebar
|
||||||
|
right: calc(4rem + 0.5rem); // clear the footer sidebar
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (min-width: 1800px) {
|
||||||
|
left: calc(8rem + 0.5rem);
|
||||||
|
right: calc(8rem + 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_recipient {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
// Generous left padding so the buddy btn glyph (3rem circle pinned
|
||||||
|
// at left:1.5rem) doesn't visually overlap the placeholder/typed text.
|
||||||
|
padding: 0 1rem 0 3.5rem;
|
||||||
|
background-color: rgba(var(--priUser), 1);
|
||||||
|
color: rgba(var(--secUser), 1);
|
||||||
|
border: 0.1rem solid rgba(var(--secUser), 0.5);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(var(--terUser), 0.75);
|
||||||
|
box-shadow: 0 0 0.75rem rgba(var(--terUser), 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.btn-confirm {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// html.buddy-open: slide the panel out, fade the kit btn away.
|
||||||
|
html.buddy-open {
|
||||||
|
#id_buddy_panel {
|
||||||
|
transform: scaleX(1);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_kit_btn {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kit dialog open: hide the buddy btn. We don't add an `html.kit-open`
|
||||||
|
// class (game-kit.js uses [open] on the dialog + .active on the btn), so
|
||||||
|
// the mutual-exclusion is driven by `:has()` against the open dialog.
|
||||||
|
html:has(#id_kit_bag_dialog[open]) #id_buddy_btn {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
@import 'note';
|
@import 'note';
|
||||||
@import 'tooltips';
|
@import 'tooltips';
|
||||||
@import 'game-kit';
|
@import 'game-kit';
|
||||||
|
@import 'buddy';
|
||||||
@import 'wallet-tokens';
|
@import 'wallet-tokens';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
136
src/templates/apps/billboard/_partials/_buddy_panel.html
Normal file
136
src/templates/apps/billboard/_partials/_buddy_panel.html
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
{% load static %}
|
||||||
|
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||||
|
{# _buddy_panel.html — bottom-left handshake btn + slide-out recipient #}
|
||||||
|
{# field for the share-post async flow. Mirror of #id_kit_btn (bottom- #}
|
||||||
|
{# right). Included by post.html only. #}
|
||||||
|
{# #}
|
||||||
|
{# Spec lives in functional_tests/test_buddy_btn.py — write it red-first. #}
|
||||||
|
{# Run: #}
|
||||||
|
{# python src/manage.py test functional_tests.test_buddy_btn #}
|
||||||
|
{# ─────────────────────────────────────────────────────────────────────── #}
|
||||||
|
|
||||||
|
<button id="id_buddy_btn" type="button" aria-label="Share with a buddy">
|
||||||
|
<i class="fa-solid fa-handshake"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="id_buddy_panel" data-share-url="{% url 'billboard:share_post' post.id %}">
|
||||||
|
<input id="id_recipient"
|
||||||
|
name="recipient"
|
||||||
|
type="email"
|
||||||
|
placeholder="friend@example.com"
|
||||||
|
autocomplete="off">
|
||||||
|
<button id="id_buddy_ok" type="button" class="btn btn-confirm">OK</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var btn = document.getElementById('id_buddy_btn');
|
||||||
|
var panel = document.getElementById('id_buddy_panel');
|
||||||
|
var input = document.getElementById('id_recipient');
|
||||||
|
var ok = document.getElementById('id_buddy_ok');
|
||||||
|
var html = document.documentElement;
|
||||||
|
if (!btn || !panel || !input || !ok) return;
|
||||||
|
|
||||||
|
function _csrf() {
|
||||||
|
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _open() {
|
||||||
|
html.classList.add('buddy-open');
|
||||||
|
btn.classList.add('active');
|
||||||
|
// small delay before focus so the slide-out animation can play
|
||||||
|
setTimeout(function () { input.focus(); }, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _close(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
html.classList.remove('buddy-open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
if (opts.clear !== false) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (html.classList.contains('buddy-open')) {
|
||||||
|
_close();
|
||||||
|
} else {
|
||||||
|
_open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape closes the panel, clears the field
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && html.classList.contains('buddy-open')) _close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click-outside dismiss — same pattern as game-kit.js
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!html.classList.contains('buddy-open')) return;
|
||||||
|
if (panel.contains(e.target)) return;
|
||||||
|
if (e.target === btn || btn.contains(e.target)) return;
|
||||||
|
_close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// OK → POST share-post async; reuses the C3.b response handling so the
|
||||||
|
// recipient chip + brief banner + post-table line append all light up.
|
||||||
|
function _appendLine(text) {
|
||||||
|
var table = document.getElementById('id_post_table');
|
||||||
|
if (!table) return;
|
||||||
|
var n = table.querySelectorAll('tr').length + 1;
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var td = document.createElement('td');
|
||||||
|
td.textContent = n + '. ' + text;
|
||||||
|
tr.appendChild(td);
|
||||||
|
table.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _appendRecipientChip(displayName) {
|
||||||
|
var box = document.getElementById('id_post_recipients');
|
||||||
|
if (!box || !displayName) return;
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'post-recipient';
|
||||||
|
span.textContent = displayName;
|
||||||
|
box.appendChild(document.createTextNode(' '));
|
||||||
|
box.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
ok.addEventListener('click', function () {
|
||||||
|
var email = input.value.trim();
|
||||||
|
if (!email) return;
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.set('recipient', email);
|
||||||
|
|
||||||
|
fetch(panel.dataset.shareUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRFToken': _csrf(),
|
||||||
|
},
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.line_text) _appendLine(data.line_text);
|
||||||
|
if (window.Brief && data.brief) Brief.showBanner(data.brief);
|
||||||
|
if (data.recipient_display) _appendRecipientChip(data.recipient_display);
|
||||||
|
_close({ clear: true });
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// swallow — privacy-safe response shape means even an
|
||||||
|
// unregistered recipient is a 200; only network/5xx land here.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit-on-Enter inside the input mirrors clicking OK
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
ok.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
@@ -24,25 +24,6 @@
|
|||||||
|
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
|
|
||||||
<form id="id_share_form" method="POST" action="{% url "billboard:share_post" post.id %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input
|
|
||||||
id="id_recipient"
|
|
||||||
name="recipient"
|
|
||||||
class="form-control form-control-lg{% if form.errors.recipient %} is-invalid{% endif %}"
|
|
||||||
placeholder="friend@example.com"
|
|
||||||
aria-describedby="id_recipient_feedback"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{% if form.errors.recipient %}
|
|
||||||
<div id="id_recipient_feedback" class="invalid-feedback">
|
|
||||||
{{ form.errors.recipient.0 }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Share</button>
|
|
||||||
</form>
|
|
||||||
<small>Post shared with:
|
<small>Post shared with:
|
||||||
<span id="id_post_recipients">
|
<span id="id_post_recipients">
|
||||||
{% for user in post.shared_with.all %}
|
{% for user in post.shared_with.all %}
|
||||||
@@ -52,68 +33,11 @@
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Buddy btn (bottom-left) + slide-out recipient field — async share. #}
|
||||||
|
{% include "apps/billboard/_partials/_buddy_panel.html" %}
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% include "apps/dashboard/_partials/_scripts.html" %}
|
{% include "apps/dashboard/_partials/_scripts.html" %}
|
||||||
<script>
|
|
||||||
// Async share: intercepts the share form, POSTs w. Accept:application/json,
|
|
||||||
// then slide-downs the SHARE_INVITE Brief banner under the navbar h2 + appends
|
|
||||||
// the freshly-recorded Line into #id_post_table. No page reload — the legacy
|
|
||||||
// alert-success flash is gone.
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
var form = document.getElementById('id_share_form');
|
|
||||||
var input = document.getElementById('id_recipient');
|
|
||||||
var table = document.getElementById('id_post_table');
|
|
||||||
var recipientsBox = document.getElementById('id_post_recipients');
|
|
||||||
if (!form || !input || !table) return;
|
|
||||||
|
|
||||||
function _csrf() {
|
|
||||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
|
||||||
return m ? m[1] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _appendLine(text) {
|
|
||||||
var n = table.querySelectorAll('tr').length + 1;
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
var td = document.createElement('td');
|
|
||||||
td.textContent = n + '. ' + text;
|
|
||||||
tr.appendChild(td);
|
|
||||||
table.appendChild(tr);
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var fd = new FormData(form);
|
|
||||||
fetch(form.action, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-CSRFToken': _csrf(),
|
|
||||||
},
|
|
||||||
body: fd,
|
|
||||||
})
|
|
||||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
|
||||||
.then(function (data) {
|
|
||||||
if (data.line_text) _appendLine(data.line_text);
|
|
||||||
if (window.Brief && data.brief) Brief.showBanner(data.brief);
|
|
||||||
if (data.recipient_display && recipientsBox) {
|
|
||||||
var span = document.createElement('span');
|
|
||||||
span.className = 'post-recipient';
|
|
||||||
span.textContent = data.recipient_display;
|
|
||||||
recipientsBox.appendChild(document.createTextNode(' '));
|
|
||||||
recipientsBox.appendChild(span);
|
|
||||||
}
|
|
||||||
input.value = '';
|
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
// No-op for now — the privacy-safe response shape means
|
|
||||||
// even an unregistered recipient is a 200 w. brief data;
|
|
||||||
// a true error path (5xx) silently swallows.
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
|
|||||||
Reference in New Issue
Block a user