Laravel 8

Créer un blog – les catégories

Nous avons dans le précédent article codé la modification et la suppression d’un article et nous en avons fini avec cette entité. Dans le présent article nous allons nous intéresser aux catégories : création d’un tableau, ajout, modification et suppression.

Comme nous aurons plusieurs entités à traiter de façon similaire on va créer un contrôleur de ressource universel. Ca va un peu nous occuper mais ensuite nous faire gagner beaucoup de temps et de code, et puis ça permet de comprendre certaines choses sur notre framework favori.

Vous pouvez télécharger le code final de cet article ici.

Edit au 26/02/2021 : j’ai ajouté une majuscule à la classe du datatable dans le contrôleur de ressource.

Un contrôleur de ressource

On a vu précédemment que nous avons créé une vue partagée (back.shared.index) pour générer des tableaux. D’autre part de façon classique il nous faut les actions suivantes :

  • create
  • store
  • edit
  • update
  • destroy

Ce qui change à chaque fois c’est :

  • la Datatable
  • les vues
  • la validation
  • le modèle

L’idée est donc de créer un contrôleur qui permette de s’adapter. On commence par créer la classe :

php artisan make:controller Back\ResourceController

Et on ajoute des propriétés pour différencier les entités :

<?php

namespace App\Http\Controllers\Back;

use App\Http\Controllers\Controller;
use Illuminate\Support\Str;

class ResourceController extends Controller
{
    protected $dataTable;
    protected $view;
    protected $formRequest;
    protected $singular;

Le constructeur

On va se baser sur les urls pour différencier. Il faut adopter quelques conventions pour les appellations pour que ça fonctionne. C’est dans le contrôleur que le gros du travail va se passer :

public function __construct()
{
    if(!app()->runningInConsole()) {

        $segment = getUrlSegment(request()->url(), 2); // categories ou newcategories

        if(substr($segment, 0, 3) === 'new') {
            $segment = substr($segment, 3);
        }

        $name = substr($segment, 0, -1); // categorie
        $this->singular = Str::singular($segment); // category

        $model = ucfirst($this->singular); // Category

        $this->model = 'App\Models\\' . $model; 
        $this->dataTable = 'App\DataTables\\' . ucfirst($name) . 'sDataTable';
        $this->view = 'back.' . $name . 's.form';
        $this->formRequest = 'App\Http\Requests\Back\\' . $model . 'Request'; 
    }
}

J’ai mis en commentaire le résultat des calculs pour les catégories. Comme j’utilise un nouveau helper on l’ajoute dans app/helpers :

if (!function_exists('getUrlSegment')) {
    function getUrlSegment($url, $segment)
    {   
        $url_path = parse_url(request()->url(), PHP_URL_PATH);
        $url_segments = explode('/', $url_path);
        return $url_segments[$segment];
    }
}

Prenons le cas de la liste des catégories, on a une url de la forme monblog.ext/admin/categories. Il nous faut récupérer le dernier segment de cette url :

$segment = getUrlSegment(request()->url(), 2);

On obtient alors categories.

Mais on doit prévoir le cas où on veut juste les nouvelles catégories (c’est un mauvais exemple parce que comme c’est l’administrateur qui crée les catégories ce n’est pas la peine d’en prévoir la liste, mais ça servira pour les utilisateurs, les contacts et les commentaires) et dans ce cas l’url est monblog.ext/admin/newcategories. Donc on doit supprimer les 3 premiers caractères pour ne garder que categories :

if(substr($segment, 0, 3) === 'new') {
    $segment = substr($segment, 3);
}

On a ensuite besoin du nom sans le « s » :

$name = substr($segment, 0, -1);

On obtient donc categorie.

On aura besoin du singulier (pour la langue anglaise) pour les messages :

$this->singular = Str::singular($segment);

Maintenant on obtient category. De là il est facile d’en déduire le nom du modèle en mettant une majuscule :

$model = ucfirst($this->singular);

On obitent ainsi Category.

Avec tout ça on affecte nos propriétés :

$this->model = 'App\Models\\' . $model; 
$this->dataTable = 'App\DataTables\\' . $name . 'sDataTable';
$this->view = 'back.' . $name . 's.form';
$this->formRequest = 'App\Http\Requests\Back\\' . $model . 'Request';

index

Pour la méthode index on adopte ce code :

public function index()
{
    return app()->make($this->dataTable)->render('back.shared.index');
}

On ne peut évidemment pas injecter la Datatable dans la méthode parce qu’on ne connait pas la classe. Alors on demande au container de Laravel (app, pour être plus précis l’application étendant le container) de générer une instance avec sa méthode make et ensuite de produire (render) la page avec la vue passée en paramètre.

Dans un contrôleur codé classiquement pour les catégories on aurait ce genre de code :

public function index(CategoriesDataTable $dataTable)
{
    return $dataTable->render('back.shared.index');
}

Avec évidemment le même résultat.

create et store

Pour la méthode create c’est tout simple, on renvoie la vue dont on a défini le nom dans la propriété :

public function create()
{
    return view($this->view);
}

Pour la méthode store on ne peut pas injecter la validation et on doit la générer à partir du conteneur, de même que le modèle :

public function store()
{
    $request = app()->make($this->formRequest);
    
    app()->make($this->model)->create($request->all());

    return back()->with(['ok' => __('The ' . $this->singular . ' has been successfully created.')]);
}

edit et update

Pour la méthode edit on ne peut pas utiliser le model binding :

public function edit($id)
{
    $element = app()->make($this->model)->find($id);

    return view($this->view, [$this->singular => $element]);
}

Pour la méthode update on doit encore utiliser le conteneur pour la validation et pour instancier le modèle :

public function update($id)
{
    $request = app()->make($this->formRequest);

    app()->make($this->model)->find($id)->update($request->all());

    return back()->with(['ok' => __('The ' . $this->singular . ' has been successfully updated.')]);
}

destroy

Il ne reste plus qu’à s’occuper de la suppression qui se passera systématiquement an Ajax :

public function destroy($id)
{
    app()->make($this->model)->find($id)->delete();

    return response()->json();
}

Notre contrôleur de ressource est maintenant prêt et on va l’utiliser pour les catégories.

Les catégories

Les routes

Pour les routes c’est simple, on veut toute la ressource sauf show et c’est réservé à l’administrateur et on pointe notre contrôleur de ressource :

use App\Http\Controllers\Back\
    ...
    ResourceController as BackResourceController
};

...

Route::prefix('admin')->group(function () {
    ...
    Route::middleware('admin')->group(function () {        
        ...
        Route::resource('categories', BackResourceController::class)->except(['show']);
    });

La configuration

Les titres

Dans app/config/titles on ajoute les titres pour les catégories :

'categories' => [
    'index'  => 'Categories',
    'create' => 'Category Creation',
    'edit'   => 'Category Edit',
],

Le menu

Dans app/config/menu on ajoute les items pour les catégories :

'Categories' => [
    'icon' => 'list',
    'role'   => 'admin',
    'children' => [
        [
            'name'  => 'All categories',
            'role'  => 'admin',
            'route' => 'categories.index',
        ],
        [
            'name'  => 'Add',
            'role'  => 'admin',
            'route' => 'categories.create',
        ],
        [
            'name'  => 'fake',
            'role'  => 'admin',
            'route' => 'categories.edit',
        ],
    ],
],

On doit avoir du nouveau dans le menu :

Si vous ne voyez pas ça c’est que vous n’êtes peut-être pas connecté avec l’administrateur !

Le tableau

On crée la Datatable :

php artisan datatables:make Categories

<?php

namespace App\DataTables;

use App\Models\Category;
use Yajra\DataTables\Html\Column;
use Yajra\DataTables\Services\DataTable;

class CategoriesDataTable extends DataTable
{
    use DataTableTrait;

    /**
     * Build DataTable class.
     *
     * @param mixed $query Results from query() method.
     * @return \Yajra\DataTables\DataTableAbstract
     */
    public function dataTable($query)
    {
        return datatables()
            ->eloquent($query)
            ->editColumn('posts_count', function ($category) {
                return $this->badge($category->posts_count, 'secondary');
            })
            ->editColumn('action', function ($category) {
                return $this->button(
                          'categories.edit', 
                          $category->id, 
                          'warning', 
                          __('Edit'), 
                          'edit'
                      ). $this->button(
                          'categories.destroy', 
                          $category->id, 
                          'danger', 
                          __('Delete'), 
                          'trash-alt', 
                          __('Really delete this category?')
                      );
            })
            ->rawColumns(['posts_count', 'action']);
    }

    /**
     * Get query source of dataTable.
     *
     * @param \App\Models\Category $model
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function query(Category $category)
    {
        return $category->withCount('posts');
    }

    /**
     * Optional method if you want to use html builder.
     *
     * @return \Yajra\DataTables\Html\Builder
     */
    public function html()
    {
        return $this->builder()
                    ->setTableId('categories-table')
                    ->columns($this->getColumns())
                    ->minifiedAjax()
                    ->dom('Blfrtip')
                    ->lengthMenu();
    }

    /**
     * Get columns.
     *
     * @return array
     */
    protected function getColumns()
    {
        return [
            Column::make('title')->title(__('Title')),
            Column::make('slug')->title(__('Slug')),
            Column::computed('posts_count')->title(__('Posts'))->addClass('text-center align-middle'),
            Column::computed('action')->title(__('Action'))->addClass('align-middle text-center'),
        ];
    }

    /**
     * Get filename for export.
     *
     * @return string
     */
    protected function filename()
    {
        return 'Categories_' . date('YmdHis');
    }
}

Dans la requête on prévoit le nombre de commentaires pour l’afficher dans le tableau.

Vous devriez maintenant avoir le tableau :

La validation

Il faut créer la form request pour la validation :

php artisan make:request Back\CategoryRequest

<?php

namespace App\Http\Requests\Back;

use Illuminate\Foundation\Http\FormRequest;
use App\Rules\Slug;

class CategoryRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        $id = $this->method() === 'PUT' ? ',' . basename($this->url()) : '';

        return $rules = [
            'title' => 'required|max:255',
            'slug' => ['required', 'max:255', new Slug, 'unique:posts,slug' . $id],
        ];
    }
}

Pour distinguer la création de la modification je regarde la méthode dans la requête. Dans le cas de la modification il faut récupérer l’identifiant pour l’exclusion pour la règle unique du slug.

On réutilise la classe Slug qu’on avait créée pour les articles.

La vue

On utilise la même vue pour le formulaire en création et en modification :

On profite pour cette vue de composants qu’on a créés pour les articles :

@extends('back.layout')

@section('main')

    <form 
        method="post" 
        action="{{ Route::currentRouteName() === 'categories.edit' ? route('categories.update', $category->id) : route('categories.store') }}">

        @if(Route::currentRouteName() === 'categories.edit')
            @method('PUT')
        @endif
        
        @csrf

        <div class="row">
          <div class="col-md-12">
                
                <x-back.validation-errors :errors="$errors" />

                @if(session('ok'))
                    <x-back.alert 
                        type='success'
                        title="{!! session('ok') !!}">
                    </x-back.alert>
                @endif

                <x-back.card
                    type='info'
                    :outline="true"
                    title=''>
                    <x-back.input
                        title='Title'
                        name='title'
                        :value="isset($category) ? $category->title : ''"
                        input='text'
                        :required="true">
                    </x-back.input>
                    <x-back.input
                        title='Slug'
                        name='slug'
                        :value="isset($category) ? $category->slug : ''"
                        input='text'
                        :required="true">
                    </x-back.input>
                </x-back.card>

                <button type="submit" class="btn btn-primary">@lang('Submit')</button>

              </div>
        </div>


    </form>

@endsection

@section('js')

    <script src="https://cdnjs.cloudflare.com/ajax/libs/speakingurl/14.0.1/speakingurl.min.js"></script>
    <script>

        $(function() 
        {
            $('#slug').keyup(function () {
              $(this).val(getSlug($(this).val()))
            })

            $('#title').keyup(function () {
              $('#slug').val(getSlug($(this).val()))
            })
        });

    </script>

@endsection

On utilise aussi la même librairie pour la génération et le contrôle du slug.

Pour la création on obtient le formulaire vierge :

Pour la modification on obtient le formulaire complété :

Vérifiez que tout fonctionne : validation, création, modification et même suppression parce qu’on avait créé du code générique.

Conclusion

On a passé pas mal de temps à créer notre contrôleur de ressource mais on a déjà vu pour les catégories comme ça nous aide ! de même que les composants qu’on a créés précédemment pour les vues.

 

Print Friendly, PDF & Email

14 commentaires

  • HK

    Bonjour Momo,

    je recherche une piste pour ajouter une image par category d’article sans tout chambouler le code et pour une intégration optimale.

    J’ai crée la migration integré le component input dans le formulaire des categories, créé un nouveau helper : getCatImage avec une injection de $category.
    A ce stade je sauvegarde l’url complète dans ma base, maintenant pour récupérer la basename de l’url de l’upload avant la sauvegarde je devrait …. ? des pistes s’il te plait 😉

      • HK

        Plus généralement, en suivant ta logique de RessourceController (générique), si on veut pour l’édition ou la création d’un Model (là Category) une fonction ou methode spécifique,vers quelle logique devons nous nous diriger ?

        Ajouter une fonction dans le constructeur du RessourceController ?
        Ajouter un Controller + routes spécifiques pour le Model ?
        Quelle solution optimale donc a choisir pour juste un petit ajout sur un model ?

        • bestmomo

          Bonjour,

          Le plus logique est de créer un contrôleur qui étend ResourceController. Là on peut ajouter des méthodes ou surcharger celles de la classe mère. C’est d’ailleurs ce que je fais dans le chapitre suivant pour la gestion des utilisateurs. J’ajoute deux méthodes avec de nouvelles routes et je surcharge la méthode update parce qu’il y a une particularité.

  • slozano54

    Up !

    Petite kwak dans le controleur App\Http\Controllers\Back\ResourceController.php

    La class est appelée avec une minuscule mais elle a été créée avec une majuscule. La casse est-elle importante sous tous les OS ? Sous Linux cela provoque une erreur.

    J’ai donc ajouté une variable $nameDataTables = ucfirst($name); // Categorie

    Puis j’ai modifié la propriété dataTable ainsi $this->dataTable = ‘App\DataTables\\’ . $nameDataTables . ‘sDataTable’;

    • bestmomo

      Salut,

      Oui c’est l’inconvénient de développer avec Windows qui est tolérant sur la casse, du coup on ne remarque pas ce genre de chose parce que tout fonctionne bien. J’ai déjà eu le cas dans un de mes packages. Je vais corriger ça. Merci !

      Edit : j’ai modifié le code et l’article et fait suivre les ZIP…

  • slozano54

    Up!

    J’ai constaté que lorsque je duplique un article dont l’utilisateur connecté est propriétaire, l’image est dupliquée aussi.
    Par contre lorsque je duplique un article dont l’utilisateur connecté n’est pas le propriétaire, l’image n’est pas dupliquée.

    J’ai deux chapitres de retard, j’aimerais résoudre ce problème d’abord …

    Je fouille mais si vous avez une piste …

    Down …

    • bestmomo

      Salut,

      Le tableau des articles apparaît pour un rédacteur et il ne dispose dans ce tableau que de ses articles, il ne peut donc pas a priori dupliquer l’article d’un autre.

      Il n’y a que l’administrateur qui peut dupliquer les articles de tout le monde et là effectivement s’il duplique l’article d’un rédacteur l’url de l’image ne sera pas générée correctement. Il faut qu’il change l’image pour qu’ensuite ça soit bon puisqu’il aura pris forcément une image à lui.

      Dans le cas de l’administrateur on pourrait ne faire apparaître le bouton de duplication que pour ses articles.

      Mais on peut aussi changer l’url pour dupliquer n’importe quel article donc il faudrait peut-être empêcher la duplication aussi dans cette situation. je vais voir ça…

      Edit : j’ai mis à jour les articles et le code

      • slozano54

        Salut,

        Merci pour ta réactivité, effectivement j’aurais dû y penser, j’ai fait ce constat avec un profil admin !

        Avec la modif proposée dans cet article, un admin peut toujours tout dupliquer mais s’il n’est pas l’auteur de l’article, l’article cloné est vierge.

        Du coup pour le clonage par un admin on pourrait aussi le permettre en conservant tout sauf l’image.
        Peut être en accompagnant d’un petit message d’alerte précisant qu’il faut en charger un nouvelle.

        … à suivre
        Merci encore

      • bestmomo

        Oui c’est ce que j’explique dans mon autre réponse, comme on n’enregistre que le nom de l’image et que l’url est générée avec l’identifiant de l’auteur si quelqu’un d’autre duplique ça coince. je vais proposer un patch pour bloquer ça.

        Edit : j’ai mis à jour les articles et le code.

  • Michel

    Bonsoir,

    Les évolutions du jour sont superbes. Merci.

    – 1 remarque: Quand je duplique un article l’image n’est pas reprise. Normal ?

    Pour la question des images:

    – Quand j’ai fermé monblg.test hier au soir, j’avais les images et tout fonctionnait bien.

    – En ouvrant ce soir, sans avoir retouché à l’appli, aucune image !!!

    Ne comprennant pas pourquoi, j’ai relancé via ma console

    composer require unisharp/laravel-filemanager

    php artisan vendor:publish –tag=lfm_config
    php artisan vendor:publish –tag=lfm_public

    php artisan storage:link

    et les images sont apparues…..

    J’ai foullé un peu plus et je ma suis aperçu que la route :

    Route::group([‘prefix’ => ‘laravel-filemanager’, ‘middleware’ => ‘auth’], function () {
    Lfm:: routes(); avait peut être une erreur de casse sur le L de lfm

    J’ai remplacé, Lfm:: routes(); par lfm::routes(); qui est la casse exacte du nom du fichier lfm.php

    Ai je bien fait ?

    – J’ai relancé avec différends users (admin, redac, lecteur) et mes images sont toujours là.

    2eme remarque: l’image 6 est beaucoup plus longue à apparaitre. Une idée du pourquoi ?

    ,

    • bestmomo

      Salut,

      – Normalement l’image est aussi dupliquée puisqu’on recopie tous les champs, mais ça doit rejoindre le souci de base pour les images.
      – La syntaxe de la classe est Lfm avec une majuscule et le fichier a bien aussi une majuscule, j’ai vérifié dans le vendor. Quand on utilise Windows c’est assez bien digéré mais sous Linux la casse ne passe pas.
      – pour la lenteur de l’image 6 je n’ai aucune explication, elle n’est pas plus lourde que les autres…

Laisser un commentaire