From 7bb2f7fa315889824a2b71b833a3ee54a05278b9 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sat, 7 Feb 2026 19:01:24 -0500 Subject: [PATCH] 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. --- remarkbox/static/js/custom.js | 70 +++++++++++------------ remarkbox/templates/snippets/ajax_node.j2 | 32 +++++++++++ remarkbox/tests/test_views.py | 31 +++++----- remarkbox/views/reply_node.py | 33 +++++++---- 4 files changed, 102 insertions(+), 64 deletions(-) create mode 100644 remarkbox/templates/snippets/ajax_node.j2 diff --git a/remarkbox/static/js/custom.js b/remarkbox/static/js/custom.js index ba7573e..883bfe4 100644 --- a/remarkbox/static/js/custom.js +++ b/remarkbox/static/js/custom.js @@ -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 = '' - + '' + escapeHtml(data.author_name) + ' ' - + '' + escapeHtml(data.ago_string) + '' - + ''; - - var contentDiv = '
' - + data.data_html + '
'; - - var statusHtml = ''; - if (!data.approved) { - statusHtml = '(hidden: waiting for approval)'; - } - - 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 diff --git a/remarkbox/templates/snippets/ajax_node.j2 b/remarkbox/templates/snippets/ajax_node.j2 new file mode 100644 index 0000000..8b9a723 --- /dev/null +++ b/remarkbox/templates/snippets/ajax_node.j2 @@ -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 %}
{% endif %} + +
+ +{{ snippets.node_avatar_link(node, size=avatar_size) }} +{{ snippets.author_and_date(node) }} +{{ snippets.status(node) }} + +
+{{ node.data_html | safe }} +
+ +
+{{ snippets.permalinks(node, parent_node, root_node) }} +{{ snippets.actions(node) }} +
+ +
+ {{ forms.edit(node) }} +
+ +
+ {{ forms.reply(node, root_node) }} +
+ +
+ +
+{% if is_conversation_root %}
{% endif %} diff --git a/remarkbox/tests/test_views.py b/remarkbox/tests/test_views.py index c5c190c..6c674d3 100644 --- a/remarkbox/tests/test_views.py +++ b/remarkbox/tests/test_views.py @@ -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("bold text", body["data_html"]) - self.assertIn("italic", body["data_html"]) + self.assertIn("bold text", body["node_html"]) + self.assertIn("italic", 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).""" diff --git a/remarkbox/views/reply_node.py b/remarkbox/views/reply_node.py index 31a167d..fae6d71 100644 --- a/remarkbox/views/reply_node.py +++ b/remarkbox/views/reply_node.py @@ -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, )