Создание древовидных комментариев, добавление JavaScript

⏱ 30 мин · 🧩 заданий: 5

Без перезагрузки страницы

Сейчас после отправки комментария страница перезагружается и прокручивается в начало. Сделаем отправку через JavaScript: форма уходит в фоне, новый комментарий появляется в нужном месте дерева.

Сервер: JSON для AJAX

Одно представление обслуживает и обычную форму, и AJAX. Отличаем их по заголовку X-Requested-With, который мы сами добавим в fetch:

from django.http import JsonResponse


class CommentCreateView(LoginRequiredMixin, CreateView):
    form_class = CommentCreateForm

    def is_ajax(self):
        return self.request.headers.get("X-Requested-With") == "XMLHttpRequest"

    def form_invalid(self, form):
        if self.is_ajax():
            return JsonResponse({"error": form.errors}, status=400)
        return super().form_invalid(form)

    def form_valid(self, form):
        comment = form.save(commit=False)
        comment.article_id = self.kwargs.get("pk")
        comment.author = self.request.user
        comment.parent_id = form.cleaned_data.get("parent")
        comment.save()

        if self.is_ajax():
            return JsonResponse({
                "is_child": comment.is_child_node(),
                "id": comment.id,
                "author": comment.author.username,
                "parent_id": comment.parent_id,
                "time_create": comment.time_create.strftime("%Y-%b-%d %H:%M:%S"),
                "avatar": comment.author.profile.avatar.url,
                "content": comment.content,
                "get_absolute_url": comment.author.profile.get_absolute_url(),
            }, status=200)

        return redirect(comment.article.get_absolute_url())

    def handle_no_permission(self):
        return JsonResponse({"error": "Необходимо авторизоваться для добавления комментариев"}, status=400)
  • is_child_node() — метод MPTT: есть ли у комментария родитель;
  • form_invalid для AJAX возвращает ошибки JSON-ом со статусом 400.

Клиент: comments.js

templates/src/custom/js/comments.js, подключается внизу детальной страницы:

const commentForm = document.forms.commentForm;
const commentFormContent = commentForm.content;
const commentFormParentInput = commentForm.parent;
const commentFormSubmit = commentForm.commentSubmit;
const commentArticleId = commentForm.getAttribute("data-article-id");

commentForm.addEventListener("submit", createComment);

replyUser();

function replyUser() {
    document.querySelectorAll(".btnReply").forEach((element) => {
        element.addEventListener("click", replyComment);
    });
}

function replyComment() {
    const commentUsername = this.getAttribute("data-comment-username");
    const commentMessageId = this.getAttribute("data-comment-id");
    commentFormContent.value = `${commentUsername}, `;
    commentFormParentInput.value = commentMessageId;
    commentFormContent.focus();
}

async function createComment(event) {
    event.preventDefault();
    commentFormSubmit.disabled = true;
    commentFormSubmit.innerText = "Ожидаем ответа сервера";
    try {
        const response = await fetch(commentForm.action, {
            method: "POST",
            headers: {
                "X-CSRFToken": csrftoken,
                "X-Requested-With": "XMLHttpRequest",
            },
            body: new FormData(commentForm),
        });
        const comment = await response.json();
        if (!response.ok) {
            throw new Error(comment.error ? JSON.stringify(comment.error) : "Ошибка сервера");
        }

        const commentTemplate = `
            <ul id="comment-thread-${comment.id}">
              <li class="card border-0">
                <div class="row">
                  <div class="col-md-2">
                    <img src="${comment.avatar}" style="width: 80px; height: 80px; object-fit: cover;" alt="${comment.author}"/>
                  </div>
                  <div class="col-md-10">
                    <div class="card-body">
                      <h6 class="card-title"><a href="${comment.get_absolute_url}">${comment.author}</a></h6>
                      <p class="card-text"></p>
                      <a class="btn btn-sm btn-dark btnReply" href="#commentForm"
                         data-comment-id="${comment.id}" data-comment-username="${comment.author}">Ответить</a>
                      <hr/>
                      <time>${comment.time_create}</time>
                    </div>
                  </div>
                </div>
              </li>
            </ul>`;

        if (comment.is_child) {
            document.querySelector(`#comment-thread-${comment.parent_id}`)
                .insertAdjacentHTML("beforeend", commentTemplate);
        } else {
            document.querySelector(".nested-comments")
                .insertAdjacentHTML("beforeend", commentTemplate);
        }
        // текст вставляем через textContent — так он не выполнится как HTML
        document.querySelector(`#comment-thread-${comment.id} .card-text`).textContent = comment.content;

        commentForm.reset();
        commentFormParentInput.value = null;
        replyUser();
    } catch (error) {
        console.log(error);
        alert(error.message);
    } finally {
        commentFormSubmit.disabled = false;
        commentFormSubmit.innerText = "Добавить комментарий";
    }
}

Что здесь происходит:

  1. Ответ. Кнопка «Ответить» пишет id комментария в скрытое поле parent и подставляет имя автора в текст.
  2. Отправка. fetch с FormData — в теле те же поля, что отправила бы обычная форма, включая csrfmiddlewaretoken. Заголовок X-Requested-With говорит серверу вернуть JSON.
  3. Вставка. Ответ добавляется в <ul> родителя, новый корневой комментарий — в конец дерева.
  4. Безопасность. Текст комментария вставляем через textContent: если пользователь напишет <script>, он покажется как текст, а не выполнится (XSS).
  5. Блокируем кнопку на время запроса, чтобы не отправить комментарий дважды.

CSRF-токен в JavaScript

Переменная csrftoken объявляется один раз в main.html:

<script>const csrftoken = "{{ csrf_token }}";</script>

Или читается из cookie csrftoken (если CSRF_COOKIE_HTTPONLY = False, по умолчанию так и есть).

В форме добавьте атрибут для скрипта:

<form method="post" action="{% url 'comment_create_view' article.pk %}" id="commentForm" name="commentForm" data-article-id="{{ article.pk }}">

И подключите скрипт внизу шаблона статьи:

{% block script %}
  <script src="{% static 'custom/js/comments.js' %}"></script>
{% endblock %}

Итог

  • Одно представление: для AJAX — JsonResponse, для обычной формы — редирект.
  • fetch + FormData + заголовки X-CSRFToken и X-Requested-With.
  • Кнопка «Ответить» заполняет скрытое parent; ответ вставляется в ветку родителя.
  • Пользовательский текст вставляем через textContent, а не через innerHTML.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Написать код

В form_valid для AJAX-запросов верните JSON: если заголовок self.request.headers.get("X-Requested-With") == "XMLHttpRequest", верните JsonResponse со словарём, где "is_child": comment.is_child_node() и "id": comment.id. Иначе — редирект на статью.

Задание 2 Написать код

Отправьте форму комментария через fetch: метод POST, тело — new FormData(commentForm), заголовки "X-CSRFToken": csrftoken и "X-Requested-With": "XMLHttpRequest". Адрес — commentForm.action.

Задание 3 Написать код

Обработчик кнопки «Ответить»: запишите id комментария из data-comment-id в скрытое поле формы (commentForm.parent.value = this.dataset.commentId) и поставьте фокус в поле текста (commentForm.content.focus()).

Задание 4 Один вариант ответа

Атрибут data-comment-id в HTML доступен в JavaScript как…

Задание 5 Несколько вариантов

Что нужно, чтобы Django принял POST-запрос от fetch?

Войдите, чтобы отмечать пройденные уроки.