Laravel 8

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.

Print Friendly, PDF & Email

8 commentaires

Laisser un commentaire