Laravel 8

Créer un blog – créer un article

Nous avons dans le précédent article créé le tableau pour afficher la liste des articles et des principaux renseignements les concernant. Par la même occasion ce que nous avons mis en place servira pour les autres entités. A présent nous allons coder le formulaire de création d’un article et les méthodes concernées du contrôleur pour le gérer. Ce formulaire sera assez chargé parce qu’il y a de nombreux champs à renseigner pour un article. Nous allons traiter simultanément le cas du clonage.

Cet article va être assez chargé mais je préfère traiter complètement la création en une seule fois.

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

Edit au 22/02/2021 : j’ai modifié la méthode create du contrôleur empêcher la duplication d’un article dont on n’est pas l’auteur.

Les routes

A priori nous avons créé toutes les routes dans le précédent article. Mais on doit traiter le cas particulier du clonage. Pour la route posts.create on n’a pas prévu de paramètre. Pour le clonage le plus évident est d’utiliser la même route mais avec un paramètre qui sera l’identifiant de l’article à cloner. On va donc sortir la route de la ressource et la coder séparément pour ajouter le paramètre :

Route::prefix('admin')->group(function () {
    Route::middleware('redac')->group(function () {  
        ...
        // Posts
        Route::resource('posts', BackPostController::class)->except(['show', 'create']);
        Route::name('posts.create')->get('posts/create/{id?}', [BackPostController::class, 'create']);
    });

Le tableau

Pour le bouton de clonage dans PostsDataTable on n’avait pas renseigné l’url alors maintenant on va le faire puisqu’on a décidé comment on va s’y prendre :

return $buttons
        ...
        ). $this->button(
            'posts.create', 
            $post->id, 
            'info', 
            __('Clone'), 
            'clone'

La validation

Pour la validation on crée un form request :

php artisan make:request Back\PostRequest

On a deux champs particuliers au niveau de la validation :

  • le slug
  • les étiquettes et les mots-clés (META)

Pour cette validation il n’existe pas de règle dans Laravel et on doit les créer nous-même. Pour le slug on crée une classe parce que la règle sera utilisée plusieurs fois :

php artisan make:rule Slug

Dans cette classe on utilise une expression régulière :

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class Slug implements Rule
{
    public function __construct()
    {
        //
    }

    public function passes($attribute, $value)
    {
        return preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $value);
    }

    public function message()
    {
        return trans('validation.slug');
    }
}

J’ai déjà prévu le message dans les fichiers de langue que j’avais intégrés dans les précédents fichiers à télécharger, on n’a donc pas à nous en inquiéter ici.

Pour les étiquettes et les mots-clés (séparés par des virgules, pas d’espace et 50 caractères maximum) je vais mettre l’expression régulière directement dans la form request :

<?php

namespace App\Http\Requests\Back;

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

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

    public function rules()
    {
        $regex = '/^[A-Za-z0-9-éèàù]{1,50}?(,[A-Za-z0-9-éèàù]{1,50})*$/';
        $id = $this->post ? ',' . $this->post->id : '';

        return $rules = [
            'title' => 'required|max:255',
            'body' => 'required|max:65000',
            'slug' => ['required', 'max:255', new Slug, 'unique:posts,slug' . $id],
            'excerpt' => 'required|max:500',
            'meta_description' => 'required|max:160',
            'meta_keywords' => 'required|regex:' . $regex,
            'seo_title' => 'required|max:60',
            'image' => 'required|max:255',
            'categories' => 'required',
            'tags' => 'nullable|regex:' . $regex,
        ];
    }
}

On utilisera la même classe pour la modification d’un article. C’est pour cette raison que je détecte la présence de données d’un article pour bien renseigner la champ unique slug. En effet dans le cas d’une modification la valeur peut évidemment être déjà présente.

Le repository

Dans le repository PostRepository on crée une méthode store :

public function store($request)
{
    $request->merge([
        'active' => $request->has('active'),
        'image' => basename($request->image),
    ]);

    $post = $request->user()->posts()->create($request->all());

    $this->saveCategoriesAndTags($post, $request);
}

Pour le champ active, qui indique si l’article doit être immédiatement publié, on aura une case à cocher. Le souci avec les cases à cocher dans un formulaire c’est qu’on ne trouve la valeur dans la requête que si la case est cochée, sinon on ne trouve rien. C’est pour cette raison qu’il faut faire un test d’existence d’active dans la requête.

Pour l’image illustrative de l’article on va trouver l’url complète dans la requête mais on n’a pas besoin de mémoriser la totalité, on se contente du nom parce qu’on sait générer l’url complète.

Pour les catégories et les étiquettes c’est un peu particulier alors j’ai préféré créer une fonction distincte, surtout qu’elle sera aussi utilisée pour la modification d’un article :

use App\Models\ { Post, Tag };
use Illuminate\Support\Str;

...

protected function saveCategoriesAndTags($post, $request)
{
    // Categorie
    $post->categories()->sync($request->categories);

    // Tags
    $tags_id = [];

    if($request->tags) {
        $tags = explode(',', $request->tags);
        foreach ($tags as $tag) {
            $tag_ref = Tag::firstOrCreate([
                'tag' => ucfirst($tag),
                'slug' => Str::slug($tag),
            ]);
            $tags_id[] = $tag_ref->id;
        }
    }

    $post->tags()->sync($tags_id);
}

Pour les catégories c’est vite réglé avec la méthode sync  puisqu’on a une relation de type n:n.

Pour les étiquettes il faut décomposer les données pour ensuite les mémoriser en tenant compte du fait qu’une étiquette peut déjà exister.

Le contrôleur

Ce sont les méthodes create et store du contrôleur Back/PostController qui sont concernées pour cette création.

create

Habituellement la fonction create se contente de renvoyer la vue qui comporte le formulaire en ajoutant parfois des données en relations. Dans notre cas on doit tenir compte aussi du clonage :

use App\Http\{
    Controllers\Controller,
    Requests\Back\PostRequest
};
use App\Repositories\PostRepository;
use App\Models\{ Post, Category };
use App\DataTables\PostsDataTable;

...

public function create($id = null)
{
    $post = null; 

    if($id) {
        $post = Post::findOrFail($id);
        if($post->user_id === auth()->id()) {
            $post->title .= ' (2)';
            $post->slug .='-2';
            $post->active = false;
        } else {
            $post = null;
        } 
    }
    
    $categories = Category::all()->pluck('title', 'id');

    return view('back.posts.form', compact('post', 'categories'));
}

Le paramètre est nul par défaut, ce qui correspond à une nouvelle création et un formulaire vierge. Si le paramètre est présent on va chercher l’article dans la base, on vérifie au passage que c’est bien l’auteur de l’article, et on envoie les informations à la vue. D’autre part on envoie toutes les catégories pour renseigner une liste déroulante dans le formulaire.

store

La méthode store est celle qui traite l’enregistrement dans la base du nouvel article :

public function store(PostRequest $request, PostRepository $repository)
{
    $repository->store($request);

    return back()->with('ok', __('The post has been successfully created'));
}

Là pour le contrôleur c’est simple parce que tout le travail se fait dans d’autres classes qu’on a déjà codées.

Des composants

Pour les vues de l’administration on aura du code commun. On règle ça en créant des composants qui vont bien alléger le code des formulaires.

alert

On aura pas mal d’alertes alors un composant :

@props([
    'type', 
    'icon' => 'check', 
    'title' => '',
])

<div class="alert alert-{{ $type }} alert-dismissible">
    <button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
    <h5><span class="icon fa fa-{{ $icon }}"></span>{{ $title }}</h5>
    {{ $slot }}
</div>

card

On va tout organiser en fiches (cards), alors un autre composant :

@props([
    'outline' => true, 
    'type', 
    'title', 
])

<div class="card @if($outline) card-outline @endif card-{{ $type }}">
    @if($title)
      <div class="card-header">
          <h3 class="card-title">{{ __($title) }}</h3>
          <div class="card-tools pull-right">
              <button 
                  type="button" 
                  class="btn btn-tool" 
                  data-card-widget="collapse">
                  <i class="fas fa-minus"></i>
              </button>
          </div>
      </div>
    @endif
    <div class="card-body">
        {{ $slot }}
    </div>
</div>

input

On aura de multiples champs de saisie, alors j’ai créé un composant universel :

@props([
    'input', 
    'name', 
    'required' => false, 
    'title',
    'rows' => 3, 
    'title', 
    'label', 
    'options', 
    'value' => '', 
    'Values',
    'multiple' => false,
])

<div class="form-group">

    @isset($title)
        <label for="{{ $name }}">@lang($title)</label>
    @endisset

    @if ($input === 'textarea')
        <textarea 
            class="form-control{{ $errors->has($name) ? ' is-invalid' : '' }}" 
            rows="{{ $rows }}" 
            id="{{ $name }}" 
            name="{{ $name }}" 
            @if ($required) required @endif>{{ old($name, $value) }}</textarea>
   
    @elseif ($input === 'checkbox')
        <div class="custom-control custom-checkbox">
            <input 
                class="custom-control-input" 
                id="{{ $name }}" 
                name="{{ $name }}" 
                type="checkbox" 
                {{ $value ? 'checked' : '' }}>
            <label 
                class="custom-control-label" 
                for="{{ $name }}">
                {{ __($label) }}
            </label>
        </div>

      @elseif ($input === 'select')
        <select 
            @if($required) required @endif 
            class="form-control{{ $errors->has($name) ? ' is-invalid' : '' }}" 
            name="{{ $name }}" 
            id="{{ $name }}">
            @foreach($options as $option)
                <option 
                    value="{{ $option }}"
                    {{ old($name) ? (old($name) == $option ? 'selected' : '') : ($option == $value ? 'selected' : '') }}>
                    {{ $option }}
                </option>
            @endforeach
        </select>

    @elseif ($input === 'selectMultiple')
        <select 
            multiple
            @if($required) required @endif 
            class="form-control{{ $errors->has($name) ? ' is-invalid' : '' }}" 
            name="{{ $name }}[]" 
            id="{{ $name }}">
            @foreach($options as $id => $title)
                <option 
                    value="{{ $id }}" 
                    {{ old($name) ? (in_array($id, old($name)) ? 'selected' : '') : ($values->contains('id', $id) ? 'selected' : '') }}>
                    {{ $title }}
                </option>
            @endforeach
        </select>
   
    @else
        <input 
            type="text" 
            class="form-control{{ $errors->has($name) ? ' is-invalid' : '' }}" 
            id="{{ $name }}" 
            name="{{ $name }}" 
            value="{{ old($name, $value) }}" 
            @if($required) required @endif>
    
    @endif

    @if ($errors->has($name))
        <div class="invalid-feedback">
            {{ $errors->first($name) }}
        </div>
    @endif
    
</div>

validation-errors

Enfin il faudra systématiquement afficher les erreurs de validation :

@props(['errors'])

@if($errors->any())
    <x-back.alert 
        type='danger' 
        icon='ban' 
        title="{{ __('Whoops! Something went wrong.') }}">
        <ul>
            @foreach($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </x-back.alert>
@endif

La vue

On crée la vue :

Le code utilise de façon systématique les composants qu’on a créés :

@extends('back.layout')

@section('css')
    <style>
        #holder img {
            height: 100%;
            width: 100%;
        }
    </style>
@endsection

@section('main')

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

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

        <div class="row">
            <div class="col-md-8">
                
                <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='primary'
                    title='Title'>
                    <x-back.input
                        name='title'
                        :value="isset($post) ? $post->title : ''"
                        input='text'
                        :required="true">
                    </x-back.input>
                </x-back.card>

                <x-back.card
                    type='primary'
                    title='Excerpt'>
                    <x-back.input
                        name='excerpt'
                        :value="isset($post) ? $post->excerpt : ''"
                        input='textarea'
                        :required="true">
                    </x-back.input>
                </x-back.card>

                <x-back.card
                    type='primary'
                    title='Body'>
                    <x-back.input
                        name='body'
                        :value="isset($post) ? $post->body : ''"
                        input='textarea'
                        rows=10
                        :required="true">
                    </x-back.input>
                </x-back.card>

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

            </div>
            <div class="col-md-4">

                <x-back.card
                    type='primary'
                    :outline="false"
                    title='Publication'>
                    <x-back.input
                        name='active'
                        :value="isset($post) ? $post->active : false"
                        input='checkbox'
                        label="Active">
                    </x-back.input>
                </x-back.card>

                <x-back.card
                    type='warning'
                    :outline="false"
                    title='Categories'
                    :required="true">
                    <x-back.input
                        name='categories'
                        :values="isset($post) ? $post->categories : collect()"
                        input='selectMultiple'
                        :options="$categories">
                    </x-back.input>
                </x-back.card>

                <x-back.card
                    type='danger'
                    :outline="false"
                    title='Tags'>
                    <x-back.input
                        name='tags'
                        :value="isset($post) ? implode(',', $post->tags->pluck('tag')->toArray()) : ''"
                        input='text'>
                    </x-back.input>
                </x-back.card>

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

                <x-back.card
                    type='primary'
                    :outline="false"
                    title='Image'>

                    <div id="holder" class="text-center" style="margin-bottom:15px;">
                        @isset($post)
                            <img style="width:100%;" src="{{ getImage($post, true) }}" alt="">
                        @endisset
                    </div>

                    <div class="input-group mb-3">
                      <div class="input-group-prepend">
                        <a id="lfm" data-input="image" data-preview="holder" class="btn btn-primary text-white" class="btn btn-outline-secondary" type="button">Button</a>
                      </div>
                      <input id="image" class="form-control {{ $errors->has('image') ? 'is-invalid' : '' }}" type="text" name="image" value="{{ old('image', isset($post) ? getImage($post) : '') }}" required>                    
                      @if ($errors->has('image'))
                          <div class="invalid-feedback">
                              {{ $errors->first('image') }}
                          </div>
                      @endif
                    </div>


                </x-back.card>

                <x-back.card
                    type='info'
                    :outline="false"
                    title='SEO'>
                    <x-back.input
                        title='META Description'
                        name='meta_description'
                        :value="isset($post) ? $post->meta_description : ''"
                        input='textarea'
                        :required="true">
                    </x-back.input>
                    <x-back.input
                        title='META Keywords'
                        name='meta_keywords'
                        :value="isset($post) ? $post->meta_keywords : ''"
                        input='textarea'
                        :required="true">
                    </x-back.input>
                    <x-back.input
                        title='SEO Title'
                        name='seo_title'
                        :value="isset($post) ? $post->seo_title : ''"
                        input='text'
                        :required="true">
                    </x-back.input>
                </x-back.card>

            </div>
        </div>


    </form>

@endsection

@section('js')

    @include('back.shared.editorScript')

@endsection

Cette vue sera utilisée également pour la modification d’un article. Il y a en effet peu de différences.

Comme le Javascript sera utilisé pour d’autres vues je l’ai placé dans un fichier partagé :

 

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

    $(function() {

        $.fn.filemanager = function(type, options) {
          type = type || 'file';

          this.on('click', function(e) {
            var route_prefix = (options && options.prefix) ? options.prefix : '/filemanager';
            var target_input = $('#' + $(this).data('input'));
            var target_preview = $('#' + $(this).data('preview'));
            window.open(route_prefix + '?type=' + type, 'FileManager', 'width=900,height=600');
            window.SetUrl = function (items) {
              var file_path = items.map(function (item) {
                return item.url;
              }).join(',');

              // set the value of the desired input to image url
              target_input.val('').val(file_path).trigger('change');

              // clear previous preview
              target_preview.html('');

              // set or change the preview image src
              items.forEach(function (item) {
                target_preview.append(
                  $('<img>').attr('src', item.thumb_url)
                );
              });

              // trigger change event
              target_preview.trigger('change');
            };
            return false;
          });
        }

        $('#lfm').filemanager('image');

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

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

    CKEDITOR.replace('body', { customConfig: '{{ asset('js/ckeditor.js') }}' });

</script>

Il nous faut un éditeur pour la rédaction du résumé et du contenu de l’article. Dans ce domaine on a du choix : Summernote, TinyMCE, CKEditor… J’ai opté pour ce dernier. Il est juste dommage que la page d’intégration de Laravel File Manager n’ait pas encore prévu la version 5 de cet excellent éditeur. Je n’ai pas eu le courage de faire cette intégration moi-même et me suis donc contenté de la version 4 qui comporte déjà tout ce dont nous avons besoin.

Pour la configuration de CKEditor j’ai préféré mettre ça dans un fichier séparé :

CKEDITOR.editorConfig = function(config) {
    config.height = 400
    config.filebrowserImageBrowseUrl = '/laravel-filemanager?type=Images',
    config.filebrowserImageUploadUrl = '/laravel-filemanager/upload?type=Images&_token=',
    config.filebrowserBrowseUrl = '/laravel-filemanager?type=Files',
    config.filebrowserUploadUrl = '/laravel-filemanager/upload?type=Files&_token='
    config.toolbarGroups = [
        { name: 'clipboard',   groups: [ 'clipboard', 'undo' ] },
        { name: 'editing',     groups: [ 'find', 'selection', 'spellchecker' ] },
        { name: 'links' },
        { name: 'insert' },
        { name: 'forms' },
        { name: 'tools' },
        { name: 'document',	   groups: [ 'mode', 'document', 'doctools' ] },
        { name: 'others' },
        { name: 'basicstyles', groups: [ 'basicstyles', 'cleanup' ] },
        { name: 'paragraph',   groups: [ 'list', 'indent', 'blocks', 'align', 'bidi' ] },
        { name: 'styles' },
        { name: 'colors' }
    ]
}

Normalement en cliquant dans le menu sur Ajouter dans la rubrique Articles vous obtenez le formulaire :

Le formulaire est séparé verticalement en deux parties. A gauche les éléments les plus importants (titre, résumé et contenu) ainsi que le bouton de soumission :

Dans la partie droite on a tout le reste :

Remarque importante : pour que Laravel File Manager génère des url correctes il faut bien renseigner la valeur de APP_URL dans le fichier .env :

APP_URL=http://monblog.oo

Fonctionnement

Pour la génération automatique du slug quand on tape le titre, mais aussi pour contrôler la saisie directement quand on tape le slug, j’ai ajouté la librairie Javascript speakingurl qui est parfaite pour ça. Ainsi quand on entre ce titre :

Ce slug est automatiquement généré :

Vous pouvez vérifier que la validation fonctionne bien :

En faisant l’essai de l’enregistrement d’un article je me rends compte que le champ $fillable du modèle Tag est incomplet, il manque slug, alors on l’ajoute :

protected $fillable = ['tag', 'slug'];

Pour les catégories on peut en sélectionner plusieurs dans la liste puisqu’on a une relation de type n:n et qu’un article peut ainsi appartenir à plusieurs catégories :

Pour l’image illustrative on utilise une intégration de Laravel File Manager un peu adaptée :

L’url générée est complète mais on a vu que dans le repository on récupère juste le nom :

$request->merge([
    ...
    'image' => basename($request->image),
]);

Par contre les images ajoutées dans le contenu de l’article auront l’url complète :

Ca peut présenter un inconvénient dans le cas où on veut changer de domaine parce que les urls ne seront plus correctes. Il serait plus judicieux de prévoir des urls relatives. Rien n’empêche de faire ce traitement lors de l’enregistrement de l’article mais je ne l’ai pas prévu.

Quand on a renseigné tous les champs et qu’il n’y a aucune erreur de validation on délivre une alerte rassurante :

Il ne reste plus qu’à vérifier si le clonage fonctionne. Dans le tableau des articles au niveau de la colonne des actions on a un bouton pour a duplication :

On se retrouve avec le formulaire complété des données de l’article dupliqué mais on change le titre en ajoutant (2) :

Et on modifie aussi le slug en accord :

D’autre part la case de publication est systématiquement décochée.

Conclusion

Un long article mais la création est la partie la plus complexe à traiter, ça sera plus simple pour la suite. Nous complèterons avec la modification et la suppression d’un article. Pour la modification les routes existent déjà, de même que la validation, le formulaire et une bonne partie du traitement. Pour la suppression il faudra utiliser un peu de Javascript pour éviter de faire une suppression directe mais plutôt de mander une confirmation.

 

Print Friendly, PDF & Email

25 commentaires

Laisser un commentaire