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:
russell@unturf.com 2026-02-07 19:01:24 -05:00
parent 20161094fc
commit 7bb2f7fa31
4 changed files with 102 additions and 64 deletions

View file

@ -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

View 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 %}

View file

@ -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)."""

View file

@ -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,
)