bud panels DRY refactor: extract shared skeleton into bud-btn.js — the three #id_bud_btn partials (_bud_panel post-share, _bud_invite_panel gatekeeper, _bud_add_panel My Buds) duplicated ~70% of their JS (csrf/open/close, btn click, Escape, click-outside w. optional suggestions excl., Enter handler, OK POST + JSON routing); collapse into bindBudBtn({submitUrl, autocompleteUrl?, onSuccess(data)}) so each panel keeps only its markup + an onSuccess callback (post-share: _appendLine + _appendRecipientChip + Brief.showBanner; gatekeeper: Brief.showBanner only; my-buds: _appendBudEntry); autocomplete binding folded into bud-btn.js (drives bindBudAutocomplete when autocompleteUrl is passed) so callers stop repeating the bindBudAutocomplete(...) boilerplate; dead data-share-url/data-invite-url/data-add-url attrs dropped since bindBudBtn takes the URL directly; behavior-preserving — all 21 existing FTs (test_my_buds + test_bud_btn) + 49 ITs (test_buds + test_share_post + test_brief) green

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:
Disco DeDisco
2026-05-12 15:52:09 -04:00
parent 7015ddd534
commit 264ed5968e
4 changed files with 164 additions and 305 deletions

View File

@@ -2,12 +2,13 @@
{% load lyric_extras %}
{# ─────────────────────────────────────────────────────────────────────── #}
{# _bud_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. #}
{# field for the share-post async flow. Skeleton (open/close/POST + auto- #}
{# complete) owned by bud-btn.js; this partial wires the share-post #}
{# success callback: appends a Line to #id_post_table, fires Brief.show- #}
{# Banner, and updates the .post-header shared-with prose. #}
{# Included by post.html only. #}
{# #}
{# Spec lives in functional_tests/test_bud_btn.py — write it red-first. #}
{# Run: #}
{# python src/manage.py test functional_tests.test_bud_btn #}
{# Spec lives in functional_tests/test_bud_btn.py. #}
{# ─────────────────────────────────────────────────────────────────────── #}
<button id="id_bud_btn" type="button" aria-label="Share with a bud">
@@ -15,7 +16,6 @@
</button>
<div id="id_bud_panel"
data-share-url="{% url 'billboard:share_post' post.id %}"
data-sharer-name="{% if request.user.is_authenticated %}{{ request.user|display_name }}{% endif %}">
<input id="id_recipient"
name="recipient"
@@ -30,76 +30,16 @@
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
<script>
bindBudAutocomplete(
document.getElementById('id_recipient'),
document.getElementById('id_bud_suggestions'),
{ searchUrl: '{% url "billboard:search_buds" %}' }
);
</script>
<script src="{% static 'apps/billboard/bud-btn.js' %}"></script>
<script>
(function () {
'use strict';
var btn = document.getElementById('id_bud_btn');
var panel = document.getElementById('id_bud_panel');
var input = document.getElementById('id_recipient');
var ok = document.getElementById('id_bud_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('bud-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('bud-open');
btn.classList.remove('active');
if (opts.clear !== false) input.value = '';
}
btn.addEventListener('click', function () {
if (html.classList.contains('bud-open')) {
_close();
} else {
_open();
}
});
// Escape closes the panel, clears the field
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
});
// Click-outside dismiss — same pattern as game-kit.js
document.addEventListener('click', function (e) {
if (!html.classList.contains('bud-open')) return;
if (panel.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return;
// Autocomplete suggestions sit outside the panel (panel overflow
// is hidden for the scaleX slide). A click inside them must NOT
// close+clear the panel.
var sg = document.getElementById('id_bud_suggestions');
if (sg && sg.contains(e.target)) return;
_close();
});
// OK → POST share-post async; reuses the C3.b response handling so the
// recipient chip + brief banner + post-line append all light up.
// Post-May08b layout: #id_post_table is a <ul> of <li class="post-line">
// rows. Author = the sharer (rendered server-side as data-sharer-name on
// the panel) so the appended Line matches the post-refresh state, where
// the persisted Line.author is request.user.
// Author = the sharer (rendered server-side as data-sharer-name) so the
// appended Line matches the post-refresh state, where the persisted
// Line.author is request.user.
function _appendLine(text) {
var list = document.getElementById('id_post_table');
if (!list) return;
@@ -119,18 +59,15 @@
li.appendChild(author);
li.appendChild(body);
li.appendChild(time);
// Insert before the trailing buffer if present
var buffer = list.querySelector('.post-line-buffer');
if (buffer) list.insertBefore(li, buffer);
else list.appendChild(li);
}
// The shared-with header lives outside #id_bud_panel — it's two <p>
// siblings under .post-header. State transitions:
// 0 → 1+ recipients : "just me, X" turns into
// "shared between {chip}" + "& me, X"
// ≥1 → +1 recipients: append chip + ", " separator before existing
// recipient(s).
// The shared-with header lives outside #id_bud_panel — two <p> siblings
// under .post-header. State transitions:
// 0 → 1+ recipients : "just me, X" → "shared between {chip}" + "& me, X"
// ≥1 → +1 recipients: append chip + ", " separator before existing.
function _appendRecipientChip(displayName) {
if (!displayName) return;
var header = document.querySelector('.post-page .post-header');
@@ -148,56 +85,26 @@
return;
}
// 0 → 1+ transition: build the recipients line, rewrite the self
// line from "just me, …" to "& me, …".
var recipientsLine = document.createElement('p');
recipientsLine.className = 'post-shared-recipients';
recipientsLine.appendChild(document.createTextNode('shared between '));
recipientsLine.appendChild(chip);
if (selfLine) {
header.insertBefore(recipientsLine, selfLine);
// Replace "just me," prefix with "& me,"
selfLine.textContent = selfLine.textContent.replace(/^just me,/, '& me,');
} else {
header.appendChild(recipientsLine);
}
}
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();
}
bindBudBtn({
submitUrl: '{% url "billboard:share_post" post.id %}',
autocompleteUrl: '{% url "billboard:search_buds" %}',
onSuccess: 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);
},
});
}());
</script>