Render AJAX replies server-side for seamless insertion.
Instead of building minimal DOM in JS, the reply handler now renders the full node HTML using the same Jinja2 macros (avatar, author, date, actions, edit/reply forms) and returns it as node_html. The JS inserts the pre-rendered fragment directly.
This commit is contained in:
parent
20161094fc
commit
7bb2f7fa31
4 changed files with 102 additions and 64 deletions
|
|
@ -271,49 +271,49 @@ function initAjaxCommentForms() {
|
|||
}
|
||||
|
||||
function insertReply(data, form) {
|
||||
// Build a new node div matching the existing markup structure.
|
||||
var nodeDiv = document.createElement('div');
|
||||
nodeDiv.id = 'node-' + data.id;
|
||||
nodeDiv.className = 'node';
|
||||
// Insert server-rendered node HTML into the correct position.
|
||||
var parentNodeDiv = form.closest('.node');
|
||||
|
||||
var authorSpan = '<span class="author-and-date">'
|
||||
+ '<strong>' + escapeHtml(data.author_name) + '</strong> '
|
||||
+ '<span class="date">' + escapeHtml(data.ago_string) + '</span>'
|
||||
+ '</span>';
|
||||
|
||||
var contentDiv = '<div id="node-data-' + data.id + '" class="node-data">'
|
||||
+ data.data_html + '</div>';
|
||||
|
||||
var statusHtml = '';
|
||||
if (!data.approved) {
|
||||
statusHtml = '<span>(hidden: waiting for approval)</span>';
|
||||
}
|
||||
|
||||
nodeDiv.innerHTML = authorSpan + statusHtml + contentDiv;
|
||||
|
||||
// Insert after the reply form's parent container.
|
||||
var parentNode = form.closest('.node, .remark-box-div-main');
|
||||
if (parentNode) {
|
||||
// Find or create a children container.
|
||||
var childrenContainer = parentNode.querySelector('[id^="node-children-"]');
|
||||
if (parentNodeDiv) {
|
||||
// Replying to a child node — append to parent's children container.
|
||||
var childrenContainer = parentNodeDiv.querySelector('[id^="node-children-"]');
|
||||
if (childrenContainer) {
|
||||
childrenContainer.insertBefore(nodeDiv, childrenContainer.firstChild);
|
||||
childrenContainer.insertAdjacentHTML('beforeend', data.node_html);
|
||||
}
|
||||
} else {
|
||||
// Replying to root — insert into .thread container.
|
||||
var threadDiv = document.querySelector('.thread');
|
||||
if (threadDiv) {
|
||||
threadDiv.insertAdjacentHTML('afterbegin', data.node_html);
|
||||
} else {
|
||||
parentNode.appendChild(nodeDiv);
|
||||
// First comment — replace .no-comments placeholder.
|
||||
var noComments = document.querySelector('.no-comments');
|
||||
if (noComments) {
|
||||
var newThread = document.createElement('div');
|
||||
newThread.className = 'thread';
|
||||
newThread.innerHTML = data.node_html;
|
||||
noComments.replaceWith(newThread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add anchor for the new comment.
|
||||
var anchor = document.createElement('a');
|
||||
anchor.className = 'anchor';
|
||||
anchor.id = data.id;
|
||||
nodeDiv.parentNode.insertBefore(anchor, nodeDiv);
|
||||
// Scroll to and highlight the new node.
|
||||
var newNode = document.getElementById('node-' + data.id);
|
||||
if (newNode) {
|
||||
newNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
newNode.classList.add('focused');
|
||||
setTimeout(function() { newNode.classList.remove('focused'); }, 3000);
|
||||
|
||||
nodeDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Bind auto-grow on new textareas.
|
||||
newNode.querySelectorAll('.common-textarea').forEach(function(textarea) {
|
||||
textarea.addEventListener('input', function() {
|
||||
autoGrow(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Briefly highlight the new comment.
|
||||
nodeDiv.classList.add('focused');
|
||||
setTimeout(function() { nodeDiv.classList.remove('focused'); }, 3000);
|
||||
// Wire up AJAX on any new reply forms.
|
||||
initAjaxCommentForms();
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
|
|
|
|||
32
remarkbox/templates/snippets/ajax_node.j2
Normal file
32
remarkbox/templates/snippets/ajax_node.j2
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{#- Single node fragment for AJAX insertion — mirrors show-node.j2 loop body. -#}
|
||||
{%- import 'snippets.j2' as snippets with context -%}
|
||||
{%- import 'forms.j2' as forms with context -%}
|
||||
{% if is_conversation_root %}<section class="conversation">{% endif %}
|
||||
<a class="anchor" id="{{ node.id }}"></a>
|
||||
<div id="node-{{ node.id }}" class="node node-depth-{{ depth }}">
|
||||
|
||||
{{ snippets.node_avatar_link(node, size=avatar_size) }}
|
||||
{{ snippets.author_and_date(node) }}
|
||||
{{ snippets.status(node) }}
|
||||
|
||||
<div id="node-data-{{ node.id }}" class="node-data">
|
||||
{{ node.data_html | safe }}
|
||||
</div>
|
||||
|
||||
<div class="node-actions">
|
||||
{{ snippets.permalinks(node, parent_node, root_node) }}
|
||||
{{ snippets.actions(node) }}
|
||||
</div>
|
||||
|
||||
<div id="edit-box-{{ node.id }}" class="edit-box-div">
|
||||
{{ forms.edit(node) }}
|
||||
</div>
|
||||
|
||||
<div id="remark-box-{{ node.id }}" class="remark-box-div">
|
||||
{{ forms.reply(node, root_node) }}
|
||||
</div>
|
||||
|
||||
<div id="node-children-{{ node.id }}"></div>
|
||||
|
||||
</div>
|
||||
{% if is_conversation_root %}</section>{% endif %}
|
||||
|
|
@ -2131,16 +2131,13 @@ class AjaxReplyFunctionalTests(FunctionalTests):
|
|||
|
||||
body = res.json
|
||||
self.assertIn("id", body)
|
||||
self.assertIn("data_html", body)
|
||||
self.assertIn("author_name", body)
|
||||
self.assertIn("ago_string", body)
|
||||
self.assertIn("approved", body)
|
||||
self.assertIn("depth", body)
|
||||
self.assertIn("verified", body)
|
||||
self.assertTrue(body["verified"])
|
||||
self.assertIn("parent_id", body)
|
||||
self.assertIn("node_html", body)
|
||||
# Server-rendered HTML contains the comment content.
|
||||
self.assertIn("AJAX reply content", body["node_html"])
|
||||
|
||||
def test_ajax_reply_json_contains_rendered_html(self):
|
||||
"""AJAX response data_html contains server-rendered markdown."""
|
||||
"""AJAX response node_html contains server-rendered markdown."""
|
||||
self._log_in_test_user()
|
||||
|
||||
res = self.testapp.post(
|
||||
|
|
@ -2154,8 +2151,8 @@ class AjaxReplyFunctionalTests(FunctionalTests):
|
|||
)
|
||||
|
||||
body = res.json
|
||||
self.assertIn("<strong>bold text</strong>", body["data_html"])
|
||||
self.assertIn("<em>italic</em>", body["data_html"])
|
||||
self.assertIn("<strong>bold text</strong>", body["node_html"])
|
||||
self.assertIn("<em>italic</em>", body["node_html"])
|
||||
|
||||
def test_ajax_reply_json_has_correct_parent_id(self):
|
||||
"""AJAX response parent_id matches the node replied to."""
|
||||
|
|
@ -2174,8 +2171,8 @@ class AjaxReplyFunctionalTests(FunctionalTests):
|
|||
body = res.json
|
||||
self.assertEqual(body["parent_id"], self.root_id)
|
||||
|
||||
def test_ajax_reply_json_has_author_name(self):
|
||||
"""AJAX response includes the authenticated user's display name."""
|
||||
def test_ajax_reply_html_has_author_name(self):
|
||||
"""AJAX response node_html includes the authenticated user's display name."""
|
||||
self._log_in_test_user()
|
||||
|
||||
res = self.testapp.post(
|
||||
|
|
@ -2189,8 +2186,8 @@ class AjaxReplyFunctionalTests(FunctionalTests):
|
|||
)
|
||||
|
||||
body = res.json
|
||||
# User name should be the email-derived name or the set display name
|
||||
self.assertTrue(len(body["author_name"]) > 0)
|
||||
# Server-rendered HTML should contain author-and-date span.
|
||||
self.assertIn("author-and-date", body["node_html"])
|
||||
|
||||
def test_non_ajax_reply_still_redirects(self):
|
||||
"""Non-AJAX reply (no X-Requested-With) returns 302 redirect."""
|
||||
|
|
@ -2334,8 +2331,8 @@ class AjaxAnonymousReplyFunctionalTests(FunctionalTests):
|
|||
)
|
||||
|
||||
body = res.json
|
||||
self.assertEqual(body["author_name"], "AjaxAnon")
|
||||
self.assertTrue(body["verified"])
|
||||
self.assertIn("node_html", body)
|
||||
self.assertIn("AjaxAnon", body["node_html"])
|
||||
|
||||
def test_ajax_anonymous_reply_default_name(self):
|
||||
"""Anonymous AJAX reply without name uses 'Anonymous'."""
|
||||
|
|
@ -2349,7 +2346,7 @@ class AjaxAnonymousReplyFunctionalTests(FunctionalTests):
|
|||
)
|
||||
|
||||
body = res.json
|
||||
self.assertEqual(body["author_name"], "Anonymous")
|
||||
self.assertIn("Anonymous", body["node_html"])
|
||||
|
||||
def test_non_ajax_anonymous_reply_still_redirects(self):
|
||||
"""Non-AJAX anonymous reply returns redirect (baseline behavior)."""
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from pyramid.csrf import check_csrf_token
|
|||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
from pyramid.renderers import render
|
||||
from pyramid.response import Response
|
||||
|
||||
from . import (
|
||||
|
|
@ -139,24 +140,32 @@ def reply_node(request):
|
|||
msg = ("Your post was successful!", "success")
|
||||
request.session.flash(msg)
|
||||
|
||||
# AJAX request — return JSON instead of redirect (capability-driven presentation).
|
||||
# AJAX request — return server-rendered HTML (capability-driven presentation).
|
||||
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
||||
if is_ajax and (user_surrogate or child.verified):
|
||||
author_name = ""
|
||||
if child.user:
|
||||
author_name = child.user.name or ""
|
||||
elif child.user_surrogate:
|
||||
author_name = child.user_surrogate.name or "Anonymous"
|
||||
depth = child.graph_depth
|
||||
is_conversation_root = (depth == 1)
|
||||
if depth > 1:
|
||||
avatar_size = int(request.avatar_size * 0.75)
|
||||
else:
|
||||
avatar_size = request.avatar_size
|
||||
node_html = render(
|
||||
'snippets/ajax_node.j2',
|
||||
{
|
||||
'node': child,
|
||||
'parent_node': request.node,
|
||||
'root_node': request.node.root,
|
||||
'is_conversation_root': is_conversation_root,
|
||||
'depth': depth,
|
||||
'avatar_size': avatar_size,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return Response(
|
||||
json={
|
||||
"id": str(child.id),
|
||||
"parent_id": str(child.parent_id) if child.parent_id else None,
|
||||
"data_html": child.data_html,
|
||||
"author_name": author_name,
|
||||
"ago_string": child.human_created_timestamp,
|
||||
"approved": child.approved,
|
||||
"depth": child.graph_depth,
|
||||
"verified": child.verified,
|
||||
"node_html": node_html,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue