diff --git a/apps/blog/tests/test_views.py b/apps/blog/tests/test_views.py index 2f07e5b..3ae9203 100644 --- a/apps/blog/tests/test_views.py +++ b/apps/blog/tests/test_views.py @@ -1,3 +1,5 @@ +import re + import pytest from taggit.models import Tag @@ -138,6 +140,54 @@ def test_article_page_renders_approved_comments_and_reply_form(client, home_page assert "Top level" in html assert "Reply" in html assert f'name="parent_id" value="{comment.id}"' in html + match = re.search(r'id="comments-empty-state"[^>]*class="([^"]+)"', html) + assert match is not None + assert "hidden" in match.group(1).split() + + +@pytest.mark.django_db +def test_article_page_shows_empty_state_when_no_approved_comments(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Main", + slug="main", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.get("/articles/main/") + html = resp.content.decode() + + assert resp.status_code == 200 + assert 'id="comments-empty-state"' in html + assert "No comments yet. Be the first to comment." in html + + +@pytest.mark.django_db +def test_article_page_loads_comment_client_script(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Main", + slug="main", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.get("/articles/main/") + html = resp.content.decode() + + assert resp.status_code == 200 + assert 'src="/static/js/comments.js"' in html @pytest.mark.django_db diff --git a/apps/comments/tests/test_v2.py b/apps/comments/tests/test_v2.py index 7f964ca..8e33690 100644 --- a/apps/comments/tests/test_v2.py +++ b/apps/comments/tests/test_v2.py @@ -91,6 +91,7 @@ def test_htmx_post_returns_form_plus_oob_comment_when_approved(client, _article) # OOB swap appends the comment to #comments-list assert "hx-swap-oob" in content assert "Hello world" in content + assert 'id="comments-empty-state" hx-swap-oob="delete"' in content comment = Comment.objects.get() assert comment.is_approved is True @@ -132,8 +133,8 @@ def test_htmx_reply_returns_oob_reply_when_approved(client, _article, approved_c ) content = resp.content.decode() assert resp.status_code == 200 - # OOB targets the sibling .replies-container of the parent comment article - assert f'hx-swap-oob="beforeend:#comment-{approved_comment.id} ~ .replies-container"' in content + # OOB targets a stable, explicit replies container for the parent comment. + assert f'hx-swap-oob="beforeend:#replies-for-{approved_comment.id}"' in content # Verify content is rendered (not empty due to context mismatch) assert "Replier" in content assert "Nice reply" in content @@ -165,6 +166,30 @@ def test_htmx_error_with_tampered_parent_id_falls_back_to_main_form(client, _art assert b"comment-form-container" in resp.content +@pytest.mark.django_db +def test_htmx_invalid_reply_rerenders_reply_form_with_values(client, _article, approved_comment): + """Invalid reply keeps user input and returns the reply form container.""" + cache.clear() + resp = client.post( + "/comments/post/", + { + "article_id": _article.id, + "parent_id": approved_comment.id, + "author_name": "Reply User", + "author_email": "reply@example.com", + "body": " ", + "honeypot": "", + }, + HTTP_HX_REQUEST="true", + ) + assert resp.status_code == 200 + content = resp.content.decode() + assert f'id="reply-form-container-{approved_comment.id}"' in content + assert "Comment form errors" in content + assert 'value="Reply User"' in content + assert "reply@example.com" in content + + # ── Turnstile Integration ──────────────────────────────────────────────────── diff --git a/apps/comments/views.py b/apps/comments/views.py index 228f3ff..9f45d05 100644 --- a/apps/comments/views.py +++ b/apps/comments/views.py @@ -133,6 +133,7 @@ class CommentCreateView(View): "comment": parent, "page": article, "turnstile_site_key": _turnstile_site_key(), "reply_form_errors": form.errors, + "reply_form": form, } return _add_vary_header(render(request, "comments/_reply_form.html", ctx)) ctx = { @@ -144,7 +145,7 @@ class CommentCreateView(View): def _render_htmx_success(self, request, article, comment): """Return fresh form + OOB-appended comment (if approved).""" tsk = _turnstile_site_key() - oob_html = "" + oob_parts = [] if comment.is_approved: ctx = _comment_template_context(comment, article, request) if comment.parent_id: @@ -152,17 +153,14 @@ class CommentCreateView(View): reply_ctx = ctx.copy() reply_ctx["reply"] = reply_ctx.pop("comment") comment_html = render_to_string("comments/_reply.html", reply_ctx, request) - # .replies-container is now a sibling of #comment-{id} - oob_html = ( - f'
{comment_html}
' + oob_parts.append( + f'
{comment_html}
' ) else: comment_html = render_to_string("comments/_comment.html", ctx, request) - oob_html = ( - f'
' - f"{comment_html}
" - ) + oob_parts.append(f'
{comment_html}
') + # Ensure stale empty-state copy is removed when the first approved comment appears. + oob_parts.append('
') if comment.parent_id: parent = Comment.objects.filter(pk=comment.parent_id, article=article).first() @@ -180,7 +178,7 @@ class CommentCreateView(View): "page": article, "turnstile_site_key": tsk, "success_message": msg, }, request) - resp = HttpResponse(form_html + oob_html) + resp = HttpResponse(form_html + "".join(oob_parts)) return _add_vary_header(resp) def post(self, request): diff --git a/static/js/comments.js b/static/js/comments.js new file mode 100644 index 0000000..0149e1f --- /dev/null +++ b/static/js/comments.js @@ -0,0 +1,83 @@ +(function () { + function renderTurnstileWidgets(root) { + if (!root || !window.turnstile || typeof window.turnstile.render !== "function") { + return; + } + + const widgets = []; + if (root.matches && root.matches(".cf-turnstile")) { + widgets.push(root); + } + if (root.querySelectorAll) { + widgets.push(...root.querySelectorAll(".cf-turnstile")); + } + + widgets.forEach(function (widget) { + if (widget.dataset.turnstileRendered === "true") { + return; + } + if (widget.querySelector("iframe")) { + widget.dataset.turnstileRendered = "true"; + return; + } + + const sitekey = widget.dataset.sitekey; + if (!sitekey) { + return; + } + + const options = { + sitekey: sitekey, + theme: widget.dataset.theme || "auto", + }; + if (widget.dataset.size) { + options.size = widget.dataset.size; + } + if (widget.dataset.action) { + options.action = widget.dataset.action; + } + if (widget.dataset.appearance) { + options.appearance = widget.dataset.appearance; + } + + window.turnstile.render(widget, options); + widget.dataset.turnstileRendered = "true"; + }); + } + + function syncCommentsEmptyState() { + const emptyState = document.getElementById("comments-empty-state"); + const commentsList = document.getElementById("comments-list"); + if (!emptyState || !commentsList) { + return; + } + + const hasComments = commentsList.querySelector("[data-comment-item='true']") !== null; + emptyState.classList.toggle("hidden", hasComments); + } + + function onTurnstileReady(root) { + if (!window.turnstile || typeof window.turnstile.ready !== "function") { + return; + } + window.turnstile.ready(function () { + renderTurnstileWidgets(root || document); + }); + } + + document.addEventListener("DOMContentLoaded", function () { + syncCommentsEmptyState(); + onTurnstileReady(document); + }); + + document.addEventListener("htmx:afterSwap", function (event) { + const target = event.detail && event.detail.target ? event.detail.target : document; + syncCommentsEmptyState(); + onTurnstileReady(target); + }); + + window.addEventListener("load", function () { + syncCommentsEmptyState(); + onTurnstileReady(document); + }); +})(); diff --git a/templates/base.html b/templates/base.html index bfb3e47..e3da92d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -18,6 +18,7 @@ + {% if turnstile_site_key %}{% endif %} diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html index 7c74ba6..345a946 100644 --- a/templates/blog/article_page.html +++ b/templates/blog/article_page.html @@ -141,16 +141,15 @@ {% if page.comments_enabled %}
-

Comments

+

Comments

+

+ {{ approved_comments|length }} public comment{{ approved_comments|length|pluralize }} +

- {% if approved_comments %} - {% include "comments/_comment_list.html" %} - {% else %} -
-
+ {% include "comments/_comment_list.html" %} +

No comments yet. Be the first to comment.

- {% endif %} {% include "comments/_comment_form.html" %}
diff --git a/templates/comments/_comment.html b/templates/comments/_comment.html index 5777495..dc0d122 100644 --- a/templates/comments/_comment.html +++ b/templates/comments/_comment.html @@ -1,40 +1,36 @@
- -
-
-
-
-
- {{ comment.author_name }} - -
-
- {{ comment.body|linebreaks }} -
-
+
+
+ {{ comment.author_name }} + +
+ +
+ {{ comment.body|linebreaks }}
-
+
{% include "comments/_reactions.html" with comment=comment counts=comment.reaction_counts user_reacted=comment.user_reacted %} - -
- - - - - Reply - - -
+
+ + Reply + + +
{% include "comments/_reply_form.html" with page=page comment=comment %}
- -
+
{% for reply in comment.replies.all %} {% include "comments/_reply.html" with reply=reply %} {% endfor %} diff --git a/templates/comments/_comment_form.html b/templates/comments/_comment_form.html index bb54e97..a6a9aa4 100644 --- a/templates/comments/_comment_form.html +++ b/templates/comments/_comment_form.html @@ -1,64 +1,93 @@ {% load static %} -
-
-

Join the conversation

-

Add your fresh comment below

+
+
+

Leave a comment

+

+ Keep it constructive. Your email will not be shown publicly. +

{% if success_message %} -
- {{ success_message }} -
+
+ {{ success_message }} +
{% endif %} {% if comment_form.errors %} -
-
There were some errors:
-
    - {% if comment_form.non_field_errors %} - {% for error in comment_form.non_field_errors %}
  • {{ error }}
  • {% endfor %} - {% endif %} - {% for field in comment_form %} - {% if field.errors %} - {% for error in field.errors %}
  • {{ field.label }}: {{ error }}
  • {% endfor %} +
    +
    There were some errors:
    +
      + {% if comment_form.non_field_errors %} + {% for error in comment_form.non_field_errors %}
    • {{ error }}
    • {% endfor %} {% endif %} - {% endfor %} -
    -
    + {% for field in comment_form %} + {% if field.errors %} + {% for error in field.errors %}
  • {{ field.label }}: {{ error }}
  • {% endfor %} + {% endif %} + {% endfor %} +
+
{% endif %} -
+ {% csrf_token %} -
-
- - + +
+
+ +
-
- - +
+ +
-
- - + +
+ +
+ - + {% if turnstile_site_key %} -
+
{% endif %} -
-
diff --git a/templates/comments/_comment_list.html b/templates/comments/_comment_list.html index 14dc428..8bb07ff 100644 --- a/templates/comments/_comment_list.html +++ b/templates/comments/_comment_list.html @@ -1,4 +1,4 @@ -
{% for comment in approved_comments %} {% include "comments/_comment.html" with comment=comment page=page %} diff --git a/templates/comments/_reply.html b/templates/comments/_reply.html index 8799cb6..ac2833a 100644 --- a/templates/comments/_reply.html +++ b/templates/comments/_reply.html @@ -1,14 +1,9 @@ -
-
-
-
-
- {{ reply.author_name }} - -
-
- {{ reply.body|linebreaks }} -
-
+
+
+ {{ reply.author_name }} + +
+
+ {{ reply.body|linebreaks }}
diff --git a/templates/comments/_reply_form.html b/templates/comments/_reply_form.html index f11a2fa..3909aa5 100644 --- a/templates/comments/_reply_form.html +++ b/templates/comments/_reply_form.html @@ -1,46 +1,77 @@ {% load static %}
-

Reply to {{ comment.author_name }}

- +

Reply to {{ comment.author_name }}

+ {% if reply_success_message %} -
- {{ reply_success_message }} -
+
+ {{ reply_success_message }} +
{% endif %} {% if reply_form_errors %} -
-
Errors:
-
    - {% for field, errors in reply_form_errors.items %} - {% for error in errors %}
  • {{ error }}
  • {% endfor %} - {% endfor %} -
-
+
+
Errors:
+
    + {% for field, errors in reply_form_errors.items %} + {% for error in errors %}
  • {{ error }}
  • {% endfor %} + {% endfor %} +
+
{% endif %} -
+ {% csrf_token %} -
- - + +
+ +
- + + + - + {% if turnstile_site_key %} -
+
{% endif %} -
- +
+