bud panels duplicate-add guard: server-side already_present flag + client-side error Brief w. FYI flash highlight on the existing entry — for each of the three #id_bud_btn panels (My Buds / post-share / gatekeeper-invite), the JSON response from add_bud / share_post / invite_gamer now carries {already_present, recipient_display, recipient_user_id}; bud-btn.js branches on already_present → calls new Brief.showDuplicateBanner({display_name, target_selector}) instead of the normal onSuccess append; banner title reads @<username> is already present, NVM dismisses, FYI dismisses AND eases in the .bud-duplicate-flash class (color: var(--terUser); text-shadow: 0 0 .5em var(--ninUser); transition: 600ms) onto the existing element (.bud-entry .bud-name / .post-recipient[data-user-id=…] / .gate-slot.filled[data-user-id=…]); gatekeeper "already present" = recipient is either GateSlot.FILLED + gamer OR has TableSeat OR has a pending RoomInvite (highlight target only set when seated — pending invites have no visible slot); .post-recipient chips + .gate-slot.filled cells gain data-user-id so the FYI selector can find them; my_buds.html now loads note.js via the {% block scripts %} pattern (Brief module is required by the duplicate banner path); bonus: latent test_jasmine.py bug fixed — "0 failures" in result.text matched "10 failures" / "20 failures" / etc, silently passing up to 99 failed specs; replaced w. re.search(r"(?<!\d)0 failures\b", …) (caught my new red specs, would've caught any prior Jasmine regression); 18 new ITs + 10 new Jasmine specs + 3 new FTs (one per panel) — TDD
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:
@@ -4,12 +4,23 @@
|
||||
// • _bud_add_panel.html — My Buds add (POSTs to billboard:add_bud)
|
||||
//
|
||||
// Owns: csrf cookie read, open/close + .bud-open html-class, button click,
|
||||
// Escape, click-outside, Enter-in-input, OK POST + JSON routing.
|
||||
// Escape, click-outside, Enter-in-input, OK POST + JSON routing, and the
|
||||
// `data.already_present` duplicate-guard branch (error Brief instead of
|
||||
// the normal onSuccess append).
|
||||
//
|
||||
// Each caller drives it with `bindBudBtn({submitUrl, autocompleteUrl?, onSuccess})`.
|
||||
// `onSuccess(data)` does the panel-specific DOM updates (line append, chip
|
||||
// append, bud-entry append, Brief banner — whatever the response carries).
|
||||
// _close({clear: true}) fires automatically on a successful response.
|
||||
// Each caller drives it with:
|
||||
// bindBudBtn({
|
||||
// submitUrl,
|
||||
// autocompleteUrl?,
|
||||
// onSuccess(data), // success path: line/chip/entry/Brief
|
||||
// duplicateTargetSelector?(data), // selector for .bud-duplicate-flash
|
||||
// })
|
||||
//
|
||||
// `onSuccess(data)` does the panel-specific DOM updates on the new-row path.
|
||||
// `duplicateTargetSelector(data)` returns a CSS selector for the existing
|
||||
// element to highlight when the FYI button on the error Brief is clicked
|
||||
// (.bud-name / .post-recipient / .gate-slot.filled — varies by page).
|
||||
// _close({clear: true}) fires automatically on every successful response.
|
||||
//
|
||||
// `autocompleteUrl` enables bud-autocomplete on the input (post-share +
|
||||
// gatekeeper panels) by binding bud-autocomplete.js to #id_bud_suggestions.
|
||||
@@ -87,7 +98,21 @@
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||
.then(function (data) {
|
||||
if (typeof opts.onSuccess === 'function') opts.onSuccess(data);
|
||||
if (data && data.already_present) {
|
||||
// Skip onSuccess — there's nothing to append. Show the
|
||||
// error Brief instead. target_selector resolves at call
|
||||
// time from the caller's per-page callback.
|
||||
if (window.Brief) {
|
||||
var sel = (typeof opts.duplicateTargetSelector === 'function')
|
||||
? opts.duplicateTargetSelector(data) : null;
|
||||
Brief.showDuplicateBanner({
|
||||
display_name: data.recipient_display,
|
||||
target_selector: sel,
|
||||
});
|
||||
}
|
||||
} else if (typeof opts.onSuccess === 'function') {
|
||||
opts.onSuccess(data);
|
||||
}
|
||||
_close({ clear: true });
|
||||
})
|
||||
.catch(function () {
|
||||
|
||||
@@ -239,3 +239,84 @@ class SharePostImplicitAutoAddTest(TestCase):
|
||||
"""Privacy: unregistered email doesn't touch the buds graph."""
|
||||
self._share("ghost@test.io")
|
||||
self.assertEqual(self.sharer.buds.count(), 0)
|
||||
|
||||
|
||||
class AddBudAlreadyPresentTest(TestCase):
|
||||
"""Duplicate-add guard: add_bud's JSON response distinguishes "newly
|
||||
added" from "already a bud" so the bud-btn JS can render an error
|
||||
Brief titled `@<username> is already present` instead of the normal
|
||||
bud-entry append. `recipient_user_id` is the highlight target id that
|
||||
the Brief FYI button toggles `.bud-duplicate-flash` onto."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@test.io", username="me")
|
||||
self.client.force_login(self.user)
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
|
||||
def _add(self, recipient):
|
||||
return self.client.post(
|
||||
reverse("billboard:add_bud"), {"recipient": recipient},
|
||||
)
|
||||
|
||||
def test_already_present_true_when_candidate_is_already_a_bud(self):
|
||||
self.assertTrue(self._add("alice@test.io").json()["already_present"])
|
||||
|
||||
def test_already_present_false_for_new_bud(self):
|
||||
User.objects.create(email="bob@test.io", username="bob")
|
||||
self.assertFalse(self._add("bob@test.io").json()["already_present"])
|
||||
|
||||
def test_already_present_false_for_unregistered_email(self):
|
||||
self.assertFalse(self._add("ghost@test.io").json()["already_present"])
|
||||
|
||||
def test_recipient_display_carries_username_on_duplicate(self):
|
||||
self.assertEqual(self._add("alice@test.io").json()["recipient_display"], "alice")
|
||||
|
||||
def test_recipient_user_id_carries_alice_id_on_duplicate(self):
|
||||
self.assertEqual(
|
||||
self._add("alice@test.io").json()["recipient_user_id"],
|
||||
str(self.alice.id),
|
||||
)
|
||||
|
||||
def test_username_typed_recipient_also_detects_duplicate(self):
|
||||
"""A user typing 'alice' (no @) resolves the same way as the email."""
|
||||
self.assertTrue(self._add("alice").json()["already_present"])
|
||||
|
||||
|
||||
class SharePostAlreadyPresentTest(TestCase):
|
||||
"""Duplicate-share guard mirrors AddBudAlreadyPresentTest — when the
|
||||
recipient is already in post.shared_with, response carries
|
||||
already_present + recipient_user_id for the .post-recipient highlight."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.billboard.models import Post
|
||||
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
|
||||
self.client.force_login(self.sharer)
|
||||
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||
self.post = Post.objects.create(owner=self.sharer)
|
||||
self.post.shared_with.add(self.alice)
|
||||
|
||||
def _share(self, recipient):
|
||||
return self.client.post(
|
||||
reverse("billboard:share_post", args=[self.post.id]),
|
||||
data={"recipient": recipient},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
|
||||
def test_already_present_true_when_recipient_in_shared_with(self):
|
||||
self.assertTrue(self._share("alice@test.io").json()["already_present"])
|
||||
|
||||
def test_already_present_false_for_new_recipient(self):
|
||||
User.objects.create(email="bob@test.io", username="bob")
|
||||
self.assertFalse(self._share("bob@test.io").json()["already_present"])
|
||||
|
||||
def test_recipient_user_id_present_on_duplicate(self):
|
||||
self.assertEqual(
|
||||
self._share("alice@test.io").json()["recipient_user_id"],
|
||||
str(self.alice.id),
|
||||
)
|
||||
|
||||
def test_recipient_display_already_present_on_duplicate(self):
|
||||
"""`recipient_display` already exists on the success path; on
|
||||
duplicate the same field must carry the matched user's handle."""
|
||||
self.assertEqual(self._share("alice@test.io").json()["recipient_display"], "alice")
|
||||
|
||||
@@ -392,12 +392,16 @@ def share_post(request, post_id):
|
||||
# list exposes; doesn't widen the privacy surface beyond what the
|
||||
# post detail page already shows publicly.
|
||||
recipient_display = None
|
||||
recipient_user_id = None
|
||||
if recipient is not None:
|
||||
recipient_display = recipient.username or recipient.email
|
||||
recipient_user_id = str(recipient.id)
|
||||
return JsonResponse({
|
||||
"brief": brief.to_banner_dict() if brief is not None else None,
|
||||
"line_text": line_text,
|
||||
"recipient_display": recipient_display,
|
||||
"recipient_user_id": recipient_user_id,
|
||||
"already_present": is_reshare,
|
||||
})
|
||||
|
||||
messages.success(request, "An invite has been sent if that address is registered.")
|
||||
@@ -446,16 +450,28 @@ def add_bud(request):
|
||||
candidate = _resolve_recipient(request.POST.get("recipient"))
|
||||
|
||||
bud = None
|
||||
already_present = False
|
||||
recipient_display = None
|
||||
recipient_user_id = None
|
||||
if candidate is not None and candidate != request.user:
|
||||
if candidate not in request.user.buds.all():
|
||||
already_present = candidate in request.user.buds.all()
|
||||
if not already_present:
|
||||
request.user.buds.add(candidate)
|
||||
display = candidate.username or candidate.email
|
||||
bud = {
|
||||
"id": str(candidate.id),
|
||||
"username": candidate.username or candidate.email,
|
||||
"username": display,
|
||||
"email": candidate.email,
|
||||
}
|
||||
recipient_display = display
|
||||
recipient_user_id = str(candidate.id)
|
||||
|
||||
return JsonResponse({"bud": bud})
|
||||
return JsonResponse({
|
||||
"bud": bud,
|
||||
"already_present": already_present,
|
||||
"recipient_display": recipient_display,
|
||||
"recipient_user_id": recipient_user_id,
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
|
||||
Reference in New Issue
Block a user