Dans ce tutoriel je vous propose de découvrir comment créer un système de commentaires imbriqués (avec système de réponse). Pour mettre en place un tel système on va avoir besoin de modifier notre base de données pour rajouter 2 champs importants
- parent_id, nous permettra de savoir à quel commentaire on est en train de répondre (par défaut 0)
- depth, permettra de connaitre la profondeur d'une réponse et ainsi de mettre en place un système de validation pour limiter la profondeur des réponses
On n'utilisera pas ici la représentation intervallaire car beaucoup trop gourmande en terme de modifications.
Histoire de créer un code un minimum réutilisable nous allons nous créer une class qui permettra de gérer la récupération et l'organisation des données. Pour fonctionner, cette classe aura besoin d'utiliser une instance de PDO qui aura un FETCH_MODE en objet
$pdo = new PDO('mysql:dbname=comments;host=localhost', 'root', 'root', [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ
]);
Ce fetch_mode permettra d'avoir des objet lorsque l'on récupèrera nos données. Objets auquels on ajoutera un attribut children pour leur associer des enfants. Une fois cette objet PDO instancié on pourra l'initialiser dans notre class.
<?php
namespace App;
class Comments
{
/**
* @var \PDO
*/
private $pdo;
public function __construct(\PDO $pdo)
{
$this->pdo = $pdo;
}
}
Récupération des commentaires
Notre premier objectif est d'être capable de récupérer les commentaires et ensuite de traiter les données pour créer l'arbre. Nous allons donc commencer par une méthode qui permettra de récupérer les résultats en les organisant par ID. Ce point est d'ailleurs important, un tableau indexé par ID nous permettra ensuite de travailler beaucoup plus facilement.
/**
* Récupère tous les commentaire organisé par ID
* @param $post_id
* @return array
*/
public function findAllById($post_id)
{
$req = $this->pdo->prepare('SELECT * FROM comments WHERE post_id = ?');
$req->execute([$post_id]);
$comments = $req->fetchAll();
$comments_by_id = [];
foreach ($comments as $comment) {
$comments_by_id[$comment->id] = $comment;
}
return $comments_by_id;
}
Une fois que l'on a les commentaires organisés convenablement on va parcourir l''arbre et créer pour chaque commentaire ayant des enfants un attribut children.
/**
* Permet de récupérer les commentaires avec les enfants
* @param $post_id
* @param bool $unset_children Doit-t-on supprimer les commentaire qui sont des enfants des résultats ?
* @return array
*/
public function findAllWithChildren($post_id, $unset_children = true)
{
// On a besoin de 2 variables
// comments_by_id ne sera jamais modifié alors que comments
$comments = $comments_by_id = $this->findAllById($post_id);
foreach ($comments as $id => $comment) {
if ($comment->parent_id != 0) {
$comments_by_id[$comment->parent_id]->children[] = $comment;
if ($unset_children) {
unset($comments[$id]);
}
}
}
return $comments;
}
Cette méthode nous serivra aussi par la suppression, on ajoute donc un paramètre pour demander au système de "nettoyer" le tableau de résultat ou non. On voit ici l'avantage de l'utilisation des objet, on modifie seulement l'attribut children et cela affectera l'instance dans tous les tableaux où elle est présente.
Maintenant vient le problème du rendu HTML. Comment gérer un arbre potentiellement infini ? La récursivité ! On va donc inclure un fichier pour afficher un commentaire, fichier qui pourra s'inclure lui même.
<?php
$comments = new App\Comments($app->pdo);
?>
<?php foreach($comments->findAllWithChildren(1) as $comment): ?>
<?php require('comment.php'); ?>
<?php endforeach; ?>
Et ce fichier comment.php se chargera de se réinclure si il trouve des enfants
<div class="panel panel-default" id="comment-<?= $comment->id; ?>">
<div class="panel-body">
<p><?= htmlentities($comment->content); ?></p>
<?php if($comment->depth <= 1): ?>
<p class="text-right">
<a href="<?= $app->urlFor('comments.delete', ['id' => $comment->id]); ?>" class="btn btn-danger">Supprimer</a>
<button class="btn btn-default reply" data-id="<?= $comment->id; ?>">Répondre</button>
</p>
<?php endif; ?>
</div>
</div>
<div style="margin-left: 50px;">
<?php if(isset($comment->children)): ?>
<?php foreach($comment->children as $comment): ?>
<?php require('comment.php'); ?>
<?php endforeach; ?>
<?php endif; ?>
</div>
Et voila le tour est joué, vous avez maintenant des commentaire imbriqués.
Et l'ajout ?
L'ajout demande donc d'envoyer une valeur de parent_id pour connaitre à quel parent un commentaire fait référence. Pour cela, il n'y a pas de secret il va falloir utiliser un petit peu de javascript.
$('.reply').click(function(e){
e.preventDefault();
var $form = $('#form-comment');
var $this = $(this);
var parent_id = $this.data('id');
var $comment = $('#comment-' + parent_id);
$form.find('h4').text('Répondre à ce commentaire');
$('#parent_id').val(parent_id);
$comment.after($form);
})
Côté traitement il faudra s'assurer que le parent_id définit existe bien, et en plus de cela que la profondeur n'est pas trop importante.
<?php
if (isset($_POST['content']) && !empty($_POST['content'])) {
$parent_id = isset($_POST['parent_id']) ? $_POST['parent_id'] : 0;
$depth = 0;
if ($parent_id != 0) {
$req = $app->pdo->prepare('SELECT id, depth FROM comments WHERE id = ?');
$req->execute([$parent_id]);
$comment = $req->fetch();
if ($comment == false) {
throw new Exception('Ce parent n\'existe pas');
}
$depth = $comment->depth + 1;
}
if($depth >= 3){
$app->flash('danger', 'Vous ne pouvez pas répondre à une réponse d\'une réponse :(');
} else {
$req = $app->pdo->prepare('INSERT INTO comments SET content = ?, parent_id = ?, post_id = ?, depth = ?');
$req->execute([
$_POST['content'],
$parent_id,
$_POST['post_id'],
$depth
]);
$app->flash('success', 'Merci pour votre commentaire :)');
}
} else {
$app->flash('danger', 'Vous n\'avez rien posté :(');
}
$app->response->redirect($app->urlFor('home'));
Et la suppression ?
Pour la suppression on peut adopter 2 stratégies.
On remonte les enfants
Le principe est de remonter les enfants et les attacher à leur nouveau parent.
/**
* Permet de supprimer un commentaire en replaçant les enfants
* @param $id
*/
public function delete($id)
{
$comment = $this->find($id);
// On supprime le commentaire
$this->pdo->prepare('DELETE FROM comments WHERE id = ?')->execute([$id]);
// On monte tous les enfants
$this->pdo->prepare('UPDATE comments SET parent_id = ?, depth = depth - 1 WHERE parent_id = ?')->execute([$comment->parent_id, $comment->id]);
}
On supprime les enfants
Si un commentaire est supprimé, alors on fait aussi disparaitre les réponses. Ce cas là est un petit peu plus subtil car nous n'avons pas de moyen, via MySQL de construire l'arbre. On va donc réutiliser la méthode que l'on avait créé précédemment pour obtenir l'arbre, et ainsi les IDs à supprimer.
/**
* Permet de supprimer un commentaire et ces enfants
* @param $id
* @return int
*/
public function deleteWithChildren($id)
{
// On récupère le commentaire à supprimer
$comment = $this->find($id);
$comments = $this->findAllWithChildren($comment->post_id, false);
$ids = $this->getChildrenIds($comments[$comment->id]);
$ids[] = $comment->id;
// On supprime le commentaire et ses enfants
return $this->pdo->exec('DELETE FROM comments WHERE id IN (' . implode(',', $ids) . ')');
}
/**
* Get all chidren ids of a comment
* @param $comment
* @return array
*/
private function getChildrenIds($comment)
{
$ids = [];
foreach ($comment->children as $child) {
$ids[] = $child->id;
if (isset($child->children)) {
$ids = array_merge($ids, $this->getChildrenIds($child));
}
}
return $ids;
}