Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Voir cette série
Mon CMS - Modifier un article
Lundi 23 septembre 2024 11:32

Dans le précédent article, nous avons créé le formulaire pour ajouter un article à notre CMS. Mais il faut pouvoir ensuite le modifier au besoin, et ça arrive bien souvent ! Nous allons réutiliser pas mal de code déjà créé précédemment et notre travail en sera d'autant plus allégé.

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

Un composant pour le formulaire

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

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

On va ajouter la route pour l'atteindre (administrateurs et rédacteurs) :

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

Et dans la foulée un lien dans le tableau des articles (posts.index) :

<x-table striped :headers="$headers" :rows="$posts" :sort-by="$sortBy" link="/admin/posts/{slug}/edit" with-pagination>

Il suffira ainsi de cliquer sur la ligne de 'l'article dans le tableau pour ouvrir directement le formulaire de modification.

Le formulaire

Le formulaire va être presque identique à celui de la création. On pourrait sans doute mutualiser le code, mais on ne va pas le faire et séparer les deux formulaires. Pour la partie HTML :

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

    <x-card>
        <x-form wire:submit="save">
           	<x-select label="{{ __('Category') }}" option-label="title" :options="$categories" wire:model="category_id"
                wire:change="$refresh" />
			<br>
            <div class="flex gap-6">
                <x-checkbox label="{{ __('Published') }}" wire:model="active" />
                <x-checkbox label="{{ __('Pinned') }}" wire:model="pinned" />
            </div>
            <x-input type="text" wire:model="title" label="{{ __('Title') }}"
                placeholder="{{ __('Enter the title') }}" wire:change="$refresh" />
            <x-input type="text" wire:model="slug" label="{{ __('Slug') }}" />
            <x-editor wire:model="body" label="{{ __('Content') }}" :config="config('tinymce.config')"
                folder="{{ 'photos/' . now()->format('Y/m') }}" />
            <x-card title="{{ __('SEO') }}" shadow separator>
                <x-input placeholder="{{ __('Title') }}" wire:model="seo_title" hint="{{ __('Max 70 chars') }}" />
                <br>
                <x-textarea label="{{ __('META Description') }}" wire:model="meta_description"
                    hint="{{ __('Max 160 chars') }}" rows="2" inline />
                <br>
                <x-textarea label="{{ __('META Keywords') }}" wire:model="meta_keywords"
                    hint="{{ __('Keywords separated by comma') }}" rows="1" inline />
            </x-card>
            <x-file wire:model="photo" label="{{ __('Featured image') }}"
                hint="{{ __('Click on the image to modify') }}" accept="image/png, image/jpeg">
                <img src="{{ asset('storage/photos/' . $post->image) }}" class="h-40" />
            </x-file>
            <x-slot:actions>
                <x-button label="{{ __('Preview') }}" icon="m-sun" link="{{ '/posts/' . $post->slug }}" external
                    class="btn-outline" />
                <x-button label="{{ __('Save') }}" icon="o-paper-airplane" spinner="save" type="submit"
                    class="btn-primary" />
            </x-slot:actions>
        </x-form>
    </x-card>
</div>

Pour le PHP, voilà ce que ça donne :

<?php

use App\Models\{Category, Post};
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\{Layout, Title};
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
use Mary\Traits\Toast;

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

	public int $postId;
	public ?Collection $categories;
	public int $category_id;
	public Post $post;
	public string $body                  = '';
	public string $title                 = '';
	public string $slug                  = '';
	public bool $active                  = false;
	public bool $pinned                  = false;
	public string $seo_title             = '';
	public string $meta_description      = '';
	public string $meta_keywords         = '';
	public ?TemporaryUploadedFile $photo = null;

	public function mount(Post $post): void
	{
		if (Auth()->user()->isRedac() && $post->user_id !== Auth()->id()) {
			abort(403);
		}

		$this->post = $post;
		$this->fill($this->post);
		$this->categories = Category::orderBy('title')->get();
	}

    public function updatedTitle($value)
	{
        $this->slug      = Str::slug($value);
        $this->seo_title = $value;
	}

	public function save()
	{
		$data = $this->validate([
			'title'            => 'required|string|max:255',
			'body'             => 'required|string|max:16777215',
			'category_id'      => 'required',
			'photo'            => 'nullable|image|max:2000',
			'active'           => 'required',
			'pinned'           => 'required',
			'slug'             => ['required', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('posts')->ignore($this->post->id)],
			'seo_title'        => 'required|max:70',
			'meta_description' => 'required|max:160',
			'meta_keywords'    => 'required|regex:/^[A-Za-z0-9-éèàù]{1,50}?(,[A-Za-z0-9-éèàù]{1,50})*$/',
		]);

		if ($this->photo) {			
			$date          = now()->format('Y/m');
			$path          = $date . '/' . basename($this->photo->store('photos/' . $date, 'public'));
			$data['image'] = $path;
		}

		$data['body'] = replaceAbsoluteUrlsWithRelative($data['body']);

		$this->post->update(
			$data + [
				'category_id' => $this->category_id,
			],
		);

		$this->success(__('Post updated with success.'));
	}
}; ?>

On a pratiquement les mêmes propriétés que pour la création, il faut surtout ajouter une propriété ($post) pour contenir l'article en cours de modification. 

Au chargement du composant (mount) il faut :

  • vérifier que l'utilisateur connecté a bien le droit d'accéder à ce formulaire (sinon on envoie une erreur 403)
  • récupérer l'article à modifier
  • renseigner les valeurs des propriétés (merci à la fonction fill)
  • charger toutes les catégories (si on veut en changer)

Pour la validation, j'ai changé de stratégie par rapport à la création et je n'ai pas utilisé l'attribut Validate de Livewire.

On va ajouter des traductions :

"Edit a post": "Modifier un article",
"Post updated with success.": "Article mis à jour avec succès.",

Vous devriez avoir un formulaire fonctionnel.

Accès direct au formulaire

Ce qui serait pratique, c'est d'avoir un bouton pour accéder directement au formulaire de modification lorsqu'on consulte un article, plutôt que de devoir aller dans l'administration pour le retrouver.

Dans posts.show, on ajoute le bouton :

@auth
    <x-popover>
        ...
    </x-popover>
    @if (Auth::user()->isAdmin() || Auth::user()->id == $post->user_id)
        <x-popover>
            <x-slot:trigger>
                <x-button icon="c-pencil-square" link="{{ route('posts.edit', $post) }}" spinner
                    class="btn-ghost btn-sm" />
            </x-slot:trigger>
            <x-slot:content class="pop-small">
                @lang('Edit this post')
            </x-slot:content>
        </x-popover>
    @endif
@endauth

Une petite traduction :

"Edit this post": "Modifier cet article",

Et on a le bouton pour chaque article affiché :

Bien sûr, on limite cet accès à l'administrateur et au rédacteur de l'article.

Cloner un article

Une autre fonctionnalité intéressante est le clonage d'un article. Parfois, on veut récupérer la trame d'un article pour en rédiger un autre. On ajoute un nouveau bouton dans posts.show :

@if (Auth::user()->isAdmin() || Auth::user()->id == $post->user_id)
    <x-popover>
        ...
    </x-popover>
    <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 this post')
        </x-slot:content>
    </x-popover>
@endif

Encore une traduction :

"Clone this post": "Dupliquer cet article",

Et on a le bouton :

Il nous reste à ajouter le code pour effectuer l'action de clonage :

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

    redirect()->route('posts.edit', $clonedPost->slug);
}

L'article en cours est intégralement copié, un nouveau slug unique est généré, on prend la précaution de rendre le nouvel article non actif, puis on ouvre le formulaire de modification.

Le tableau de bord

Sur le tableau de bord, on a des pavés de statistiques, en particulier pour les articles :

Ce qui serait bien, c'est qu'un clic sur ce pavé nous emmène directement au tableau des articles. Dans admin.index, on change cette partie du code :

<a href="{{ route('posts.index') }}" class="flex-grow">
    <x-stat title="{{ __('Posts') }}" description="" value="{{ $posts->count() }}" icon="s-document-text"
        class="shadow-hover" />
</a>

Et ça devrait fonctionner !

Conclusion

On a bouclé la gestion des articles. Notre prochaine étape sera la gestion des catégories.

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 - Créer un article
Article suivant : Mon CMS - Les catégories