diff --git a/apps/comments/tests/test_v2.py b/apps/comments/tests/test_v2.py index 9ede878..da914ac 100644 --- a/apps/comments/tests/test_v2.py +++ b/apps/comments/tests/test_v2.py @@ -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).""" diff --git a/apps/comments/views.py b/apps/comments/views.py index 672ceb2..842b7b3 100644 --- a/apps/comments/views.py +++ b/apps/comments/views.py @@ -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'
{comment_html}
' + ) + else: + comment_html = render_to_string("comments/_comment.html", ctx, request) + oob_html = ( + f'
' + f"{comment_html}
" + ) + + 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) diff --git a/e2e/test_comments.py b/e2e/test_comments.py index 26a6073..f18674e 100644 --- a/e2e/test_comments.py +++ b/e2e/test_comments.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 5270ff4..c6f324c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html index 11db6aa..7c74ba6 100644 --- a/templates/blog/article_page.html +++ b/templates/blog/article_page.html @@ -152,13 +152,6 @@ {% endif %} - {% if comment_form and comment_form.errors %} -
- {{ comment_form.non_field_errors }} - {% for field in comment_form %}{{ field.errors }}{% endfor %} -
- {% endif %} - {% include "comments/_comment_form.html" %} {% endif %} diff --git a/templates/comments/_comment_form.html b/templates/comments/_comment_form.html index 5e8184a..0242865 100644 --- a/templates/comments/_comment_form.html +++ b/templates/comments/_comment_form.html @@ -1,9 +1,19 @@ {% load static %}

Post a Comment

+ {% if success_message %} +
+ {{ success_message }} +
+ {% endif %} + {% if comment_form.errors %} +
+ {% for error in comment_form.non_field_errors %}

{{ error }}

{% endfor %} + {% for field in comment_form %}{% for error in field.errors %}

{{ field.label }}: {{ error }}

{% endfor %}{% endfor %} +
+ {% endif %}
+ hx-post="{% url 'comment_post' %}" hx-target="#comment-form-container" hx-swap="outerHTML"> {% csrf_token %}
diff --git a/templates/comments/_reply.html b/templates/comments/_reply.html new file mode 100644 index 0000000..4cf4920 --- /dev/null +++ b/templates/comments/_reply.html @@ -0,0 +1,10 @@ +
+
+
+
+
{{ comment.author_name }}
+
{{ comment.created_at|date:"M j, Y" }}
+
+
+

{{ comment.body }}

+
diff --git a/templates/comments/_reply_form.html b/templates/comments/_reply_form.html index 68eecfe..9294503 100644 --- a/templates/comments/_reply_form.html +++ b/templates/comments/_reply_form.html @@ -1,20 +1,30 @@ {% load static %} - - {% csrf_token %} - - -
- - -
- - - {% if turnstile_site_key %} -
+
+ {% if reply_success_message %} +
{{ reply_success_message }}
{% endif %} - - + {% if reply_form_errors %} +
+ {% for field, errors in reply_form_errors.items %}{% for error in errors %}

{{ error }}

{% endfor %}{% endfor %} +
+ {% endif %} +
+ {% csrf_token %} + + +
+ + +
+ + + {% if turnstile_site_key %} +
+ {% endif %} + +
+