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:
parent
2ae2863a4c
commit
68089dff5b
5 changed files with 288 additions and 0 deletions
34
docs/tickets/mps-0.md
Normal file
34
docs/tickets/mps-0.md
Normal 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
|
||||
100
make_post_sell/static/js/comments.js
Normal file
100
make_post_sell/static/js/comments.js
Normal 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;
|
||||
}
|
||||
})();
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue