Add AJAX comment submission to preserve media playback

Comments now submit via fetch() when JS is available, returning JSON
instead of triggering a full page reload that kills video/audio playback.
Falls back to the existing POST+redirect when JS is disabled.
This commit is contained in:
russell@unturf.com 2026-02-07 11:19:58 -05:00
parent 2ae2863a4c
commit 68089dff5b
5 changed files with 288 additions and 0 deletions

34
docs/tickets/mps-0.md Normal file
View file

@ -0,0 +1,34 @@
# MPS-0: AJAX Comment Submission (No Page Refresh)
## Problem
When playing video or audio on product/content pages, leaving a comment
triggers a full page reload (`POST /comments/new` followed by `HTTPFound`
redirect). This kills media playback mid-stream.
## Solution
Progressive enhancement: when JavaScript is available, intercept the comment
form submission and use `fetch()` with the `X-Requested-With: XMLHttpRequest`
header. The server detects this header and returns JSON instead of a redirect.
The client inserts the new comment into the DOM without reloading.
When JavaScript is disabled, the existing form POST and redirect behaviour is
unchanged.
## Files Changed
| File | Change |
|------|--------|
| `make_post_sell/views/comment.py` | Return JSON when AJAX header is present |
| `make_post_sell/static/js/comments.js` | New: fetch-based form handler, DOM insertion |
| `make_post_sell/templates/snippets/comments.j2` | Add `id="comments-list"`, load `comments.js` |
| `make_post_sell/tests/test_functional.py` | AJAX comment endpoint tests |
## Verification
1. `make test` -- all existing and new tests pass
2. Play a video, leave a comment, video keeps playing
3. Disable JS, leave a comment, redirect behaviour works as before
4. Comment count updates without reload
5. Pending-approval banner shows for non-auto-approved comments

View file

@ -0,0 +1,100 @@
// AJAX comment submission — progressive enhancement.
// When JS is available, intercepts the comment form POST and submits
// via fetch so media playback is not interrupted by a page reload.
// When JS is disabled, the form falls back to the normal POST + redirect.
(function() {
var form = document.querySelector('.comment-form form');
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
var formData = new FormData(form);
var submitBtn = form.querySelector('input[type="submit"]');
var originalLabel = submitBtn.value;
submitBtn.disabled = true;
submitBtn.value = 'Sending...';
fetch(form.action, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(response) {
if (!response.ok) {
// Auth redirect or server error — fall back to regular form submit
submitBtn.disabled = false;
submitBtn.value = originalLabel;
form.submit();
return;
}
return response.json();
})
.then(function(data) {
if (!data) return;
insertComment(data);
form.querySelector('textarea[name="data"]').value = '';
updateCommentCount(1);
})
.catch(function() {
// Network error — fall back to regular form submit
submitBtn.disabled = false;
submitBtn.value = originalLabel;
form.submit();
})
.finally(function() {
submitBtn.disabled = false;
submitBtn.value = originalLabel;
});
});
function insertComment(data) {
var commentDiv = document.createElement('div');
commentDiv.id = 'comment-' + data.id;
commentDiv.className = 'comment';
commentDiv.style.marginLeft = (data.depth * 20) + 'px';
commentDiv.style.marginBottom = '20px';
var header = '<div class="comment-header">'
+ '<strong>' + escapeHtml(data.author_name) + '</strong> '
+ '<span class="comment-date">' + escapeHtml(data.ago_string) + '</span>';
if (!data.approved) {
header += ' <span class="comment-status comment-pending">[Pending Approval]</span>';
}
header += '</div>';
var content = '<div class="comment-content">' + data.data_html + '</div>';
commentDiv.innerHTML = header + content;
var commentsList = document.getElementById('comments-list');
if (commentsList) {
commentsList.insertBefore(commentDiv, commentsList.firstChild);
}
commentDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function updateCommentCount(delta) {
var heading = document.querySelector('.comments-section h3');
if (!heading) {
var section = document.querySelector('.comments-section');
if (!section) return;
var firstChild = document.getElementById('comments-list') || section.firstChild;
heading = document.createElement('h3');
heading.textContent = 'Comments & Reviews (1)';
section.insertBefore(heading, firstChild);
return;
}
var match = heading.textContent.match(/\((\d+)\)/);
if (match) {
var count = parseInt(match[1]) + delta;
heading.textContent = heading.textContent.replace(/\(\d+\)/, '(' + count + ')');
}
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
})();

View file

@ -66,11 +66,13 @@
<p><a href="/join-or-log-in">Sign in</a> to leave a comment.</p>
{% endif %}
<div id="comments-list">
{% if comments %}
{% for comment in comments %}
{{ render_comment(comment, shop, request) }}
{% endfor %}
{% endif %}
</div>
<!-- Comment Form -->
{% if request.user.authenticated %}
@ -103,4 +105,5 @@
{% endif %}
{% endif %}
</div>
<script src="/static/js/comments.js"></script>
{% endif %}

View file

@ -2229,3 +2229,138 @@ class AuthenticatedFunctionalTests(FunctionalTests):
# Should handle gracefully and return [accepted]
self.assertIn("[accepted]", response.body.decode())
# ── AJAX comment submission (MPS-0) ──────────────────────────────
def _create_shop_and_product_for_comments(self):
"""Helper: create a shop + product with comments enabled, stay logged in."""
shop = self._create_shop_helper(
user_creds=self.user1_creds, shop_params=self.shop1_params
)
# Enable comments on the shop
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "comment-settings",
"comments-enabled-checkbox": "on",
"submit": "Save Settings",
},
status=302,
)
# Create a product
redirect_res = self.testapp.post(
f"/p/new?shop_id={shop.id}", self.product1_params
)
res = redirect_res.follow()
self.assertIn("Great, next you may upload files.", res.body.decode())
# Fetch the product from DB
products = get_all_products(self.dbsession).all()
self.assertTrue(len(products) > 0)
product = products[0]
return shop, product
@patch("smtplib.SMTP")
def test_ajax_comment_returns_json(self, mock_smtp):
"""AJAX POST to /comments/new returns 201 JSON with comment data."""
shop, product = self._create_shop_and_product_for_comments()
res = self.testapp.post(
"/comments/new",
{
"product_id": str(product.id),
"parent_id": "",
"data": "This is an AJAX comment",
},
headers={"X-Requested-With": "XMLHttpRequest"},
status=201,
)
# Should be JSON
data = res.json
self.assertIn("id", data)
self.assertIn("data_html", data)
self.assertIn("author_name", data)
self.assertIn("ago_string", data)
self.assertIn("approved", data)
self.assertIn("depth", data)
self.assertIn("parent_id", data)
# Comment was auto-approved (shop owner)
self.assertTrue(data["approved"])
self.assertEqual(data["depth"], 0)
self.assertIsNone(data["parent_id"])
self.assertIn("AJAX comment", data["data_html"])
@patch("smtplib.SMTP")
def test_non_ajax_comment_returns_redirect(self, mock_smtp):
"""Regular POST to /comments/new still returns 302 redirect."""
shop, product = self._create_shop_and_product_for_comments()
res = self.testapp.post(
"/comments/new",
{
"product_id": str(product.id),
"parent_id": "",
"data": "This is a regular comment",
},
status=302,
)
# Should redirect to the product page
self.assertIn(str(product.id), res.location)
self.assertIn("#comment-", res.location)
@patch("smtplib.SMTP")
def test_ajax_comment_missing_data_returns_redirect(self, mock_smtp):
"""AJAX POST with missing comment body falls back to redirect."""
shop, product = self._create_shop_and_product_for_comments()
res = self.testapp.post(
"/comments/new",
{
"product_id": str(product.id),
"parent_id": "",
"data": "",
},
headers={"X-Requested-With": "XMLHttpRequest"},
status=302,
)
@patch("smtplib.SMTP")
def test_ajax_comment_pending_approval(self, mock_smtp):
"""AJAX comment on a shop requiring approval shows approved=false for non-owner."""
shop, product = self._create_shop_and_product_for_comments()
# Enable comment approval requirement
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "comment-settings",
"comments-enabled-checkbox": "on",
"comments-require-approval-checkbox": "on",
"submit": "Save Settings",
},
status=302,
)
# Log out shop owner, log in as a different user
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
"/comments/new",
{
"product_id": str(product.id),
"parent_id": "",
"data": "Comment awaiting approval",
},
headers={"X-Requested-With": "XMLHttpRequest"},
status=201,
)
data = res.json
self.assertFalse(data["approved"])

View file

@ -1,5 +1,6 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound, HTTPNotFound, HTTPForbidden
from pyramid.response import Response
from . import (
get_referer_or_home,
@ -98,6 +99,21 @@ def comment_new(request):
request.dbsession.add(comment)
request.dbsession.flush()
# AJAX request — return JSON instead of redirect
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return Response(
json={
"id": str(comment.id),
"data_html": comment.data_html,
"author_name": request.user.name or "Anonymous",
"ago_string": comment.ago_string,
"approved": comment.approved,
"depth": comment.graph_depth,
"parent_id": str(comment.parent_id) if comment.parent_id else None,
},
status_code=201,
)
if comment.approved:
request.session.flash(("Comment posted successfully", "success"))
else: