Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Voir cette série
Mon CMS - Tableau des articles
Mardi 17 septembre 2024 18:19

Dans notre précédent article, nous avons établi les fondations de notre tableau de bord d'administration. Cette étape cruciale a impliqué la création d'un nouveau layout et d'une barre de navigation latérale pour une navigation fluide. Soucieux de l'ergonomie et de la sécurité de notre code, nous avons également mis en place des middlewares spécifiques pour les rédacteurs et les administrateurs, garantissant ainsi un accès approprié aux différentes fonctionnalités.

Maintenant que cette infrastructure est solidement en place, nous pouvons nous concentrer sur l'ajout de fonctionnalités essentielles à notre CMS. Au cœur de notre système se trouvent les articles, et leur gestion efficace est primordiale pour le bon fonctionnement de notre plateforme.

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

Conception du tableau de gestion des articles

Le tableau

Pour faciliter la gestion des articles, nous allons créer un tableau intuitif et complet. Ce tableau rassemblera les informations clés de chaque article en un coup d'œil, permettant aux administrateurs et aux rédacteurs de gérer efficacement le contenu. Voici les éléments que nous inclurons dans notre tableau :

  • Titre de l'article
  • Nom de l'auteur
  • Catégorie
  • Statut de publication (publié, brouillon)
  • Nombre de commentaires
  • Date de création
  • Actions rapides (modifier, cloner, supprimer)

Fonctionnalités avancées

En plus de ces informations de base, nous allons envisager d'ajouter des fonctionnalités supplémentaires pour améliorer l'expérience utilisateur :

  • Filtrage : Permettre aux utilisateurs de filtrer les articles par catégorie
  • Recherche rapide : Intégrer un contrôle de recherche pour trouver rapidement un article spécifique
  • Pagination : Pour gérer efficacement un grand nombre d'articles.

Un composant pour le tableau

On crée un composant Volt :

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

La route

On ajoute une route pour l'atteindre :

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

La navigation

On ajoute un lien dans la barre latérale de l'administration :

<x-menu-separator />
<x-menu-item title="{{ __('Dashboard') }}" icon="s-building-office-2" link="{{ route('admin') }}" />
<x-menu-sub title="{{ __('Posts') }}" icon="s-document-text">
    <x-menu-item title="{{ __('All posts') }}" link="{{ route('posts.index') }}" />
</x-menu-sub>   

Avec une traduction :

"All posts": "Tous les articles",

On commence à coder le tableau

Je vais profiter de cet article pour détailler la construction d'un tableau avec MaryUI. Pour commencer, on va se contenter d'une version simple :

<?php

use App\Models\Post;
use App\Repositories\PostRepository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\{Layout, Title};
use Livewire\Volt\Component;

new #[Title('List Posts'), Layout('components.layouts.admin')] class extends Component {

	public function headers(): array
	{
		$headers = [['key' => 'title', 'label' => __('Title')]];

		if (Auth::user()->isAdmin()) {
			$headers = array_merge($headers, [['key' => 'user_name', 'label' => __('Author')]]);
		}

		return array_merge($headers, [['key' => 'category_title', 'label' => __('Category')], ['key' => 'comments_count', 'label' => __('')], ['key' => 'active', 'label' => __('Published')], ['key' => 'date', 'label' => __('Date')]]);
	}

	public function posts()
	{
		return Post::query()
			->select('id', 'title', 'slug', 'category_id', 'active', 'user_id', 'created_at', 'updated_at')
			->when(Auth::user()->isAdmin(), fn (Builder $q) => $q->withAggregate('user', 'name'))
			->when(!Auth::user()->isAdmin(), fn (Builder $q) => $q->where('user_id', Auth::id()))
			->withAggregate('category', 'title')
			->withcount('comments')
			->latest()
            ->get();
	}

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

<div>
    <x-header title="{{ __('Posts') }}" separator progress-indicator>
        <x-slot:actions>
            <x-button label="{{ __('Add a post') }}" class="btn-outline lg:hidden" link="#" />
            <x-button icon="s-building-office-2" label="{{ __('Dashboard') }}" class="btn-outline lg:hidden"
                link="{{ route('admin') }}" />
        </x-slot:actions>
    </x-header>

    @if ($posts->count() > 0)
        <x-card>
            <x-table striped :headers="$headers" :rows="$posts" link="#" >
                @scope('header_comments_count', $header)
                    {{ $header['label'] }}
                    <x-icon name="c-chat-bubble-left" />
                @endscope

                @scope('cell_user.name', $post)
                    {{ $post->user->name }}
                @endscope
                @scope('cell_category.title', $post)
                    {{ $post->category->title }}
                @endscope
                @scope('cell_comments_count', $post)
                    @if ($post->comments_count > 0)
                        <x-badge value="{{ $post->comments_count }}" class="badge-primary" />
                    @endif
                @endscope
                @scope('cell_active', $post)
                    @if ($post->active)
                        <x-icon name="o-check-circle" />
                    @endif
                @endscope
                @scope('cell_date', $post)
                    @lang('Created') {{ $post->created_at->diffForHumans() }}
                    @if ($post->updated_at != $post->created_at)
                        <br>
                        @lang('Updated') {{ $post->updated_at->diffForHumans() }}
                    @endif
                @endscope
            </x-table>
        </x-card>
    @endif
</div>

Avec ces traductions :

"Title": "Titre",
"Author": "Auteur",
"Updated": "Mis à jour",
"Category": "Catégorie",
"Published": "Publié",
"Add a post": "Ajouter un article",

Deux boutons apparaissent sur petit écran :

En effet, dans ce cas, la navigation latérale est cachée et il est ainsi plus facile de cliquer sur ces boutons.

Dans la partie PHP, on doit envoyer au tableau :

  • l'intitulé des colonnes (label) et leur correspondance au niveau des données (key) :
public function headers(): array
{
    $headers = [['key' => 'title', 'label' => __('Title')]];

    if (Auth::user()->isAdmin()) {
        $headers = array_merge($headers, [['key' => 'user_name', 'label' => __('Author')]]);
    }

    return array_merge($headers, [['key' => 'category_title', 'label' => __('Category')], ['key' => 'comments_count', 'label' => __('')], ['key' => 'active', 'label' => __('Published')], ['key' => 'date', 'label' => __('Date')]]);
}
  • les données des articles nécessaires et suffisantes (en particulier, on ne va pas charger le contenu des articles) pour le tableau :
public function posts()
{
    return Post::query()
        ->select('id', 'title', 'slug', 'category_id', 'active', 'user_id', 'created_at', 'updated_at')
        ->when(Auth::user()->isAdmin(), fn (Builder $q) => $q->withAggregate('user', 'name'))
        ->when(!Auth::user()->isAdmin(), fn (Builder $q) => $q->where('user_id', Auth::id()))
        ->withAggregate('category', 'title')
        ->withcount('comments')
        ->latest()
        ->get();
}

Les lignes utilisant withAggregate sont utilisées pour ajouter des sous-requêtes qui incluent des valeurs agrégées basées sur des relations. Voici une explication détaillée de ces lignes :

    ->when(Auth::user()->isAdmin(), fn (Builder $q) => $q->withAggregate('user', 'name'))

Cette ligne ajoute conditionnellement une sous-requête pour inclure le nom de l'utilisateur associé à chaque post, mais seulement si l'utilisateur authentifié est un administrateur. Si l'utilisateur est un administrateur, chaque post aura un attribut supplémentaire user_name contenant le nom de l'utilisateur qui a créé le post.

    ->withAggregate('category', 'title')

Cette ligne ajoute une sous-requête pour inclure le titre de la catégorie associée à chaque post. Après cette opération, chaque post aura un attribut supplémentaire category_title contenant le titre de sa catégorie.

L'utilisation de withAggregate présente plusieurs avantages :

  • Optimisation des performances : Au lieu de charger toutes les relations complètes (ce qui pourrait être fait avec with('user', 'category')), withAggregate permet de récupérer uniquement les colonnes spécifiques dont vous avez besoin, réduisant ainsi la quantité de données transférées depuis la base de données
  • Réduction du nombre de requêtes : withAggregate intègre les informations des relations dans la requête principale via des sous-requêtes, évitant ainsi des requêtes supplémentaires pour chaque relation
  • Flexibilité : Vous pouvez facilement ajouter des agrégations conditionnelles, comme dans le cas de la vérification de l'administrateur

En résumé, ces lignes withAggregate permettent d'enrichir efficacement les résultats de la requête avec des informations provenant de relations, tout en maintenant de bonnes performances

La ligne avec withCount est utilisée pour compter le nombre d'enregistrements liés dans une relation, sans avoir à charger ces enregistrements. Voici comment fonctionne withCount :

  • Il ajoute une sous-requête à la requête principale pour compter les enregistrements liés.
  • Il crée un nouvel attribut sur le modèle principal avec le nom {relation}_count, où {relation} est le nom de la relation comptée.

Dans la ligne :

->withCount('comments')

Cette méthode va compter le nombre de commentaires associés à chaque post. Elle ajoutera un attribut comments_count à chaque instance de Post retournée par la requête.  withCount est une méthode efficace pour obtenir le nombre d'enregistrements liés sans avoir à charger ces enregistrements, ce qui peut grandement améliorer les performances de vos requêtes.

Dans le tableau, on transmet les titres et les données avec des propriétés :

<x-table striped :headers="$headers" :rows="$posts" link="#" >

La propriété link permet d'activer un lien pour chaque ligne du tableau, on l'utilisera pour permettre d'accéder directement au formulaire de modification de l'article, pour le moment, il n'est pas actif.

Ensuite, il n'y a rien de spécial à faire pour avoir à la fois le titre et la donnée lorsqu'on se contente des valeurs brutes, par exemple pour la catégorie avec le nom de celle-ci.

On peut effectuer des changements au niveau des titres, je l'ai fait pour les commentaires :

@scope('header_comments_count', $header)
    {{ $header['label'] }}
    <x-icon name="c-chat-bubble-left" />
@endscope

Ainsi, on affiche une icône :

De la même manière, on peut modifier l'apparence des valeurs, je l'ai fait plusieurs fois dans le tableau, par exemple pour ces mêmes commentaires avec un label contenant le nombre de commentaires de l'article :

@scope('cell_comments_count', $post)
    @if ($post->comments_count > 0)
        <x-badge value="{{ $post->comments_count }}" class="badge-primary" />
    @endif
@endscope

On peut ainsi complètement personnaliser le tableau.

Supprimer un article

On peut aussi ajouter des actions dans un tableau, on va en profiter pour prévoir un bouton pour la suppression de l'article :

    @scope('actions', $post)
        <x-popover>
            <x-slot:trigger>
                <x-button icon="o-trash" wire:click="deletePost({{ $post->id }})"
                    wire:confirm="{{ __('Are you sure to delete this post?') }}" 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>
    @endscope
</x-table>

Il faut ajouter une traduction pour le texte d'alerte avant suppression :

"Are you sure to delete this post?": "Etes-vous sûr de vouloir supprimer cet article ?",

Et évidemment ajouter le code PHP pour effectuer concrètement cette suppression :

...

use Mary\Traits\Toast;

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

...

public function deletePost(int $postId): void
{
    $post = Post::findOrFail($postId);		
    $post->delete();
    $this->success("{$post->title} " . __('deleted'));
}

Et encore une petite traduction :

"deleted": "supprimé",

Cloner un article

Ajoutons encore une action : le clonage d'un article. C'est bien pratique dans certaines circonstances.

On ajoute le bouton dans le tableau à côté de celui de la suppression :

@scope('actions', $post)
    <div class="flex">
        <x-popover>
            <x-slot:trigger>
                <x-button icon="o-finger-print" wire:click="clonePost({{ $post->id }})" spinner
                    class="btn-ghost btn-sm" />
            </x-slot:trigger>
            <x-slot:content class="pop-small">
                @lang('Clone')
            </x-slot:content>
        </x-popover>
        ...
    </div>
@endscope

Avec la traduction :

"Clone": "Dupliquer",

On ajoute une fonction dans le repository (PostRepository) pour créer un slug unique :

public function generateUniqueSlug(string $slug): string
{
	$newSlug = $slug;
	$counter = 1;
	while (Post::where('slug', $newSlug)->exists()) {
		$newSlug = $slug . '-' . $counter;
		++$counter;
	}
	return $newSlug;
}

Et enfin, on code le clonage dans le PHP du composant :

public function clonePost(int $postId): void
{
    $originalPost       = Post::findOrFail($postId);
    $clonedPost         = $originalPost->replicate();
    $postRepository     = new PostRepository();
    $clonedPost->slug   = $postRepository->generateUniqueSlug($originalPost->slug);
    $clonedPost->active = false;
    $clonedPost->save();

    // Ici on redirigera vers le formulaire de modification de l'article cloné
}

Avec cette méthode, le nouvel article n'est pas encore enregistré dans la base, On redirigera vers le formulaire de modification lorsqu'on l'aura créé.

La pagination

S'il y a de nombreux articles, ce qui est en général le cas dans une CMS, on a besoin d'une pagination. On commence par modifier le PHP :

use Illuminate\Pagination\LengthAwarePaginator;

...

use Livewire\WithPagination;

new #[Title('List Posts'), Layout('components.layouts.admin')] class extends Component {
    use Toast, WithPagination;
    
	public function posts(): LengthAwarePaginator
	{
		return Post::query()
			...
            ->paginate(6);
	}

Dans le HTML un petit ajout :

<x-table striped :headers="$headers" :rows="$posts" link="#" with-pagination >

Et ça fonctionne :

La recherche

Une autre fonctionnalité intéressante lorsqu'on a de nombreux articles est de pouvoir en trouver un avec une zone de recherche.

On ajoute la zone de saisie du texte :

<x-header title="{{ __('Posts') }}" separator progress-indicator>
	<x-slot:actions>
		<x-input placeholder="{{ __('Search...') }}" wire:model.live.debounce="search" clearable	icon="o-magnifying-glass" />
		...
	</x-slot:actions>
</x-header>

Et pour la partie PHP :

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

    public string $search = '';
    
	...

	public function posts()
	{
		return Post::query()
			...
            ->when($this->search, fn (Builder $q) => $q->where('title', 'like', "%{$this->search}%"))
			->latest()
            ->paginate(6);
	}

On se contente de faire la recherche sur le titre des articles. Vérifiez que ça fonctionne.

Filtrer par catégorie

Il peut être intéressant de limiter l'affichage des articles à une seule catégorie.

On ajoute la liste déroulante :

</x-header>

<x-collapse>
    <x-slot:heading>
        @lang(__('Filters'))
    </x-slot:heading>
    <x-slot:content>
        <x-select label="{{ __('Category') }}" :options="$categories" placeholder="{{ __('Select a category') }}"
            option-label="title" wire:model="category_id" wire:change="$refresh" />
    </x-slot:content>
</x-collapse>

<br>

Avec quelques traductions :

"Filters": "Filtres",
"Select a category": "Sélectionnez une catégorie",

On ajoute la gestion PHP :

use App\Models\{ Post, category };
use Illuminate\Database\Eloquent\{Builder, Collection};

...

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

    public string $search = '';
    public Collection $categories;
	public $category_id = 0;

    public function mount(): void
	{
		$this->categories = $this->getCategories();
	}

    public function getCategories(): Collection
	{
		if (Auth::user()->isAdmin()) {
			return Category::all();
		}

		return Category::whereHas('posts', fn (Builder $q) => $q->where('user_id', Auth::id()))->get();
	}
    
	public function posts(): LengthAwarePaginator
	{
		return Post::query()
			...
            ->when($this->category_id, fn (Builder $q) => $q->where('category_id', $this->category_id))
			...
	}

Si l'utilisateur connecté est un rédacteur, on limite le choix des catégories selon les articles qu'il a écrits.

Si tout se passe bien, ça devrait réagir correctement :

Le tri

On va aussi forcer le tri des articles selon certaines colonnes. Dans les tableaux de MaryUI, on peut rendre les colonnes triables par un clic très simplement.

On commence par créer une propriété $sortBy avec une valeur par défaut (le plus pertinent est la date de publication) :

public array $sortBy  = ['column' => 'created_at', 'direction' => 'desc'];

On actualise la requête pour tenir compte du tri :

public function posts(): LengthAwarePaginator
{
    return Post::query()
        ...
        ->when('date' === $this->sortBy['column'], fn (Builder $q) => $q->orderBy('created_at', $this->sortBy['direction']), fn (Builder $q) => $q->orderBy($this->sortBy['column'], $this->sortBy['direction']))
        ->latest()
        ->paginate(6);
}

Enfin, on informe le tableau :

<x-table striped :headers="$headers" :rows="$posts" :sort-by="$sortBy" link="#" with-pagination>

Maintenant avec un clic sur le titre d'une colonne, vous obtenez le tri (ascendant ou descendant) de cette colonne :

Conclusion

On dispose à présent d'un tableau des articles bien pratique. La prochaine étape va consister à créer un formulaire pour créer un article.

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 - L'administration
Article suivant : Mon CMS - Créer un article