billscroll should now remember user's position across devices
This commit is contained in:
@@ -2,7 +2,7 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.applets.models import Applet
|
from apps.applets.models import Applet
|
||||||
from apps.drama.models import GameEvent, record
|
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||||
from apps.epic.models import Room
|
from apps.epic.models import Room
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
|
||||||
@@ -133,3 +133,52 @@ class BillscrollViewTest(TestCase):
|
|||||||
def test_passes_page_class_billscroll(self):
|
def test_passes_page_class_billscroll(self):
|
||||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
self.assertEqual(response.context["page_class"], "page-billscroll")
|
self.assertEqual(response.context["page_class"], "page-billscroll")
|
||||||
|
|
||||||
|
def test_passes_scroll_position_zero_when_none_saved(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertEqual(response.context["scroll_position"], 0)
|
||||||
|
|
||||||
|
def test_passes_saved_scroll_position_in_context(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertEqual(response.context["scroll_position"], 250)
|
||||||
|
|
||||||
|
|
||||||
|
class SaveScrollPositionTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@savescroll.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
|
||||||
|
def test_post_saves_scroll_position(self):
|
||||||
|
self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 300},
|
||||||
|
)
|
||||||
|
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
|
||||||
|
self.assertEqual(sp.position, 300)
|
||||||
|
|
||||||
|
def test_post_updates_existing_position(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
|
||||||
|
self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 450},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_returns_204(self):
|
||||||
|
response = self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 100},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
def test_post_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 100},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ urlpatterns = [
|
|||||||
path("", views.billboard, name="billboard"),
|
path("", views.billboard, name="billboard"),
|
||||||
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||||
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
|
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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.shortcuts import redirect, render
|
|||||||
|
|
||||||
from apps.applets.models import Applet, UserApplet
|
from apps.applets.models import Applet, UserApplet
|
||||||
from apps.applets.utils import applet_context
|
from apps.applets.utils import applet_context
|
||||||
from apps.drama.models import GameEvent
|
from apps.drama.models import GameEvent, ScrollPosition
|
||||||
from apps.epic.models import GateSlot, Room, RoomInvite
|
from apps.epic.models import GateSlot, Room, RoomInvite
|
||||||
|
|
||||||
|
|
||||||
@@ -61,9 +61,26 @@ def toggle_billboard_applets(request):
|
|||||||
def room_scroll(request, room_id):
|
def room_scroll(request, room_id):
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
events = room.events.select_related("actor").all()
|
events = room.events.select_related("actor").all()
|
||||||
|
sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
|
||||||
return render(request, "apps/billboard/room_scroll.html", {
|
return render(request, "apps/billboard/room_scroll.html", {
|
||||||
"room": room,
|
"room": room,
|
||||||
"events": events,
|
"events": events,
|
||||||
"viewer": request.user,
|
"viewer": request.user,
|
||||||
|
"scroll_position": sp.position if sp else 0,
|
||||||
"page_class": "page-billscroll",
|
"page_class": "page-billscroll",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def save_scroll_position(request, room_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
from django.http import HttpResponseNotAllowed
|
||||||
|
return HttpResponseNotAllowed(["POST"])
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
position = int(request.POST.get("position", 0))
|
||||||
|
ScrollPosition.objects.update_or_create(
|
||||||
|
user=request.user, room=room,
|
||||||
|
defaults={"position": position},
|
||||||
|
)
|
||||||
|
from django.http import HttpResponse
|
||||||
|
return HttpResponse(status=204)
|
||||||
|
|||||||
@@ -83,6 +83,25 @@ class GameEvent(models.Model):
|
|||||||
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}"
|
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}"
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollPosition(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||||
|
related_name="scroll_positions",
|
||||||
|
)
|
||||||
|
room = models.ForeignKey(
|
||||||
|
"epic.Room", on_delete=models.CASCADE,
|
||||||
|
related_name="scroll_positions",
|
||||||
|
)
|
||||||
|
position = models.PositiveIntegerField(default=0)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("user", "room")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.email} @ {self.room.name}: {self.position}px"
|
||||||
|
|
||||||
|
|
||||||
def record(room, verb, actor=None, **data):
|
def record(room, verb, actor=None, **data):
|
||||||
"""Record a game event in the drama log."""
|
"""Record a game event in the drama log."""
|
||||||
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.db import IntegrityError
|
||||||
|
|
||||||
from apps.drama.models import GameEvent, record
|
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||||
from apps.epic.models import Room
|
from apps.epic.models import Room
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
|
||||||
@@ -42,3 +43,31 @@ class GameEventModelTest(TestCase):
|
|||||||
def test_str_without_actor_shows_system(self):
|
def test_str_without_actor_shows_system(self):
|
||||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||||
self.assertIn("system", str(event))
|
self.assertIn("system", str(event))
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollPositionModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="reader@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
|
||||||
|
def test_can_save_scroll_position(self):
|
||||||
|
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150)
|
||||||
|
self.assertEqual(ScrollPosition.objects.count(), 1)
|
||||||
|
self.assertEqual(sp.position, 150)
|
||||||
|
|
||||||
|
def test_default_position_is_zero(self):
|
||||||
|
sp = ScrollPosition.objects.create(user=self.user, room=self.room)
|
||||||
|
self.assertEqual(sp.position, 0)
|
||||||
|
|
||||||
|
def test_unique_per_user_and_room(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
|
||||||
|
|
||||||
|
def test_upsert_updates_existing_position(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
|
||||||
|
ScrollPosition.objects.update_or_create(
|
||||||
|
user=self.user, room=self.room,
|
||||||
|
defaults={"position": 200},
|
||||||
|
)
|
||||||
|
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
from .base import FunctionalTest
|
from .base import FunctionalTest
|
||||||
@@ -123,6 +125,63 @@ class BillboardScrollTest(FunctionalTest):
|
|||||||
self.assertIn("other", theirs_events[0].text)
|
self.assertIn("other", theirs_events[0].text)
|
||||||
|
|
||||||
|
|
||||||
|
class BillscrollPositionTest(FunctionalTest):
|
||||||
|
"""
|
||||||
|
FT: the user's scroll position in a billscroll is saved to the server
|
||||||
|
and restored when they return to the same scroll from any device/session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.founder = User.objects.create(email="founder@scrollpos.io")
|
||||||
|
self.room = Room.objects.create(name="Persistent Chamber", owner=self.founder)
|
||||||
|
# Enough events to make #id_drama_scroll scrollable
|
||||||
|
for i in range(20):
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||||
|
slot_number=(i % 6) + 1, token_type="coin",
|
||||||
|
token_display=f"Coin-{i}", renewal_days=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scroll_position_persists_across_sessions(self):
|
||||||
|
# 1. Log in and navigate to the room's billscroll
|
||||||
|
self.create_pre_authenticated_session("founder@scrollpos.io")
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
|
||||||
|
)
|
||||||
|
scroll_el = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Force the element scrollable (CSS not served by StaticLiveServerTestCase),
|
||||||
|
# set position, and dispatch scroll event to trigger the debounced save
|
||||||
|
target = 100
|
||||||
|
self.browser.execute_script("""
|
||||||
|
var el = arguments[0];
|
||||||
|
el.style.overflow = 'auto';
|
||||||
|
el.style.height = '150px';
|
||||||
|
el.scrollTop = arguments[1];
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
""", scroll_el, target)
|
||||||
|
|
||||||
|
# 3. Wait for debounce (800ms) + fetch to complete
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# 4. Navigate away and back in a fresh session
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/")
|
||||||
|
self.create_pre_authenticated_session("founder@scrollpos.io")
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. The saved position is reflected in the page's data attribute
|
||||||
|
scroll_el = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||||
|
)
|
||||||
|
restored = int(scroll_el.get_attribute("data-scroll-position"))
|
||||||
|
self.assertEqual(restored, target)
|
||||||
|
|
||||||
|
|
||||||
class BillboardAppletsTest(FunctionalTest):
|
class BillboardAppletsTest(FunctionalTest):
|
||||||
"""
|
"""
|
||||||
FT: billboard page renders three applets in the grid — My Scrolls,
|
FT: billboard page renders three applets in the grid — My Scrolls,
|
||||||
|
|||||||
@@ -2,3 +2,30 @@
|
|||||||
<h2>{{ room.name }}</h2>
|
<h2>{{ room.name }}</h2>
|
||||||
{% include "core/_partials/_scroll.html" %}
|
{% include "core/_partials/_scroll.html" %}
|
||||||
</section>
|
</section>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var scroll = document.getElementById('id_drama_scroll');
|
||||||
|
if (!scroll) return;
|
||||||
|
|
||||||
|
// Restore saved position
|
||||||
|
scroll.scrollTop = {{ scroll_position }};
|
||||||
|
|
||||||
|
// Debounced save on scroll
|
||||||
|
var saveTimer;
|
||||||
|
scroll.addEventListener('scroll', function() {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(function() {
|
||||||
|
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
var token = csrfToken ? csrfToken.value : '';
|
||||||
|
fetch("{% url 'billboard:save_scroll_position' room.id %}", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-CSRFToken': token,
|
||||||
|
},
|
||||||
|
body: 'position=' + Math.round(scroll.scrollTop),
|
||||||
|
});
|
||||||
|
}, 800);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block header_text %}<span>Bill</span>scroll{% endblock header_text %}
|
{% block header_text %}<span>Bill</span>scroll{% endblock header_text %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% csrf_token %}
|
||||||
<div class="billscroll-page">
|
<div class="billscroll-page">
|
||||||
{% include "apps/billboard/_partials/_applet-billboard-scroll.html" %}
|
{% include "apps/billboard/_partials/_applet-billboard-scroll.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% load lyric_extras %}
|
{% load lyric_extras %}
|
||||||
<section id="id_drama_scroll" class="drama-scroll">
|
<section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}">
|
||||||
{% for event in events %}
|
{% for event in events %}
|
||||||
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
||||||
<span class="event-body">
|
<span class="event-body">
|
||||||
|
|||||||
Reference in New Issue
Block a user