Implement comments (#10)

Allow logged users to comment posts. Edition is not allowed via the API since it doesn't support auth yet.

Symfony UX is used with Turbo to avoid full pages reload, without JavaScript™.

Co-authored-by: clfreville2 <clement.freville2@etu.uca.fr>
Reviewed-on: https://codefirst.iut.uca.fr/git/clement.freville2/herbarium/pulls/10
This commit is contained in:
2024-06-11 17:17:24 +02:00
parent b4a1ae592f
commit 8859cd0000
20 changed files with 477 additions and 5 deletions

View File

@@ -0,0 +1 @@
import './bootstrap.js';

3
assets/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,3 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();

View File

@@ -2,7 +2,10 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Comment;
use App\Entity\Post; use App\Entity\Post;
use App\Entity\User;
use App\Form\CommentType;
use App\Form\PostType; use App\Form\PostType;
use App\Repository\PostRepository; use App\Repository\PostRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -10,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\UX\Turbo\TurboBundle;
class PostController extends AbstractController class PostController extends AbstractController
{ {
@@ -62,8 +67,12 @@ class PostController extends AbstractController
#[Route('/post/{id}', name: 'app_post_show', methods: ['GET'])] #[Route('/post/{id}', name: 'app_post_show', methods: ['GET'])]
public function show(Post $post): Response public function show(Post $post): Response
{ {
$form = $this->createForm(CommentType::class, new Comment(), [
'action' => $this->generateUrl('app_post_comment', ['id' => $post->getId()]),
]);
return $this->render('post/show.html.twig', [ return $this->render('post/show.html.twig', [
'post' => $post, 'post' => $post,
'form' => $form,
]); ]);
} }
@@ -97,4 +106,71 @@ class PostController extends AbstractController
return $this->redirectToRoute('app_posts', [], Response::HTTP_SEE_OTHER); return $this->redirectToRoute('app_posts', [], Response::HTTP_SEE_OTHER);
} }
#[Route('/post/{id}/comment', name: 'app_post_comment', methods: ['POST'])]
public function publishComment(Request $request, Post $post, EntityManagerInterface $entityManager, #[CurrentUser] User $user): Response
{
$comment = new Comment();
$form = $this->createForm(CommentType::class, $comment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$comment->setRelatedPost($post)
->setAuthor($user);
$entityManager->persist($comment);
$entityManager->flush();
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock('comment/new.stream.html.twig', 'success_stream', ['comment' => $comment]);
}
}
return $this->redirectToRoute('app_post_show', ['id' => $post->getId()], Response::HTTP_SEE_OTHER);
}
#[Route('/comment/{id}/edit', name: 'app_post_comment_edit', methods: ['GET', 'POST'])]
public function editComment(Request $request, Comment $comment, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(CommentType::class, $comment, [
'action' => $this->generateUrl('app_post_comment_edit', ['id' => $comment->getId()]),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
if ($comment->getRelatedPost() === null) {
return $this->redirectToRoute('app_posts', [], Response::HTTP_SEE_OTHER);
}
return $this->redirectToRoute('app_post_show', [
'id' => $comment->getRelatedPost()->getId()
], Response::HTTP_SEE_OTHER);
}
return $this->render('comment/edit.html.twig', [
'comment' => $comment,
'form' => $form,
]);
}
#[Route('/comment/{id}', name: 'app_post_comment_delete', methods: ['POST'])]
#[IsGranted('COMMENT_EDIT', subject: 'comment')]
public function deleteComment(Request $request, Comment $comment, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete'.$comment->getId(), (string) $request->getPayload()->get('_token'))) {
$id = $comment->getId();
$entityManager->remove($comment);
$entityManager->flush();
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock('comment/deleted.stream.html.twig', 'success_stream', ['comment' => $id]);
}
}
if ($comment->getRelatedPost() === null) {
return $this->redirectToRoute('app_posts');
}
return $this->redirectToRoute('app_post_show', ['id' => $comment->getRelatedPost()->getId()]);
}
} }

119
src/Entity/Comment.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\CommentRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CommentRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(operations: [new GetCollection()])]
#[ApiFilter(filterClass: SearchFilter::class, properties: ['author', 'related_post'])]
class Comment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
#[ORM\ManyToOne(inversedBy: 'comments')]
private ?Post $related_post = null;
#[ORM\Column]
private ?\DateTimeImmutable $created_at = null;
#[ORM\Column]
private ?\DateTimeImmutable $edited_at = null;
#[ORM\Column(length: 255)]
private ?string $content = null;
public function getId(): ?int
{
return $this->id;
}
public function getAuthor(): ?User
{
return $this->author;
}
public function setAuthor(?User $author): static
{
$this->author = $author;
return $this;
}
public function getRelatedPost(): ?Post
{
return $this->related_post;
}
public function setRelatedPost(?Post $related_post): static
{
$this->related_post = $related_post;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->created_at;
}
public function setCreatedAt(\DateTimeImmutable $created_at): static
{
$this->created_at = $created_at;
return $this;
}
public function getEditedAt(): ?\DateTimeImmutable
{
return $this->edited_at;
}
public function setEditedAt(\DateTimeImmutable $edited_at): static
{
$this->edited_at = $edited_at;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
#[ORM\PrePersist]
public function setCreatedAtDate(): void
{
if ($this->created_at === null) {
$this->created_at = new \DateTimeImmutable();
$this->edited_at = $this->created_at;
}
}
#[ORM\PreUpdate]
public function setEditedAtDate(): void
{
$this->edited_at = new \DateTimeImmutable();
}
}

View File

@@ -8,6 +8,8 @@ use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use App\Repository\PostRepository; use App\Repository\PostRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -61,6 +63,17 @@ class Post
#[Groups(['post:collection:read'])] #[Groups(['post:collection:read'])]
private ?Species $species = null; private ?Species $species = null;
/**
* @var Collection<int, Comment>
*/
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'related_post', fetch: 'EXTRA_LAZY')]
private Collection $comments;
public function __construct()
{
$this->comments = new ArrayCollection();
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -158,4 +171,34 @@ class Post
$this->publicationDate = new \DateTimeImmutable(); $this->publicationDate = new \DateTimeImmutable();
} }
} }
/**
* @return Collection<int, Comment>
*/
public function getComments(): Collection
{
return $this->comments;
}
public function addComment(Comment $comment): static
{
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
$comment->setRelatedPost($this);
}
return $this;
}
public function removeComment(Comment $comment): static
{
if ($this->comments->removeElement($comment)) {
// set the owning side to null (unless already changed)
if ($comment->getRelatedPost() === $this) {
$comment->setRelatedPost(null);
}
}
return $this;
}
} }

View File

@@ -10,6 +10,8 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\State\UserPasswordHasher; use App\State\UserPasswordHasher;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -60,6 +62,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['user:create', 'user:update'])] #[Groups(['user:create', 'user:update'])]
private ?string $plainPassword = null; private ?string $plainPassword = null;
/**
* @var Collection<int, Comment>
*/
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'author')]
private Collection $comments;
public function __construct()
{
$this->comments = new ArrayCollection();
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -146,4 +159,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
// If you store any temporary, sensitive data on the user, clear it here // If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null; // $this->plainPassword = null;
} }
/**
* @return Collection<int, Comment>
*/
public function getComments(): Collection
{
return $this->comments;
}
public function addComment(Comment $comment): static
{
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
$comment->setAuthor($this);
}
return $this;
}
public function removeComment(Comment $comment): static
{
if ($this->comments->removeElement($comment)) {
// set the owning side to null (unless already changed)
if ($comment->getAuthor() === $this) {
$comment->setAuthor(null);
}
}
return $this;
}
} }

29
src/Form/CommentType.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App\Form;
use App\Entity\Comment;
use App\Entity\Post;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CommentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('content', TextareaType::class)
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Comment::class,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\Comment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Comment>
*/
class CommentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Comment::class);
}
// /**
// * @return Comment[] Returns an array of Comment objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('c.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Comment
// {
// return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -25,6 +25,8 @@ class PostRepository extends ServiceEntityRepository
public function findPaginatedPosts(int $page, int $limit): Paginator public function findPaginatedPosts(int $page, int $limit): Paginator
{ {
$query = $this->createQueryBuilder('p') $query = $this->createQueryBuilder('p')
->addSelect('species')
->leftJoin('p.species', 'species')
->setFirstResult(($page - 1) * $limit) ->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit); ->setMaxResults($limit);

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class CommentVoter extends Voter
{
public const EDIT = 'COMMENT_EDIT';
protected function supports(string $attribute, mixed $subject): bool
{
// replace with your own logic
// https://symfony.com/doc/current/security/voters.html
return $attribute === self::EDIT
&& $subject instanceof \App\Entity\Comment;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// if the user is anonymous, do not grant access
if (!$user instanceof UserInterface) {
return false;
}
if ($subject->getAuthor() === $user) {
return true;
}
return in_array('ROLE_ADMIN', $user->getRoles(), true);
}
}

View File

@@ -12,7 +12,7 @@
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %} {% endblock %}
</head> </head>
<body> <body data-turbo="false">
<nav class="navbar navbar-expand-lg bg-body-tertiary"> <nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ path('app_species_index') }}">Herbarium</a> <a class="navbar-brand" href="{{ path('app_species_index') }}">Herbarium</a>

View File

@@ -0,0 +1,4 @@
<form method="post" action="{{ path('app_post_comment_delete', {'id': comment.id}) }}" onsubmit="return confirm('Are you sure you want to delete this comment?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ comment.id) }}">
<button class="btn btn-danger">Delete</button>
</form>

View File

@@ -0,0 +1,17 @@
<turbo-frame id="comment_{{ comment.id }}">
<div class="card">
<div class="card-body">
<h5 class="card-title">
{{ comment.author.email }} le {{ comment.createdAt | date }}
{% if comment.createdAt != comment.editedAt %}
(modifié le {{ comment.editedAt | date }})
{% endif %}
</h5>
<p class="card-text">{{ comment.content }}</p>
{% if is_granted('COMMENT_EDIT', comment) %}
<a href="{{ path('app_post_comment_edit', {'id': comment.id}) }}" class="btn btn-primary">Modifier</a>
{{ include('comment/_delete_form.html.twig') }}
{% endif %}
</div>
</div>
</turbo-frame>

View File

@@ -0,0 +1,3 @@
{% block success_stream %}
<turbo-stream action="remove" target="comment_{{ comment }}"></turbo-stream>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'base.html.twig' %}
{% block title %}Edit Comment{% endblock %}
{% block body %}
<h1>Edit Comment</h1>
<turbo-frame id="comment_{{ comment.id }}">
{{ include('post/_form.html.twig', {'button_label': 'Update'}) }}
</turbo-frame>
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% block success_stream %}
<turbo-stream action="append" targets="#comments">
<template>
{{ include('comment/comment.html.twig') }}
</template>
</turbo-stream>
{% endblock %}

View File

@@ -1,4 +1,4 @@
{{ form_start(form) }} {{ form_start(form) }}
{{ form_widget(form) }} {{ form_widget(form) }}
<button class="btn">{{ button_label|default('Save') }}</button> <button class="btn btn-primary">{{ button_label|default('Save') }}</button>
{{ form_end(form) }} {{ form_end(form) }}

View File

@@ -4,16 +4,16 @@
{% block body %} {% block body %}
{% for post in posts.iterator %} {% for post in posts.iterator %}
<div class="card" style="width: 42rem; margin: 20px 0 50px 100px;"> <div class="card mb-4">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">{{ post.species ? post.species.vernacularName : 'Post' }}</h5> <h5 class="card-title"><a href="{{ path('app_post_show', {id: post.id}) }}">{{ post.species ? post.species.vernacularName : 'Post' }}</a></h5>
<h6 class="card-subtitle mb-2 text-muted">{{ post.foundDate | date("d/m/Y \\à H \\h") }}</h6> <h6 class="card-subtitle mb-2 text-muted">{{ post.foundDate | date("d/m/Y \\à H \\h") }}</h6>
<p class="card-subtitle mb-2 text-muted">{{ post.latitude }}, {{ post.longitude }}, {{ post.altitude }}m</p> <p class="card-subtitle mb-2 text-muted">{{ post.latitude }}, {{ post.longitude }}, {{ post.altitude }}m</p>
<p class="card-text">{{ post.commentary }}</p> <p class="card-text">{{ post.commentary }}</p>
</div> </div>
<div class="card-footer"> <div class="card-footer">
28 ❤️ 28 ❤️
128 💬 {{ post.comments.count() }} 💬
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -43,4 +43,17 @@
<a href="{{ path('app_post_edit', {'id': post.id}) }}">edit</a> <a href="{{ path('app_post_edit', {'id': post.id}) }}">edit</a>
{{ include('post/_delete_form.html.twig') }} {{ include('post/_delete_form.html.twig') }}
<div data-turbo="true">
<div id="comments">
{% for comment in post.comments %}
{{ include('/comment/comment.html.twig') }}
{% endfor %}
</div>
{{ form_start(form) }}
{{ form_widget(form) }}
<button type="submit" class="btn btn-primary">Comment</button>
{{ form_end(form) }}
</div>
{% endblock %} {% endblock %}

View File

@@ -125,4 +125,26 @@ class PostControllerTest extends WebTestCase
self::assertResponseRedirects('/'); self::assertResponseRedirects('/');
self::assertSame(0, $this->repository->count()); self::assertSame(0, $this->repository->count());
} }
public function testPostComment()
{
$fixture = new Post();
$fixture->setFoundDate(new \DateTimeImmutable('2024-01-01 00:00:00'));
$fixture->setCommentary('Cool stuff');
$this->manager->persist($fixture);
$this->manager->flush();
$this->client->request('GET', sprintf('%s%s', $this->path, $fixture->getId()));
self::assertResponseStatusCodeSame(200);
$this->client->submitForm('Comment', [
'comment[content]' => 'This is a comment',
]);
self::assertResponseRedirects(sprintf('%s%s', $this->path, $fixture->getId()));
$comments = $this->repository->find($fixture->getId())->getComments();
self::assertSame(1, $comments->count());
}
} }