Implement comments
continuous-integration/drone/push Build is passing Details

pull/10/head
Clément FRÉVILLE 11 months ago
parent b4a1ae592f
commit 67ff9ff8db

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

@ -0,0 +1,5 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);

@ -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()]);
}
}

@ -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();
}
}

@ -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<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
{
return $this->id;
@ -158,4 +171,34 @@ class Post
$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;
}
}

@ -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<int, Comment>
*/
#[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<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;
}
}

@ -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,
]);
}
}

@ -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()
// ;
// }
}

@ -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);

@ -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);
}
}

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

@ -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>

@ -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>

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

@ -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 %}

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

@ -1,4 +1,4 @@
{{ form_start(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) }}

@ -4,16 +4,16 @@
{% block body %}
{% 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">
<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>
<p class="card-subtitle mb-2 text-muted">{{ post.latitude }}, {{ post.longitude }}, {{ post.altitude }}m</p>
<p class="card-text">{{ post.commentary }}</p>
</div>
<div class="card-footer">
28 ❤️
128 💬
{{ post.comments.count() }} 💬
</div>
</div>
{% endfor %}

@ -43,4 +43,17 @@
<a href="{{ path('app_post_edit', {'id': post.id}) }}">edit</a>
{{ 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 %}

@ -125,4 +125,26 @@ class PostControllerTest extends WebTestCase
self::assertResponseRedirects('/');
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());
}
}

Loading…
Cancel
Save