recognition: page, palette modal, & dashboard palette unlock — TDD
- billboard/recognition/ view + template; recognition/<slug>/set-palette endpoint (no trailing slash) - recognition.html: <template>-based modal (clone on open, remove on close — Selenium find_elements compatible) - recognition-page.js: image-box → modal → swatch preview → body-click restore → OK → confirm → POST set-palette - _palettes_for_user() replaces static PALETTES; Recognition.palette unlocks swatch + populates data-shoptalk - _unlocked_palettes_for_user() wires dynamic unlock check into set_palette view - _applet-palette.html: data-shoptalk from context instead of hard-coded "Placeholder" - _recognition.scss: banner, recog-list/item, image-box, modal, palette-confirm; :not([hidden]) pattern avoids display override - FT T2 split into T2a (banner → FYI → recog page), T2b (palette modal flow), T2c (dashboard palette applet) - 684 ITs green; 7 FTs green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
src/apps/billboard/static/apps/billboard/recognition-page.js
Normal file
143
src/apps/billboard/static/apps/billboard/recognition-page.js
Normal file
@@ -0,0 +1,143 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _state = 'closed'; // 'closed' | 'open' | 'previewing'
|
||||
var _selectedPalette = null;
|
||||
var _activeItem = null;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _activeModal() {
|
||||
return _activeItem && _activeItem.querySelector('.recog-palette-modal');
|
||||
}
|
||||
|
||||
function _paletteClass(el) {
|
||||
return Array.from(el.classList).find(function (c) { return c.startsWith('palette-'); }) || '';
|
||||
}
|
||||
|
||||
function _getCsrf() {
|
||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
// ── modal lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
function _openModal() {
|
||||
// Clone from <template> if the modal is not in the DOM yet.
|
||||
var existing = _activeModal();
|
||||
if (!existing) {
|
||||
var tpl = _activeItem.querySelector('.recog-palette-modal-tpl');
|
||||
if (!tpl) return;
|
||||
var clone = tpl.content.firstElementChild.cloneNode(true);
|
||||
_activeItem.appendChild(clone);
|
||||
_wireModal();
|
||||
}
|
||||
_state = 'open';
|
||||
var confirmEl = _activeModal().querySelector('.recog-palette-confirm');
|
||||
if (confirmEl) confirmEl.hidden = true;
|
||||
}
|
||||
|
||||
function _closeModal() {
|
||||
_state = 'closed';
|
||||
var modal = _activeModal();
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
// Wire event listeners onto the freshly-cloned modal DOM.
|
||||
function _wireModal() {
|
||||
var modal = _activeModal();
|
||||
if (!modal) return;
|
||||
|
||||
// Swatch body → preview (remove modal from DOM)
|
||||
modal.querySelectorAll('.recog-swatch-body').forEach(function (body) {
|
||||
body.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_selectedPalette = _paletteClass(body.parentElement);
|
||||
_state = 'previewing';
|
||||
modal.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// OK button (not inside confirm submenu) → show confirm submenu
|
||||
modal.querySelectorAll('.btn.btn-confirm').forEach(function (btn) {
|
||||
if (btn.closest('.recog-palette-confirm')) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_selectedPalette = _paletteClass(btn.parentElement);
|
||||
var confirmEl = modal.querySelector('.recog-palette-confirm');
|
||||
if (confirmEl) confirmEl.hidden = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm YES → POST and update DOM
|
||||
modal.querySelectorAll('.recog-palette-confirm .btn.btn-confirm').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_doSetPalette();
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm NVM → hide confirm submenu
|
||||
modal.querySelectorAll('.recog-palette-confirm .btn.btn-cancel').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
btn.closest('.recog-palette-confirm').hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Stop all other modal clicks from reaching body handler.
|
||||
modal.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
}
|
||||
|
||||
// ── set-palette POST ──────────────────────────────────────────────────────
|
||||
|
||||
function _doSetPalette() {
|
||||
var url = _activeItem.dataset.setPaletteUrl;
|
||||
var palette = _selectedPalette;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': _getCsrf(),
|
||||
},
|
||||
body: JSON.stringify({ palette: palette }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () {
|
||||
_closeModal();
|
||||
var imageBox = _activeItem.querySelector('.recog-item__image-box');
|
||||
if (imageBox) {
|
||||
var swatch = document.createElement('div');
|
||||
swatch.className = 'recog-item__palette ' + palette;
|
||||
imageBox.parentNode.replaceChild(swatch, imageBox);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function _init() {
|
||||
// Image-box click → open modal
|
||||
document.querySelectorAll('.recog-item__image-box').forEach(function (box) {
|
||||
box.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_activeItem = box.closest('.recog-item');
|
||||
_openModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Body click → restore modal when previewing
|
||||
document.body.addEventListener('click', function () {
|
||||
if (_state === 'previewing') {
|
||||
_openModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
}());
|
||||
@@ -1,8 +1,11 @@
|
||||
import json as _json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||
from apps.drama.models import GameEvent, Recognition, ScrollPosition, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
@@ -161,6 +164,102 @@ class BillscrollViewTest(TestCase):
|
||||
self.assertContains(response, 'class="drama-event-time"')
|
||||
|
||||
|
||||
class RecognitionPageViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="recog@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_returns_200(self):
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_uses_recognition_template(self):
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertTemplateUsed(response, "apps/billboard/recognition.html")
|
||||
|
||||
def test_passes_recognitions_in_context(self):
|
||||
recog = Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertIn(recog, response.context["recognitions"])
|
||||
|
||||
def test_excludes_other_users_recognitions(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
Recognition.objects.create(
|
||||
user=other, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertEqual(list(response.context["recognitions"]), [])
|
||||
|
||||
def test_renders_recog_list_and_items(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertContains(response, 'class="recog-list"')
|
||||
self.assertContains(response, 'class="recog-item"')
|
||||
|
||||
def test_renders_recog_item_title_description_image_box(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertContains(response, 'class="recog-item__title"')
|
||||
self.assertContains(response, 'class="recog-item__description"')
|
||||
self.assertContains(response, 'class="recog-item__image-box"')
|
||||
|
||||
|
||||
class RecognitionSetPaletteViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="setpal@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.recognition = Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
)
|
||||
self.url = "/billboard/recognition/stargazer/set-palette"
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_sets_palette_on_recognition(self):
|
||||
self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.recognition.refresh_from_db()
|
||||
self.assertEqual(self.recognition.palette, "palette-bardo")
|
||||
|
||||
def test_returns_200_with_ok(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"ok": True})
|
||||
|
||||
def test_returns_404_for_slug_user_does_not_own(self):
|
||||
response = self.client.post(
|
||||
"/billboard/recognition/schizo/set-palette",
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class SaveScrollPositionTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@savescroll.io")
|
||||
|
||||
@@ -7,6 +7,8 @@ app_name = "billboard"
|
||||
urlpatterns = [
|
||||
path("", views.billboard, name="billboard"),
|
||||
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||
path("recognition/", views.recognition_page, name="recognition"),
|
||||
path("recognition/<slug:slug>/set-palette", views.recognition_set_palette, name="recognition_set_palette"),
|
||||
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
|
||||
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Max, Q
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from apps.drama.models import GameEvent, ScrollPosition
|
||||
from apps.drama.models import GameEvent, Recognition, ScrollPosition
|
||||
from apps.epic.models import Room
|
||||
from apps.epic.utils import rooms_for_user
|
||||
|
||||
@@ -68,6 +71,58 @@ def room_scroll(request, room_id):
|
||||
})
|
||||
|
||||
|
||||
_RECOGNITION_META = {
|
||||
"stargazer": {
|
||||
"title": "Stargazer",
|
||||
"description": "You saved your first personal sky chart.",
|
||||
"palette_options": ["palette-bardo", "palette-sheol"],
|
||||
},
|
||||
"schizo": {
|
||||
"title": "Schizo",
|
||||
"description": "The socius recognizes the line of flight.",
|
||||
"palette_options": [],
|
||||
},
|
||||
"nomad": {
|
||||
"title": "Nomad",
|
||||
"description": "The socius recognizes the smooth space.",
|
||||
"palette_options": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def recognition_set_palette(request, slug):
|
||||
from django.http import Http404
|
||||
try:
|
||||
recognition = Recognition.objects.get(user=request.user, slug=slug)
|
||||
except Recognition.DoesNotExist:
|
||||
raise Http404
|
||||
if request.method == "POST":
|
||||
body = json.loads(request.body)
|
||||
recognition.palette = body.get("palette", "")
|
||||
recognition.save(update_fields=["palette"])
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def recognition_page(request):
|
||||
qs = Recognition.objects.filter(user=request.user)
|
||||
recognitions = [
|
||||
{
|
||||
"obj": r,
|
||||
"title": _RECOGNITION_META.get(r.slug, {}).get("title", r.slug),
|
||||
"description": _RECOGNITION_META.get(r.slug, {}).get("description", ""),
|
||||
"palette_options": _RECOGNITION_META.get(r.slug, {}).get("palette_options", []),
|
||||
}
|
||||
for r in qs
|
||||
]
|
||||
return render(request, "apps/billboard/recognition.html", {
|
||||
"recognitions": qs,
|
||||
"recognition_items": recognitions,
|
||||
"page_class": "page-recognition",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def save_scroll_position(request, room_id):
|
||||
if request.method != "POST":
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import override_settings, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import html
|
||||
from django.utils import html, timezone
|
||||
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
from apps.dashboard.forms import (
|
||||
@@ -13,6 +13,7 @@ from apps.dashboard.forms import (
|
||||
EMPTY_ITEM_ERROR,
|
||||
)
|
||||
from apps.dashboard.models import Item, Note
|
||||
from apps.drama.models import Recognition
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
@@ -348,6 +349,52 @@ class SetPaletteTest(TestCase):
|
||||
swatches = parsed.cssselect(".swatch")
|
||||
self.assertEqual(len(swatches), len(response.context["palettes"]))
|
||||
|
||||
class RecognitionPaletteContextTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="recog_palette@test.io")
|
||||
self.client.force_login(self.user)
|
||||
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||
|
||||
def test_recognition_palette_unlocks_swatch_in_context(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
response = self.client.get("/")
|
||||
palettes = response.context["palettes"]
|
||||
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
|
||||
self.assertFalse(bardo["locked"])
|
||||
|
||||
def test_recognition_palette_shoptalk_contains_recognition_title(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
response = self.client.get("/")
|
||||
palettes = response.context["palettes"]
|
||||
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
|
||||
self.assertIn("Stargazer", bardo["shoptalk"])
|
||||
|
||||
def test_recognition_without_palette_field_keeps_swatch_locked(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette=None,
|
||||
)
|
||||
response = self.client.get("/")
|
||||
palettes = response.context["palettes"]
|
||||
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
|
||||
self.assertTrue(bardo["locked"])
|
||||
|
||||
def test_recognition_palette_allows_set_palette_via_view(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
|
||||
self.user.refresh_from_db()
|
||||
self.assertEqual(self.user.palette, "palette-bardo")
|
||||
|
||||
|
||||
@override_settings(COMPRESS_ENABLED=False)
|
||||
class ProfileViewTest(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -23,23 +23,59 @@ from apps.lyric.models import PaymentMethod, Token, User, Wallet
|
||||
|
||||
|
||||
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
|
||||
UNLOCKED_PALETTES = frozenset([
|
||||
_BASE_UNLOCKED = frozenset([
|
||||
"palette-default",
|
||||
"palette-cedar",
|
||||
"palette-oblivion-light",
|
||||
"palette-monochrome-dark",
|
||||
])
|
||||
PALETTES = [
|
||||
{"name": "palette-default", "label": "Earthman", "locked": False},
|
||||
{"name": "palette-cedar", "label": "Cedar", "locked": False},
|
||||
{"name": "palette-oblivion-light", "label": "Oblivion (Light)", "locked": False},
|
||||
{"name": "palette-monochrome-dark", "label": "Monochrome (Dark)", "locked": False},
|
||||
{"name": "palette-bardo", "label": "Bardo", "locked": True},
|
||||
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
||||
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
||||
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
|
||||
{"name": "palette-celestia", "label": "Celestia", "locked": True},
|
||||
_PALETTE_DEFS = [
|
||||
{"name": "palette-default", "label": "Earthman", "locked": False},
|
||||
{"name": "palette-cedar", "label": "Cedar", "locked": False},
|
||||
{"name": "palette-oblivion-light", "label": "Oblivion (Light)","locked": False},
|
||||
{"name": "palette-monochrome-dark","label": "Monochrome (Dark)","locked": False},
|
||||
{"name": "palette-bardo", "label": "Bardo", "locked": True},
|
||||
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
||||
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
||||
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
|
||||
{"name": "palette-celestia", "label": "Celestia", "locked": True},
|
||||
]
|
||||
_RECOGNITION_TITLES = {
|
||||
"stargazer": "Stargazer",
|
||||
"schizo": "Schizo",
|
||||
"nomad": "Nomad",
|
||||
}
|
||||
# Keep PALETTES as an alias used by views that don't have a request user.
|
||||
PALETTES = _PALETTE_DEFS
|
||||
|
||||
|
||||
def _palettes_for_user(user):
|
||||
if not (user and user.is_authenticated):
|
||||
return [dict(p, shoptalk="Placeholder") for p in _PALETTE_DEFS]
|
||||
granted = {
|
||||
r.palette: r
|
||||
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette="")
|
||||
}
|
||||
result = []
|
||||
for p in _PALETTE_DEFS:
|
||||
entry = dict(p)
|
||||
r = granted.get(p["name"])
|
||||
if r and p["locked"]:
|
||||
entry["locked"] = False
|
||||
title = _RECOGNITION_TITLES.get(r.slug, r.slug.capitalize())
|
||||
entry["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}"
|
||||
else:
|
||||
entry["shoptalk"] = "Placeholder"
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def _unlocked_palettes_for_user(user):
|
||||
base = set(_BASE_UNLOCKED)
|
||||
if user and user.is_authenticated:
|
||||
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
|
||||
base.add(r.palette)
|
||||
return base
|
||||
|
||||
|
||||
def _recent_notes(user, limit=3):
|
||||
@@ -55,7 +91,7 @@ def _recent_notes(user, limit=3):
|
||||
def home_page(request):
|
||||
context = {
|
||||
"form": ItemForm(),
|
||||
"palettes": PALETTES,
|
||||
"palettes": _palettes_for_user(request.user),
|
||||
"page_class": "page-dashboard",
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
@@ -75,7 +111,7 @@ def new_note(request):
|
||||
else:
|
||||
context = {
|
||||
"form": form,
|
||||
"palettes": PALETTES,
|
||||
"palettes": _palettes_for_user(request.user),
|
||||
"page_class": "page-dashboard",
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
@@ -125,7 +161,7 @@ def share_note(request, note_id):
|
||||
def set_palette(request):
|
||||
if request.method == "POST":
|
||||
palette = request.POST.get("palette", "")
|
||||
if palette in UNLOCKED_PALETTES:
|
||||
if palette in _unlocked_palettes_for_user(request.user):
|
||||
request.user.palette = palette
|
||||
request.user.save(update_fields=["palette"])
|
||||
if "application/json" in request.headers.get("Accept", ""):
|
||||
@@ -147,7 +183,7 @@ def toggle_applets(request):
|
||||
if request.headers.get("HX-Request"):
|
||||
return render(request, "apps/dashboard/_partials/_applets.html", {
|
||||
"applets": applet_context(request.user, "dashboard"),
|
||||
"palettes": PALETTES,
|
||||
"palettes": _palettes_for_user(request.user),
|
||||
"form": ItemForm(),
|
||||
"recent_notes": _recent_notes(request.user),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user