From 8859cd00004136d98f085bcf44ce3a56e51c757d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20FR=C3=89VILLE?= Date: Tue, 11 Jun 2024 17:17:24 +0200 Subject: [PATCH] Implement comments (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Reviewed-on: https://codefirst.iut.uca.fr/git/clement.freville2/herbarium/pulls/10 --- assets/app.js | 1 + assets/bootstrap.js | 3 + src/Controller/PostController.php | 76 +++++++++++++ src/Entity/Comment.php | 119 +++++++++++++++++++++ src/Entity/Post.php | 43 ++++++++ src/Entity/User.php | 43 ++++++++ src/Form/CommentType.php | 29 +++++ src/Repository/CommentRepository.php | 43 ++++++++ src/Repository/PostRepository.php | 2 + src/Security/Voter/CommentVoter.php | 36 +++++++ templates/base.html.twig | 2 +- templates/comment/_delete_form.html.twig | 4 + templates/comment/comment.html.twig | 17 +++ templates/comment/deleted.stream.html.twig | 3 + templates/comment/edit.html.twig | 11 ++ templates/comment/new.stream.html.twig | 7 ++ templates/post/_form.html.twig | 2 +- templates/post/index.html.twig | 6 +- templates/post/show.html.twig | 13 +++ tests/Controller/PostControllerTest.php | 22 ++++ 20 files changed, 477 insertions(+), 5 deletions(-) create mode 100644 assets/bootstrap.js create mode 100644 src/Entity/Comment.php create mode 100644 src/Form/CommentType.php create mode 100644 src/Repository/CommentRepository.php create mode 100644 src/Security/Voter/CommentVoter.php create mode 100644 templates/comment/_delete_form.html.twig create mode 100644 templates/comment/comment.html.twig create mode 100644 templates/comment/deleted.stream.html.twig create mode 100644 templates/comment/edit.html.twig create mode 100644 templates/comment/new.stream.html.twig diff --git a/assets/app.js b/assets/app.js index e69de29..d2b348e 100644 --- a/assets/app.js +++ b/assets/app.js @@ -0,0 +1 @@ +import './bootstrap.js'; diff --git a/assets/bootstrap.js b/assets/bootstrap.js new file mode 100644 index 0000000..b22f20c --- /dev/null +++ b/assets/bootstrap.js @@ -0,0 +1,3 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle'; + +const app = startStimulusApp(); diff --git a/src/Controller/PostController.php b/src/Controller/PostController.php index d0f604d..af3d395 100644 --- a/src/Controller/PostController.php +++ b/src/Controller/PostController.php @@ -2,7 +2,10 @@ namespace App\Controller; +use App\Entity\Comment; use App\Entity\Post; +use App\Entity\User; +use App\Form\CommentType; use App\Form\PostType; use App\Repository\PostRepository; use Doctrine\ORM\EntityManagerInterface; @@ -10,7 +13,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +use Symfony\UX\Turbo\TurboBundle; class PostController extends AbstractController { @@ -62,8 +67,12 @@ class PostController extends AbstractController #[Route('/post/{id}', name: 'app_post_show', methods: ['GET'])] 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', [ 'post' => $post, + 'form' => $form, ]); } @@ -97,4 +106,71 @@ class PostController extends AbstractController 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()]); + } } diff --git a/src/Entity/Comment.php b/src/Entity/Comment.php new file mode 100644 index 0000000..42dd726 --- /dev/null +++ b/src/Entity/Comment.php @@ -0,0 +1,119 @@ +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(); + } +} diff --git a/src/Entity/Post.php b/src/Entity/Post.php index 22e346a..4d0b54f 100644 --- a/src/Entity/Post.php +++ b/src/Entity/Post.php @@ -8,6 +8,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use App\Repository\PostRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -61,6 +63,17 @@ class Post #[Groups(['post:collection:read'])] private ?Species $species = null; + /** + * @var Collection + */ + #[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 { return $this->id; @@ -158,4 +171,34 @@ class Post $this->publicationDate = new \DateTimeImmutable(); } } + + /** + * @return Collection + */ + 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; + } } diff --git a/src/Entity/User.php b/src/Entity/User.php index e924817..8519d9d 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -10,6 +10,8 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Put; use App\Repository\UserRepository; use App\State\UserPasswordHasher; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -60,6 +62,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Groups(['user:create', 'user:update'])] private ?string $plainPassword = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'author')] + private Collection $comments; + + public function __construct() + { + $this->comments = new ArrayCollection(); + } + public function getId(): ?int { 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 // $this->plainPassword = null; } + + /** + * @return Collection + */ + 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; + } } diff --git a/src/Form/CommentType.php b/src/Form/CommentType.php new file mode 100644 index 0000000..e41bdd8 --- /dev/null +++ b/src/Form/CommentType.php @@ -0,0 +1,29 @@ +add('content', TextareaType::class) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Comment::class, + ]); + } +} diff --git a/src/Repository/CommentRepository.php b/src/Repository/CommentRepository.php new file mode 100644 index 0000000..47ea76e --- /dev/null +++ b/src/Repository/CommentRepository.php @@ -0,0 +1,43 @@ + + */ +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() + // ; + // } +} diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index 2082982..64328a5 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -25,6 +25,8 @@ class PostRepository extends ServiceEntityRepository public function findPaginatedPosts(int $page, int $limit): Paginator { $query = $this->createQueryBuilder('p') + ->addSelect('species') + ->leftJoin('p.species', 'species') ->setFirstResult(($page - 1) * $limit) ->setMaxResults($limit); diff --git a/src/Security/Voter/CommentVoter.php b/src/Security/Voter/CommentVoter.php new file mode 100644 index 0000000..8761145 --- /dev/null +++ b/src/Security/Voter/CommentVoter.php @@ -0,0 +1,36 @@ +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); + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index 3aac3b0..1988983 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -12,7 +12,7 @@ {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} - +