Créer un blog – les commentaires 2/2
Nous avons dans le précédent article commencé à coder la gestion des commentaires pour un article. Pour le moment on a installé un bouton qui sert à lancer une requête Ajax pour afficher ces commentaires sans régénérer toute la page. Maintenant on va prévoir l’ajout d’un commentaire avec deux cas : nouveau commentaire ou réponse à un commentaire existant. Pour terminer on autorisera l’auteur d’un commentaire à le supprimer, ce qui va automatiquement supprimer les réponses à ce commentaire.
Vous pouvez télécharger le code final de cet article ici.
Ajouter un commentaire
Pour pouvoir ajouter un commentaire l’utilisateur doit être connecté. Je rappelle que les pages de l’authentification existent déjà puisqu’on a installé Breeze. Pour accéder au login il faut utiliser l’url monblog.ext/login. Pour le moment on n’a pas prévu de lien dans la barre de navigation, on s’en occupera en même temps que les nouvelles vues de l’authentification.
La validation
Pour la validation on va utiliser une form request :
php artisan make:request Front\CommentRequest
On aura juste un champ qu’on va limiter à 1000 caractères :
<?php namespace App\Http\Requests\Front; use Illuminate\Foundation\Http\FormRequest; class CommentRequest extends FormRequest { public function authorize() { return true; } public function rules() { return [ 'message' => 'required|max:1000', ]; } }
Le contrôleur et la route
Pour l’ajout d’un commentaire on va avoir deux cas :
- nouveau commentaire
- réponse à un commentaire existant
Il faut pouvoir distinguer les deux situations. J’ai opté pour l’ajout d’un contrôle caché commentId dans le formulaire qui sera renseigné en cas de réponse avec l’identifiant du commentaire pour lequel on a une réponse.
Donc dans le contrôleur CommentController :
use App\Http\Requests\Front\CommentRequest; use App\Models\{ Comment, Post }; ... public function store(CommentRequest $request, Post $post) { $data = [ 'body' => $request->message, 'post_id' => $post->id, 'user_id' => $request->user()->id, ]; $request->has('commentId') ? Comment::findOrFail($request->commentId)->children()->create($data): Comment::create($data); $commenter = $request->user(); return response()->json($commenter->valid ? 'ok' : 'invalid'); }
On transmet une référence de l’article au contrôleur. On collecte les informations nécessaires :
- le message
- l’identifiant de l’article concerné
- l’identifiant de l’auteur du commentaire
Ensuite on enregistre le commentaire en distinguant la réponse du nouveau commentaire selon la présence ou non du champ commentId.
On renvoie une réponse au client en précisant si l’utilisateur est valide (on affiche immédiatement son message) ou pas (il faudra qu’il soit validé par l’administrateur).
On ajoute la route avec le middleware auth :
Route::prefix('posts')->group(function () { ... Route::name('posts.comments.store')->post('{post}/comments', [FrontCommentController::class, 'store'])->middleware('auth'); });
La vue post
On complète la vue front.post avec le formulaire :
<!-- comments ================================================== --> <div class="comments-wrap"> ... @if(Auth::check()) <div class="row comment-respond"> <div id="respond" class="column"> <h3>@lang('Add Comment') <span id="forName"></span> <span><a id="abort" hidden href="#">@lang('Abort reply')</a></span> </h3> <div id="alert" class="alert-box" style="display: none"> <p></p> <span class="alert-box__close"></span> </div> <form id="messageForm" method="post" action="{{ route('posts.comments.store', $post->id) }}" autocomplete="off"> <input id="commentId" name="commentId" type="hidden" value=""> <div class="message form-field"> <textarea name="message" id="message" class="h-full-width" placeholder="@lang('Your Message')"></textarea> </div> <br> <p id="forSubmit" class="text-center"> <input name="submit" id="submit" class="btn btn--primary btn-wide btn--large h-full-width" value="@lang('Add Comment')" type="submit"> </p> <p id="commentIcon" class="h-text-center" hidden> <span class="fa fa-spinner fa-pulse fa-3x fa-fw"></span> </p> </form> </div> </div> @endif </div>
Si l’utilisateur est connecté le formulaire apparaît :
Voici le code Javascript complet pour l’ajout d’un commentaire (y compris pour une réponse) :
@section('scripts') <script src="https://cdn.jsdelivr.net/npm/sweetalert2@10"></script> <script> (() => { // Variables const headers = { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } const commentId = document.getElementById('commentId'); const alert = document.getElementById('alert'); const message = document.getElementById('message'); const forName = document.getElementById('forName'); const abort = document.getElementById('abort'); const commentIcon = document.getElementById('commentIcon'); const forSubmit = document.getElementById('forSubmit'); // Add comment const addComment = async e => { e.preventDefault(); // Get datas const datas = { message: message.value }; if(document.querySelector('#commentId').value != '') { datas['commentId'] = commentId.value; } // Icon commentIcon.hidden = false; forSubmit.hidden = true; // Send request const response = await fetch('{{ route('posts.comments.store', $post->id) }}', { method: 'POST', headers: headers, body: JSON.stringify(datas) }); // Wait for response const data = await response.json(); // Icon commentIcon.hidden = true; forSubmit.hidden = false; // Manage response if (response.ok) { purge(); if(data == 'ok') { showComments(); showAlert('success', '@lang('Your comment has been saved')'); } else { showAlert('info', "@lang('Thanks for your comment. It will appear when an administrator has validated it. Once you are validated your other comments immediately appear.')"); } } else { if(response.status == 422) { showAlert('error', data.errors.message[0]); } else { errorAlert(); } } } const errorAlert = () => Swal.fire({ icon: 'error', title: '@lang('Whoops!')', text: '@lang('Something went wrong!')' }); // Show alert const showAlert = (type, text) => { alert.style.display = 'block'; alert.className = ''; alert.classList.add('alert-box', 'alert-box--' + type); alert.firstChild.textContent = text; } // Hide alert const hideAlert = () => alert.style.display = 'none'; // Prepare show comments const prepareShowComments = e => { e.preventDefault(); document.getElementById('showbutton').toggleAttribute('hidden'); document.getElementById('showicon').toggleAttribute('hidden'); showComments(); } // Show comments const showComments = async () => { // Send request const response = await fetch('{{ route('posts.comments', $post->id) }}', { method: 'GET', headers: headers }); // Wait for response const data = await response.json(); document.getElementById('commentsList').innerHTML = data.html; @if(Auth::check()) document.getElementById('respond').hidden = false; @endif } // Reply to comment const replyToComment = e => { e.preventDefault(); forName.textContent = `@lang('Reply to') ${e.target.dataset.name}`; commentId.value = e.target.dataset.id; abort.hidden = false; message.focus(); } // Abort reply const abortReply = (e) => { e.preventDefault(); purge(); } // Purge reply const purge = () => { forName.textContent = ''; commentId.value = ''; message.value = ''; abort.hidden = true; } // Listener wrapper const wrapper = (selector, type, callback, condition = 'true', capture = false) => { const element = document.querySelector(selector); if(element) { document.querySelector(selector).addEventListener(type, e => { if(eval(condition)) { callback(e); } }, capture); } }; // Set listeners window.addEventListener('DOMContentLoaded', () => { wrapper('#showcomments', 'click', prepareShowComments); wrapper('#abort', 'click', abortReply); wrapper('#message', 'focus', hideAlert); wrapper('#messageForm', 'submit', addComment); wrapper('#commentsList', 'click', replyToComment, "e.target.matches('.replycomment')"); }) })() </script> @endsection
On va voir le fonctionnement pour l’ajout d’un commentaire.
On met une écoute sur la soumission du formulaire :
wrapper('#messageForm', 'submit', addComment);
Donc dès qu’on clique sur le bouton de soumission on active la fonction addComment. Dans un premier temps on empêche la soumission d’avoir lieu immédiatement :
e.preventDefault();
Ensuite on collecte les données, ici on a juste le message :
const datas = { message: message.value };
On cache le bouton et on affiche l’icône d’attente :
commentIcon.hidden = false; forSubmit.hidden = true;
On envoie la requête au serveur avec fetch :
const response = await fetch('{{ route('posts.comments.store', $post->id) }}', { method: 'POST', headers: headers, body: JSON.stringify(datas) });
Et on attend la réponse :
const data = await response.json();
Quand on reçoit la réponse on cache l’icône et on remet le bouton :
commentIcon.hidden = true; forSubmit.hidden = false;
Si la réponse est positive (ok) on commence par purger :
const purge = () => { forName.textContent = ''; commentId.value = ''; message.value = ''; abort.hidden = true; }
On efface le message (les autres affectations concernent la réponse à un commentaire qu’on verra plus loin).
Ensuite selon le statut de l’utilisateur on affiche le message qui convient et on régénère les commentaires dans le cas où l’utilisateur est validé :
if(data == 'ok') { showComments(); showAlert('success', '@lang('Your comment has been saved')'); } else { showAlert('info', "@lang('Thanks for your comment. It will appear when an administrator has validated it. Once you are validated your other comments immediately appear.')"); }
Donc on a soit ce message :
Soit celui-ci avec régénération des messages :
Si le serveur renvoie un code d’erreur :
if(response.status == 422) { showAlert('error', data.errors.message[0]); } else { errorAlert(); }
Si c’est un code 422 on affiche le problème de validation :
Sinon on affiche un message général :
Répondre à un commentaire
Pour une réponse à un commentaire on peut avoir évidemment plusieurs liens, j’ai donc ajouté une classe (.replycomment) pour les repérer. On installe une écoute d’un click :
wrapper('#commentsList', 'click', replyToComment, "e.target.matches('.replycomment')");
On écoute sur toute la liste des commentaires et on repère la présence de la classe. On appelle ainsi la méthode replyToComment. Là on empêche l’événement de se propager plus loin :
e.preventDefault();
On précise au niveau du formulaire à qui on répond :
forName.textContent = `@lang('Reply to') ${e.target.dataset.name}`;
Et on ajoute un lien pour annuler éventuellement cette action de réponse :
abort.hidden = false;
On ajoute dans le formulaire l’identifiant du commentaire auquel on répond :
commentId.value = e.target.dataset.id;
Et enfin on met le curseur dans le contrôle du formulaire :
message.focus();
Pour que l’annulation fonctionne on écoute le lien qu’on a installé :
wrapper('#abort', 'click', abortReply);
En cas de clic :
const abortReply = (e) => { e.preventDefault(); purge(); }
On fait une purge :
const purge = () => { forName.textContent = ''; commentId.value = ''; message.value = ''; abort.hidden = true; }
Si on envoie effectivement un commentaire le déroulement va être exactement le même que ce qu’on a vu précédemment.
Supprimer un commentaire
Pour la suppression d’un commentaire on va prendre quelques précautions, on va générer un policy :
php artisan make:policy CommentPolicy --model=Comment
Avec ce code :
<?php namespace App\Policies; use App\Models\{ Comment, User}; use Illuminate\Auth\Access\HandlesAuthorization; class CommentPolicy { use HandlesAuthorization; protected function manage(User $user, Comment $comment) { return $user->isAdmin() ?: $user->id === $comment->post->user_id || $user->id === $comment->user_id; } public function viewAny(User $user) { return true; } public function view(User $user, Comment $comment) { return true; } public function update(User $user, Comment $comment) { return $this->manage($user, $comment); } public function delete(User $user, Comment $comment) { return $this->manage($user, $comment); } }
On anticipe un peu pour la suite mais pour le moment on a l’autorisation qu’il nous faut. Et pour que ça fonctionne on va ajouter dans le modèle User une fonction pour savoir facilement si un utilisateur est administrateur :
public function isAdmin() { return $this->role === 'admin'; }
Contrôleur et route
Dans le contrôleur on code la fonction destroy :
public function destroy(Comment $comment) { $this->authorize('delete', $comment); $comment->delete(); return response()->json(); }
Et on ajoute la route :
Route::name('front.comments.destroy')->delete('comments/{comment}', [FrontCommentController::class, 'destroy']);
Composant comments-base
Dans le composant comments-base il faut renseigner le lien pour la suppression :
@if(Auth::user()->name == $comment->user->name) <a href="{{ route('front.comments.destroy', $comment->id) }}" ... </a> @endif
Ce lien n’apparaît que pour l’auteur du commentaire.
Vue post
Enfin dans la vue front.post on ajoute le code Javascript pour gérer la suppression :
... // Delete comment const deleteComment = async e => { e.preventDefault(); Swal.fire({ title: '@lang('Really delete this comment?')', icon: "warning", showCancelButton: true, confirmButtonColor: "#DD6B55", confirmButtonText: "@lang('Yes')", cancelButtonText: "@lang('No')", preConfirm: () => { return fetch(e.target.getAttribute('href'), { method: 'DELETE', headers: headers }) .then(response => { if (response.ok) { showComments(); } else { errorAlert(); } }); } }); } // Set listeners window.addEventListener('DOMContentLoaded', () => { ... wrapper('#commentsList', 'click', deleteComment, "e.target.matches('.deletecomment')"); })
On installe une écoute d’un clic pour tous les éléments qui possèdent la classe deletecomment :
wrapper('#commentsList', 'click', deleteComment, "e.target.matches('.deletecomment')");
On appelle alors la fonction deleteComment. Là on utilise Sweetalert pour afficher un message :
Swal.fire({ title: '@lang('Really delete this comment?')', icon: "warning", showCancelButton: true, confirmButtonColor: "#DD6B55", confirmButtonText: "@lang('Yes')", cancelButtonText: "@lang('No')",
Si on choisit YES alors on lance la requête avec fetch :
return fetch(e.target.getAttribute('href'), { method: 'DELETE', headers: headers })
Au retour si tout s’est bien passé on régénère les commentaires, sinon on affiche un message général d’erreur :
.then(response => { if (response.ok) { showComments(); } else { errorAlert(); } });
Conclusion
On en a fini avec la gestion des commentaires. On peut désormais en ajouter, faire une réponse, supprimer un commentaire. On dispose de l’essentiel du code côté client mais on n’en a pas fini. Dans le prochain article on va créer les vues pour l’authentification pour les harmoniser avec l’aspect de notre blog et on va ainsi se débarrasser de Tailwind.
8 commentaires
MatthiasScreed
j’ai un problem mon prevent default ne fonctionne pas du coup je n’arrive pas reply sur les comments : « `const replyToComment = e => {
e.preventDefault();
forName.textContent = `@lang(‘Reply to’) ${e.target.dataset.name}`;
commentId.value = e.target.dataset.id;
abort.hidden = false;
message.focus();
}« `
et je ne comprends pas pourquoi
bestmomo
Salut,
Essaie avec e.stopImmediatePropagation() pour voir.
inesW
Merci infiniment !
J’ai une erreur 403 finalement le pense que l’Ajax ne marche pas car on a dit s’il ne charge pas alors on retourne cette erreur.
Ma question fondamentale est pourquoi et comment remédié ?
Merci à tous!
bestmomo
Salut,
Il faut voir avec les outils développement du navigateur les requêtes qui passent.
oksam
Bonsoir Best c’est pour Swal merci pour la réactivité!
Sinon j’ai cette autre erreur :
Uncaught (in promise) TypeError: Cannot set property ‘hidden’ of null
at showComments (post-6:445)
je crois que c’est dû à l’authentification! tu as sinon une idée?
bestmomo
Salut,
Bien vu, l’erreur passe inaperçue si on n’ouvre pas la console. Ça arrive quand on charge les commentaires pour un utilisateur non authentifié. On commande l’affichage du formulaire mais comme le HTML n’existe pas on référence un élément inconnu. Il faut ajouter un test :
const respond = document.getElementById('respond');
if(respond) {
respond.hidden = false;
}
Ou une autre solution qui me semble plus pertinente :
@if(Auth::check())
document.getElementById('respond').hidden = false;
@endif
oksam
Bonsoir Best j’ai cette erreur : Uncaught (in promise) ReferenceError: Swal is not defined
at deleteComment (post-6:506)
at HTMLDivElement. (post-6:496)
j’ai installer la librairie sweetalert2 mais je pense que ce soit là le problème!
bestmomo
Salut,
Tu as bien mi le lien vers le CDN pour charger Swal ?