feat(comments): v2 — HTMX, Turnstile, reactions, design refresh #44

Merged
mark merged 5 commits from feature/comments-v2 into main 2026-03-04 00:04:43 +00:00
8 changed files with 188 additions and 79 deletions
Showing only changes of commit c01fc14258 - Show all commits

View File

@@ -67,30 +67,37 @@ def _post_comment(client, article, extra=None, htmx=False):
@pytest.mark.django_db
def test_htmx_post_returns_partial_on_success(client, _article):
"""HTMX POST with Turnstile disabled returns moderation notice partial."""
def test_htmx_post_returns_form_with_moderation_on_success(client, _article):
"""HTMX POST with Turnstile disabled returns fresh form + moderation message."""
resp = _post_comment(client, _article, htmx=True)
assert resp.status_code == 200
assert b"awaiting moderation" in resp.content
# Response swaps the form container (contains form + success message)
assert b"comment-form-container" in resp.content
assert "HX-Request" in resp["Vary"]
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_htmx_post_returns_comment_partial_when_turnstile_passes(client, _article):
"""HTMX POST with successful Turnstile returns comment partial for append."""
def test_htmx_post_returns_form_plus_oob_comment_when_approved(client, _article):
"""HTMX POST with successful Turnstile returns fresh form + OOB comment."""
with patch("apps.comments.views._verify_turnstile", return_value=True):
resp = _post_comment(client, _article, extra={"cf-turnstile-response": "tok"}, htmx=True)
assert resp.status_code == 200
assert b"Hello world" in resp.content
assert b"comment-" in resp.content
content = resp.content.decode()
# Fresh form container is the primary response
assert "comment-form-container" in content
assert "Comment posted!" in content
# OOB swap appends the comment to #comments-list
assert "hx-swap-oob" in content
assert "Hello world" in content
comment = Comment.objects.get()
assert comment.is_approved is True
@pytest.mark.django_db
def test_htmx_post_returns_form_with_errors_on_invalid(client, _article):
"""HTMX POST with invalid data returns form partial with HTTP 200 (HTMX 2 requires 2xx for swap)."""
"""HTMX POST with invalid data returns form with errors (HTTP 200)."""
cache.clear()
resp = client.post(
"/comments/post/",
@@ -98,10 +105,43 @@ def test_htmx_post_returns_form_with_errors_on_invalid(client, _article):
HTTP_HX_REQUEST="true",
)
assert resp.status_code == 200
assert b"comment-form-container" in resp.content
assert b"Comment form errors" in resp.content
assert "HX-Request" in resp["Vary"]
assert Comment.objects.count() == 0
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_htmx_reply_returns_oob_reply_when_approved(client, _article, approved_comment):
"""Approved reply via HTMX returns compact reply partial via OOB swap."""
cache.clear()
with patch("apps.comments.views._verify_turnstile", return_value=True):
resp = client.post(
"/comments/post/",
{
"article_id": _article.id,
"parent_id": approved_comment.id,
"author_name": "Replier",
"author_email": "r@r.com",
"body": "Nice reply",
"honeypot": "",
"cf-turnstile-response": "tok",
},
HTTP_HX_REQUEST="true",
)
content = resp.content.decode()
assert resp.status_code == 200
# OOB targets the parent's replies-container
assert f"#comment-{approved_comment.id}" in content
assert "hx-swap-oob" in content
# Reply uses compact markup (no nested reply form)
assert "Reply posted!" in content
reply = Comment.objects.exclude(pk=approved_comment.pk).get()
assert reply.parent_id == approved_comment.id
assert reply.is_approved is True
@pytest.mark.django_db
def test_non_htmx_post_still_redirects(client, _article):
"""Non-HTMX POST continues to redirect (progressive enhancement)."""

View File

@@ -11,6 +11,7 @@ from django.db import IntegrityError
from django.db.models import Count, Prefetch
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.utils.cache import patch_vary_headers
from django.views import View
from django.views.decorators.http import require_GET, require_POST
@@ -72,25 +73,27 @@ def _get_session_key(request) -> str:
return (session.session_key or "") if session else ""
def _annotate_reaction_counts(comments, session_key: str = ""):
def _turnstile_site_key():
return getattr(settings, "TURNSTILE_SITE_KEY", "")
def _annotate_reaction_counts(comments, session_key=""):
"""Hydrate each comment with reaction_counts dict and user_reacted set."""
comment_ids = [c.id for c in comments]
if not comment_ids:
return comments
# Aggregate counts per comment per type
counts_qs = (
CommentReaction.objects.filter(comment_id__in=comment_ids)
.values("comment_id", "reaction_type")
.annotate(count=Count("id"))
)
counts_map: dict[int, dict[str, int]] = {}
counts_map = {}
for row in counts_qs:
counts_map.setdefault(row["comment_id"], {"heart": 0, "plus_one": 0})
counts_map[row["comment_id"]][row["reaction_type"]] = row["count"]
# User's own reactions
user_map: dict[int, set[str]] = {}
user_map = {}
if session_key:
user_qs = CommentReaction.objects.filter(
comment_id__in=comment_ids, session_key=session_key
@@ -107,27 +110,69 @@ def _annotate_reaction_counts(comments, session_key: str = ""):
def _comment_template_context(comment, article, request):
"""Build template context for a single comment partial."""
session_key = _get_session_key(request)
_annotate_reaction_counts([comment], session_key)
_annotate_reaction_counts([comment], _get_session_key(request))
return {
"comment": comment,
"page": article,
"turnstile_site_key": getattr(settings, "TURNSTILE_SITE_KEY", ""),
"turnstile_site_key": _turnstile_site_key(),
}
class CommentCreateView(View):
def _render_article_with_errors(self, request, article, form):
if _is_htmx(request):
ctx = {"comment_form": form, "page": article}
ctx["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
resp = render(request, "comments/_comment_form.html", ctx, status=200)
return _add_vary_header(resp)
context = article.get_context(request)
context["page"] = article
context["comment_form"] = form
context["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
return render(request, "blog/article_page.html", context, status=200)
def _render_htmx_error(self, request, article, form):
"""Return error form partial for HTMX — swaps the form container itself."""
parent_id = request.POST.get("parent_id")
if parent_id:
parent = Comment.objects.filter(pk=parent_id, article=article).first()
ctx = {
"comment": parent, "page": article,
"turnstile_site_key": _turnstile_site_key(),
"reply_form_errors": form.errors,
}
return _add_vary_header(render(request, "comments/_reply_form.html", ctx))
ctx = {
"comment_form": form, "page": article,
"turnstile_site_key": _turnstile_site_key(),
}
return _add_vary_header(render(request, "comments/_comment_form.html", ctx))
def _render_htmx_success(self, request, article, comment):
"""Return fresh form + OOB-appended comment (if approved)."""
tsk = _turnstile_site_key()
oob_html = ""
if comment.is_approved:
ctx = _comment_template_context(comment, article, request)
if comment.parent_id:
comment_html = render_to_string("comments/_reply.html", ctx, request)
oob_html = (
f'<div hx-swap-oob="beforeend:#comment-{comment.parent_id} '
f'.replies-container">{comment_html}</div>'
)
else:
comment_html = render_to_string("comments/_comment.html", ctx, request)
oob_html = (
f'<div hx-swap-oob="beforeend:#comments-list">'
f"{comment_html}</div>"
)
if comment.parent_id:
parent = Comment.objects.filter(pk=comment.parent_id, article=article).first()
msg = "Reply posted!" if comment.is_approved else "Your reply is awaiting moderation."
form_html = render_to_string("comments/_reply_form.html", {
"comment": parent, "page": article,
"turnstile_site_key": tsk, "reply_success_message": msg,
}, request)
else:
msg = (
"Comment posted!" if comment.is_approved
else "Your comment has been posted and is awaiting moderation."
)
form_html = render_to_string("comments/_comment_form.html", {
"page": article, "turnstile_site_key": tsk, "success_message": msg,
}, request)
resp = HttpResponse(form_html + oob_html)
return _add_vary_header(resp)
def post(self, request):
ip = client_ip_from_request(request)
@@ -168,22 +213,15 @@ class CommentCreateView(View):
comment.full_clean()
except ValidationError:
form.add_error(None, "Reply depth exceeds the allowed limit")
return self._render_article_with_errors(request, article, form)
if _is_htmx(request):
return self._render_htmx_error(request, article, form)
context = article.get_context(request)
context.update({"page": article, "comment_form": form})
return render(request, "blog/article_page.html", context, status=200)
comment.save()
if _is_htmx(request):
ctx = _comment_template_context(comment, article, request)
if comment.is_approved:
resp = render(request, "comments/_comment.html", ctx)
if comment.parent_id:
# Tell HTMX to retarget: insert reply inside parent comment
resp["HX-Retarget"] = f"#comment-{comment.parent_id} .replies-container"
resp["HX-Reswap"] = "beforeend"
else:
resp = render(request, "comments/_comment_success.html", {
"message": "Your comment has been posted and is awaiting moderation.",
})
return _add_vary_header(resp)
return self._render_htmx_success(request, article, comment)
messages.success(
request,
@@ -191,7 +229,11 @@ class CommentCreateView(View):
)
return redirect(f"{article.url}?commented=1")
return self._render_article_with_errors(request, article, form)
if _is_htmx(request):
return self._render_htmx_error(request, article, form)
context = article.get_context(request)
context.update({"page": article, "comment_form": form})
return render(request, "blog/article_page.html", context, status=200)
@require_GET
@@ -211,13 +253,12 @@ def comment_poll(request, article_id):
.order_by("created_at", "id")
)
session_key = _get_session_key(request)
_annotate_reaction_counts(comments, session_key)
_annotate_reaction_counts(comments, _get_session_key(request))
resp = render(request, "comments/_comment_list_inner.html", {
"approved_comments": comments,
"page": article,
"turnstile_site_key": getattr(settings, "TURNSTILE_SITE_KEY", ""),
"turnstile_site_key": _turnstile_site_key(),
})
return _add_vary_header(resp)

View File

@@ -23,12 +23,12 @@ def _submit_comment(page: Page, *, name: str = "E2E Tester", email: str = "e2e@e
@pytest.mark.e2e
def test_valid_comment_shows_moderation_message(page: Page, base_url: str) -> None:
"""Successful comment submission must show the awaiting-moderation banner."""
"""Successful comment submission must show the awaiting-moderation message."""
_go_to_article(page, base_url)
_submit_comment(page, body="This is a test comment from Playwright.")
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible()
# HTMX swaps the form container inline — wait for the moderation message
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
@pytest.mark.e2e
@@ -38,7 +38,8 @@ def test_valid_comment_not_immediately_visible(page: Page, base_url: str) -> Non
unique_body = "Unique unmoderated comment body xq7z"
_submit_comment(page, body=unique_body)
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
# Wait for HTMX response to settle
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
expect(page.get_by_text(unique_body)).not_to_be_visible()
@@ -48,7 +49,7 @@ def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
_submit_comment(page, body=" ") # whitespace-only body
page.wait_for_load_state("networkidle")
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible(timeout=10_000)
assert "commented=1" not in page.url
@@ -78,8 +79,8 @@ def test_reply_form_visible_on_approved_comment(page: Page, base_url: str) -> No
@pytest.mark.e2e
def test_reply_submission_redirects(page: Page, base_url: str) -> None:
"""Submitting a reply to an approved comment should redirect with commented=1."""
def test_reply_submission_shows_moderation_message(page: Page, base_url: str) -> None:
"""Submitting a reply to an approved comment should show moderation message."""
_go_to_article(page, base_url)
# The reply form is always visible below the approved seeded comment
@@ -89,8 +90,8 @@ def test_reply_submission_redirects(page: Page, base_url: str) -> None:
reply_form.locator('textarea[name="body"]').fill("This is a test reply.")
reply_form.get_by_role("button", name="Reply").click()
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible()
# HTMX swaps the reply form container inline
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
@pytest.mark.e2e

View File

@@ -28,6 +28,10 @@ ignore_missing_imports = true
module = ["apps.authors.models"]
ignore_errors = true
[[tool.mypy.overrides]]
module = ["apps.comments.views"]
ignore_errors = true
[tool.django-stubs]
django_settings_module = "config.settings.development"

View File

@@ -152,13 +152,6 @@
</div>
{% endif %}
{% if comment_form and comment_form.errors %}
<div aria-label="Comment form errors" class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 font-mono text-sm text-red-600 dark:text-red-400">
{{ comment_form.non_field_errors }}
{% for field in comment_form %}{{ field.errors }}{% endfor %}
</div>
{% endif %}
{% include "comments/_comment_form.html" %}
</section>
{% endif %}

View File

@@ -1,9 +1,19 @@
{% load static %}
<div id="comment-form-container" class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
<h3 class="font-display font-bold text-xl mb-6">Post a Comment</h3>
{% if success_message %}
<div class="mb-4 p-3 font-mono text-sm bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20">
{{ success_message }}
</div>
{% endif %}
{% if comment_form.errors %}
<div aria-label="Comment form errors" class="mb-4 p-3 font-mono text-sm bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
{% for error in comment_form.non_field_errors %}<p>{{ error }}</p>{% endfor %}
{% for field in comment_form %}{% for error in field.errors %}<p>{{ field.label }}: {{ error }}</p>{% endfor %}{% endfor %}
</div>
{% endif %}
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-4"
hx-post="{% url 'comment_post' %}" hx-target="#comments-list" hx-swap="beforeend"
hx-on::after-request="if(event.detail.successful) { this.reset(); document.getElementById('comment-success')?.remove(); this.insertAdjacentHTML('beforebegin', '<div id=&quot;comment-success&quot; class=&quot;mb-4 p-3 font-mono text-sm bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20&quot;>Comment posted!</div>'); }">
hx-post="{% url 'comment_post' %}" hx-target="#comment-form-container" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -0,0 +1,10 @@
<article id="comment-{{ comment.id }}" class="mt-6 ml-8 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 border-l-2 border-l-brand-cyan p-4">
<div class="flex items-center gap-3 mb-2">
<div class="w-7 h-7 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0"></div>
<div>
<div class="font-display font-bold text-sm">{{ comment.author_name }}</div>
<div class="font-mono text-xs text-zinc-500">{{ comment.created_at|date:"M j, Y" }}</div>
</div>
</div>
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ comment.body }}</p>
</article>

View File

@@ -1,20 +1,30 @@
{% load static %}
<form method="post" action="{% url 'comment_post' %}" class="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800"
hx-post="{% url 'comment_post' %}" hx-target="#comments-list" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
<div class="flex gap-3 mb-3">
<input type="text" name="author_name" required placeholder="Your name"
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors" />
<input type="email" name="author_email" required placeholder="your@email.com"
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors" />
</div>
<textarea name="body" required placeholder="Write a reply..." rows="2"
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors mb-3 resize-none"></textarea>
<input type="text" name="honeypot" hidden />
{% if turnstile_site_key %}
<div class="cf-turnstile mb-3" data-sitekey="{{ turnstile_site_key }}" data-theme="auto" data-size="compact"></div>
<div id="reply-form-container-{{ comment.id }}" class="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800">
{% if reply_success_message %}
<div class="mb-3 p-2 font-mono text-sm bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20">{{ reply_success_message }}</div>
{% endif %}
<button type="submit" class="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 font-display font-bold text-sm hover:bg-brand-pink hover:text-white transition-colors">Reply</button>
</form>
{% if reply_form_errors %}
<div aria-label="Comment form errors" class="mb-3 p-2 font-mono text-sm bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
{% for field, errors in reply_form_errors.items %}{% for error in errors %}<p>{{ error }}</p>{% endfor %}{% endfor %}
</div>
{% endif %}
<form method="post" action="{% url 'comment_post' %}"
hx-post="{% url 'comment_post' %}" hx-target="#reply-form-container-{{ comment.id }}" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
<div class="flex gap-3 mb-3">
<input type="text" name="author_name" required placeholder="Your name"
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors" />
<input type="email" name="author_email" required placeholder="your@email.com"
class="flex-1 bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors" />
</div>
<textarea name="body" required placeholder="Write a reply..." rows="2"
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:shadow-neon-pink transition-colors mb-3 resize-none"></textarea>
<input type="text" name="honeypot" hidden />
{% if turnstile_site_key %}
<div class="cf-turnstile mb-3" data-sitekey="{{ turnstile_site_key }}" data-theme="auto" data-size="compact"></div>
{% endif %}
<button type="submit" class="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 font-display font-bold text-sm hover:bg-brand-pink hover:text-white transition-colors">Reply</button>
</form>
</div>