Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Voir cette série
Mon CMS - Les commentaires
Dimanche 29 septembre 2024 15:50

On a bien avancé dans notre administration du CMS avec les catégories, les articles, les pages, et dans la précédente étape les comptes. À présent, nous allons nous pencher sur les commentaires. Il est intéressant d'en avoir une liste avec les principales informations (auteur, date, article). Nous devons aussi pouvoir à partir de cette liste supprimer un commentaire, le modifier, et y répondre. C'est tout l'objet du présent article.

Pour rappel, la table des matières est ici.

Un composant pour les commentaires

On a à nouveau besoin d'un composant Volt pour gérer le tableau des commentaires et le code PHP qui va faire tout le traitement :

php artisan make:volt admin/comments/index --class

On va ajouter la route pour l'atteindre :

Route::middleware('auth')->group(function () {
	...
	Route::middleware(IsAdminOrRedac::class)->prefix('admin')->group(function () {
		...
		Volt::route('/comments/index', 'admin.comments.index')->name('comments.index');

Les rédacteurs doivent avoir accès aux commentaires qui concernent leurs articles.

On ajoute un item dans la barre latérale (admin.sidebar) :

@if (Auth::user()->isAdmin())
    ...
@endif
<x-menu-item icon="c-chat-bubble-left" title="{{ __('Comments') }}" link="{{ route('comments.index') }}" />

On peut désormais atteindre le nouveau composant, il ne nous reste plus qu'à le compléter.

Le tableau des commentaires

Voilà le code complet du composant comments.index, je vais commenter après les points essentiels :

<?php

use App\Models\Comment;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\{Layout, Title};
use Livewire\Volt\Component;
use Livewire\WithPagination;
use Mary\Traits\Toast;

new #[Title('Comments'), Layout('components.layouts.admin')] 
class extends Component {
    use Toast, WithPagination;

    public string $search = '';
    public array $sortBy = ['column' => 'created_at', 'direction' => 'desc'];
    public $role = 'all';

    public function deleteComment(Comment $comment): void
    {
        $comment->delete();
        $this->success(__('Comment deleted'));
    }

    public function validComment(Comment $comment): void
    {
        $comment->user->valid = true;
        $comment->user->save();

        $this->success(__('Comment validated'));
    }

    public function headers(): array
    {
        return [['key' => 'user_name', 'label' => __('Author')], ['key' => 'body', 'label' => __('Comment'), 'sortable' => false], ['key' => 'post_title', 'label' => __('Post')], ['key' => 'created_at', 'label' => __('Sent on')]];
    }

    public function comments(): LengthAwarePaginator
    {
        return Comment::query()
            ->when($this->search, fn($q) => $q->where('body', 'like', "%{$this->search}%"))
            ->when('post_title' === $this->sortBy['column'], fn($q) => $q->join('posts', 'comments.post_id', '=', 'posts.id')->orderBy('posts.title', $this->sortBy['direction']), fn($q) => $q->orderBy($this->sortBy['column'], $this->sortBy['direction']))
            ->when(Auth::user()->isRedac(), fn($q) => $q->whereRelation('post', 'user_id', Auth::id()))
            ->with([
                'user:id,name,email,valid',
                'post:id,title,slug,user_id',
            ])
            ->withAggregate('user', 'name')
            ->paginate(10);
    }

    public function with(): array
    {
        return [
            'headers' => $this->headers(),
            'comments' => $this->comments(),
        ];
    }
}; ?>

<div>
    <x-header title="{{ __('Comments') }}" separator progress-indicator>
        <x-slot:actions>
            <x-button icon="s-building-office-2" label="{{ __('Dashboard') }}" class="btn-outline lg:hidden"
                link="{{ route('admin') }}" />
            <x-input placeholder="{{ __('Search...') }}" wire:model.live.debounce="search" clearable
                icon="o-magnifying-glass" />
        </x-slot:actions>
    </x-header>
    <x-card>
        <x-table striped :headers="$headers" :rows="$comments" link="/admin/comments/{id}/edit" :sort-by="$sortBy"
            with-pagination>
            @scope('cell_created_at', $comment)
                {{ $comment->created_at->isoFormat('LL') }} {{ __('at') }}
                {{ $comment->created_at->isoFormat('HH:mm') }}
            @endscope
            @scope('cell_body', $comment)
                {!! nl2br($comment->body) !!}
            @endscope
            @scope('cell_user_name', $comment)
                <x-avatar :image="Gravatar::get($comment->user->email)">
                    <x-slot:title>
                        {{ $comment->user->name }}
                    </x-slot:title>
                </x-avatar>
            @endscope
            @scope('cell_post_title', $comment)
                {{ $comment->post->title }}
            @endscope
            @scope('actions', $comment)
                <div class="flex">
                    @if (!$comment->user->valid)
                        <x-popover>
                            <x-slot:trigger>
                                <x-button icon="c-eye" wire:click="validComment({{ $comment->id }})"
                                    wire:confirm="{{ __('Are you sure to validate this user for comment?') }}" spinner
                                    class="text-yellow-500 btn-ghost btn-sm" />
                            </x-slot:trigger>
                            <x-slot:content class="pop-small">
                                @lang('Validate the user')
                            </x-slot:content>
                        </x-popover>
                    @endif
                    <x-popover>
                        <x-slot:trigger>
                            <x-button icon="s-document-text" link="{{ route('posts.show', $comment->post->slug) }}" spinner
                                class="btn-ghost btn-sm" />
                        </x-slot:trigger>
                        <x-slot:content class="pop-small">
                            @lang('Show post')
                        </x-slot:content>
                    </x-popover>
                    <x-popover>
                        <x-slot:trigger>
                            <x-button icon="o-trash" wire:click="deleteComment({{ $comment->id }})"
                                wire:confirm="{{ __('Are you sure to delete this comment?') }}" spinner
                                class="text-red-500 btn-ghost btn-sm" />
                        </x-slot:trigger>
                        <x-slot:content class="pop-small">
                            @lang('Delete')
                        </x-slot:content>
                    </x-popover>
                </div>
            @endscope
        </x-table>
    </x-card>
</div>

On a encore besoin de traductions :

"Comment validated": "Commentaire validé",
"Comment deleted": "Commentaire supprimé",
"Sent on": "Envoyé le",
"Post": "Article",
"Are you sure to validate this user for comment?": "Etes-vous sûr de vouloir valider cet utilisateur pour les commentaires ?",
"Validate the user": "Valider l'utilisateur",

On a cet aspect global :

Pour chaque commentaire, on peut ainsi connaître : l'auteur, le contenu, l'article concerné, la date et l'heure d'envoi.

On dispose de trois actions : 

  1. valider l'utilisateur pour les commentaires (l'œil n'apparaît que s'il n'est pas déjà validé)
  2. accéder à l'article commenté
  3. supprimer le commentaire

La requête expliquée

La requête pour récupérer les commentaires est moyennement complexe et mérite quelques explications, en particulier pour les débutants qui souvent ont un peu du mal avec Eloquent.

public function comments(): LengthAwarePaginator

C'est une fonction nommée comments qui retourne un objet de type LengthAwarePaginator qui est une classe Laravel qui gère la pagination des résultats.

Comment::query()

On commence une nouvelle requête sur le modèle Comment.

->when($this->search, fn($q) => $q->where('body', 'like', "%{$this->search}%"))

when() est une méthode conditionnelle. Si $this->search existe, elle exécute la fonction donnée. Si une recherche est effectuée, elle filtre les commentaires dont le corps contient le texte recherché.

->when('post_title' === $this->sortBy['column'], fn($q) => $q->join('posts', 'comments.post_id', '=', 'posts.id')->orderBy('posts.title', $this->sortBy['direction']), fn($q) => $q->orderBy($this->sortBy['column'], $this->sortBy['direction']))

ce deuxième ->when() est plus complexe :

  • Il vérifie si on trie par 'post_title'.
  • Si oui, il joint la table 'posts' et trie par le titre du post.
  • Sinon, il trie par la colonne spécifiée dans $this->sortBy['column'].
->when(Auth::user()->isRedac(), fn($q) => $q->whereRelation('post', 'user_id', Auth::id()))

Si l'utilisateur connecté est un rédacteur (isRedac()), il filtre les commentaires pour ne montrer que ceux des posts de l'utilisateur connecté.

->with([...])

On charge (eager loads) les relations 'user' et 'post' avec les colonnes spécifiées pour optimiser les performances.

->withAggregate('user', 'name')

On ajoute le nom de l'utilisateur comme une colonne agrégée aux résultats.

->paginate(10)

On pagine les résultats, retournant 10 éléments par page.

Modifier un commentaire

On se prépare

On va avoir besoin de connaître la profondeur d'un commentaire, alors on ajoute une fonction dans le modèle Comment :

class Comment extends Model
{
    ...
	public function getDepth(): int
	{
		return $this->parent ? $this->parent->getDepth() + 1 : 0;
	}

D'autre part, on va encore utiliser TinyMCE pour la réponse à un commentaire, mais avec une barre d'outil allégée par rapport à ce qu'on a prévu pour la rédaction des articles et des pages. On ajoute cette configuration (config/tinymce) :

return [
	'config' => [
		...
	],
	'config_comment' => [
		'language'       => env('APP_TINYMCE_LOCALE', 'en_US'),
		'plugins'        => 'codesample',
		'toolbar'        => 'undo redo | styles | copy cut paste pastetext | hr | codesample',
		'toolbar_sticky' => true,
		'min_height'     => 300,
		'license_key'    => 'gpl',
	],
];

Le composant

Lorsqu'on clique sur une ligne du tableau, on doit accéder au formulaire de modification du commentaire ainsi qu'à celui de la réponse. On a à nouveau besoin d'un composant Volt pour gérer ce formulaire et faire tout le traitement :

php artisan make:volt admin/comments/edit --class

Il nous faut une route :

Route::middleware('auth')->group(function () {
	...
	Route::middleware(IsAdminOrRedac::class)->prefix('admin')->group(function () {
		...
		Volt::route('/comments/{comment}/edit', 'admin.comments.edit')->name('comments.edit');

Et le code du composant :

<?php

use App\Models\Comment;
use Livewire\Attributes\{Layout, Title};
use Livewire\Volt\Component;
use Mary\Traits\Toast;

new #[Title('Edit Comment'), Layout('components.layouts.admin')] 
class extends Component {
	use Toast;

	public Comment $comment;
	public string $body        = '';
	public string $body_answer = '';
	public int $depth          = 0;

	public function mount(Comment $comment): void
	{
		$this->authorizeCommentAccess($comment);

		$this->comment = $comment;
		$this->fill($this->comment->toArray());
		$this->depth = $this->comment->getDepth();
	}

	public function save()
	{
		$data = $this->validate([
			'body' => 'required|max:10000',
		]);

		$this->comment->update($data);

		$this->success(__('Comment edited with success.'), redirectTo: '/admin/comments/index');
	}

	public function saveAnswer()
	{
		$data = $this->validate([
			'body_answer' => 'required|max:10000',
		]);

		$data['body']      = $data['body_answer'];
		$data['user_id']   = Auth::id();
		$data['parent_id'] = $this->comment->id;
		$data['post_id']   = $this->comment->post_id;

		Comment::create($data);

		$this->success(__('Answer created with success.'), redirectTo: '/admin/comments/index');
	}

	private function authorizeCommentAccess(Comment $comment): void
	{
		if (auth()->user()->isRedac() && $comment->post->user_id !== auth()->id()) {
			abort(403);
		}
	}
}; ?>

<div>
    <x-header title="{{ __('Edit a comment') }}" separator progress-indicator>
        <x-slot:actions class="lg:hidden">
            <x-button icon="s-building-office-2" label="{{ __('Dashboard') }}" class="btn-outline"
                link="{{ route('admin') }}" />
        </x-slot:actions>
    </x-header>
    <x-card>
        <x-form wire:submit="save">
            <x-textarea wire:model="body" label="{{ __('Content') }}" hint="{{ __('Max 10000 chars') }}" rows="5"
                inline />
            <x-slot:actions>
                <x-button label="{{ __('Cancel') }}" icon="o-hand-thumb-down" class="btn-outline"
                    link="/admin/comments/index" />
                <x-button label="{{ __('Save') }}" icon="o-paper-airplane" spinner="save" type="submit"
                    class="btn-primary" />
            </x-slot:actions>
        </x-form>

        @if ($depth < 3)
            <x-card title="{{ __('Your answer') }}" shadow separator progress-indicator>
                <x-form wire:submit="saveAnswer">
                    <x-editor wire:model="body_answer" label="{{ __('Content') }}" :config="config('tinymce.config_comment')" folder="photos" />
                    <x-slot:actions>
                        <x-button label="{{ __('Save') }}" icon="o-paper-airplane" spinner="save" type="submit"
                            class="btn-primary" />
                    </x-slot:actions>
                </x-form>
            </x-card>
        @endif
    </x-card>
</div>

Quatre petites traductions :

"Edit a comment": "Modifier un commentaire",
"Your answer": "Votre réponse",
"Comment edited with success.": "Commentaire mis à jour avec succès.",
"Answer created with success.": "Reponse ajoutée avec succès.",

On se retrouve avec le regroupement des deux formulaires sur la même page :

Pour l'accès, on vérifie qu'un rédacteur est bien l'auteur de l'article commenté avec la fonction authorizeCommentAccess.

On ne propose de faire une réponse que si la profondeur est inférieure à 3.

Pour le reste, on se retrouve avec du code qu'on a déjà rencontré précédemment.

Vérifiez que tout fonctionne correctement.

Le tableau de bord

On va ajouter le lien du tableau des comptes à partir du tableau de bord (admin.index).

D'abord dans les statistiques :

<a href="{{ route('comments.index') }}" class="flex-grow">
    <x-stat title="{{ __('Comments') }}" value="{{ $commentsNumber }}" icon="c-chat-bubble-left"
        class="shadow-hover" />
</a>

Un clic sur la statistique des commentaires doit maintenant mener au tableau :

D'autre part, dans la liste des commentaires récents, on doit ajouter le lien ici :

<x-popover>
    <x-slot:trigger>
        <x-button icon="c-eye" link="{{ route('comments.edit', $comment->id) }}" spinner class="btn-ghost btn-sm" />                         
    </x-slot:trigger>
    <x-slot:content class="pop-small">
        @lang('Edit or answer')
    </x-slot:content>
</x-popover>

Pour terminer, on va faire apparaître une alerte explicite pour les commentaires à valider :

@foreach ($comments as $comment)
    @if (!$comment->user->valid)
        <x-alert title="{!! __('Comment to valid from ') . $comment->user->name !!}" description="{!! $comment->body !!}" icon="c-chat-bubble-left"
            class="shadow-md alert-warning">
            <x-slot:actions>
                <x-button link="{{ route('comments.index') }}" label="{!! __('Show the comments') !!}" />
            </x-slot:actions>
        </x-alert>
        <br>
    @endif
@endforeach

On complète avec deux traductions :

"Comment to valid from ": "Commentaire à valider de ",
"Show the comments": "Voir les commentaires",

Conclusion

Nous pouvons maintenant gérer les commentaires dans notre CMS. Nous avons bien avancé, mais il reste du travail. Dans le prochain article, on s'occupera des menus. En effet, on a vu qu'ils sont dynamiques, il nous faut donc pouvoir en créer, les modifier, les supprimer, les organiser.

Pour vous simplifier la vie, vous pouvez charger le projet dans son état à l’issue de ce chapitre.



Par bestmomo

Aucun commentaire

Article précédent : Mon CMS - Les comptes
Article suivant : Mon CMS - Les menus (partie 1)