Shopping : les produits

Les produits constituent les éléments clés d’une boutique en ligne. On doit pouvoir les gérer de façon efficace, les illustrer correctement, bien préciser leur poids, leur prix, leurs caractéristiques.

Vous pouvez télécharger un ZIP du projet ici.

Les données et les images

Les données des produits sont dans la table products :

On doit pouvoir les modifier, les supprimer…

On va devoir gérer les images des produits. Pour le faire on va installer le package incontournable Intervention/image :

composer require intervention/image

 

Contrôleur et routes

On crée un contrôleur :

php artisan make:controller Back\ProductController --resource --model=Models\Product

On utilisera toutes les méthodes sauf show.

On ajoute les routes :

Route::prefix('admin')->middleware('admin')->namespace('Back')->group(function () {
    ...
    Route::resource('produits', 'ProductController')->except('show');
});

DataTable

On va utiliser un dataTable pour la gestion des produits :

php artisan datatables:make ProductsDataTable

<?php

namespace App\DataTables;

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

class ProductsDataTable extends DataTable
{
    /**
     * Build DataTable class.
     *
     * @param mixed $query Results from query() method.
     * @return \Yajra\DataTables\DataTableAbstract
     */
    public function dataTable($query)
    {
        return datatables()
            ->eloquent($query)
            ->addColumn('show', function ($product) {
                return '<a href="' . route('produits.show', $product->id) . '" class="btn btn-xs btn-info btn-block" target="_blank">Voir</a>';
            })
            ->addColumn('edit', function ($product) {
                return '<a href="' . route('produits.edit', $product->id) . '" class="btn btn-xs btn-warning btn-block">Modifier</a>';
            })
            ->addColumn('destroy', function ($product) {
                return '<a href="#" class="btn btn-xs btn-danger btn-block">Supprimer</a>';
            })
            ->editColumn('active', function ($product) {
                return $product->active ? '<i class="fas fa-check text-success"></i>' : ''; 
            })
            ->rawColumns(['show', 'edit', 'destroy', 'active']);
    }

    /**
     * Get query source of dataTable.
     *
     * @param \App\Product $model
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function query(Product $model)
    {
        return $model->newQuery();
    }

    /**
     * Optional method if you want to use html builder.
     *
     * @return \Yajra\DataTables\Html\Builder
     */
    public function html()
    {
        return $this->builder()
                    ->setTableId('products-table')
                    ->columns($this->getColumns())
                    ->minifiedAjax()
                    ->dom('Blfrtip')
                    ->orderBy(1)
                    ->lengthMenu()
                    ->language('//cdn.datatables.net/plug-ins/1.10.20/i18n/French.json');
    }

    /**
     * Get columns.
     *
     * @return array
     */
    protected function getColumns()
    {
        return [
            Column::make('id'),
            Column::make('name')->title('Nom'),
            Column::make('price')->title('Prix TTC'),
            Column::make('quantity')->title('Quantité'),
            Column::make('active')
              ->title('Actif')              
              ->width(60)
              ->addClass('text-center'),
            Column::computed('show')
              ->title('')
              ->width(60)
              ->addClass('text-center'),
            Column::computed('edit')
              ->title('')
              ->width(60)
              ->addClass('text-center'),
            Column::computed('destroy')
              ->title('')
              ->width(60)
              ->addClass('text-center'),
        ];
    }

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

Le tableau

Pour afficher la liste des produits on code la méthode index du contrôleur ProductController :

use App\DataTables\ProductsDataTable;

...

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

On renseigne le titre dans config.titles :

return [

   ...

    'produits' => [
        'index' => 'Catalogue',
        'create' => "Création d'un produit",
        'destroy' => [
          'alert' => "Suppression d'un produit",
        ],
    ],
];

Avec l’url …/admin/produits on a le tableau :

On peut trier par colonne.

Le bouton Voir se content d’ouvrir la page du produit dans un autre onglet.

Création d’un produit

Le contrôleur

Pour créer un produit on code la méthode create du contrôleur :

public function create()
{
    return view('back.products.form');
}

La vue

On crée la vue du formulaire :

@extends('back.layout')

@section('css')
  <style>
    .custom-file-label::after { content: "Parcourir"; }
  </style>
@endsection

@section('main') 
  <div class="container-fluid"> 
    @if(session()->has('alert'))
      <div class="alert alert-success alert-dismissible fade show" role="alert">
        {{ session('alert') }}
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
          <span aria-hidden="true">&times;</span>
        </button>
      </div>
    @endif
    <div class="row">
      <div class="col-sm-12">
        <div class="card">
        
          <form method="POST" action="@isset($product) {{ route('produits.update', $product->id) }} @else {{ route('produits.store') }} @endisset" enctype="multipart/form-data">
            <div class="card-body">
              @isset($product) @method('PUT') @endisset
              @csrf   
              
              <x-inputbs4
                name="name"
                type="text"
                label="Nom"
                :value="isset($product) ? $product->name : ''"
              ></x-inputbs4>

              <x-textareabs4
                name="description"
                label="Description"
                :value="isset($product) ? $product->description : ''"
              ></x-textareabs4>

              <x-inputbs4
                name="weight"
                type="text"
                label="Poids en kg"
                :value="isset($product) ? $product->weight : ''"
                required="true"
              ></x-inputbs4>

              <x-inputbs4
                name="price"
                type="text"
                label="Prix TTC"
                :value="isset($product) ? $product->price : ''"
                required="true"
              ></x-inputbs4>

              <x-inputbs4
                name="quantity"
                type="number"
                label="Quantité disponible"
                :value="isset($product) ? $product->quantity : ''"
                required="true"
              ></x-inputbs4>
        
              <x-inputbs4
                name="quantity_alert"
                type="number"
                label="Quantité pour alerte stock"
                :value="isset($product) ? $product->quantity_alert : ''"
                required="true"
              ></x-inputbs4>

              <div class="form-group{{ $errors->has('image') ? ' is-invalid' : '' }}">
                <label for="description">Image</label>
                @if(isset($product) && !$errors->has('image'))
                  <div>
                    <p><img src="{{ asset('images/thumbs/' . $product->image) }}"></p>
                    <button id="changeImage" class="btn btn-warning">Changer d'image</button>
                  </div>
                @endif
                <div id="wrapper">
                  @if(!isset($product) || $errors->has('image'))
                    <div class="custom-file">
                      <input type="file" id="image" name="image"
                            class="{{ $errors->has('image') ? ' is-invalid ' : '' }}custom-file-input" required>
                      <label class="custom-file-label" for="image"></label>
                      @if ($errors->has('image'))
                        <div class="invalid-feedback">
                          {{ $errors->first('image') }}
                        </div>
                      @endif
                    </div> 
                  @endif
                </div>
              </div>

              <div class="form-group">
                <div class="custom-control custom-checkbox">
                  <input type="checkbox" class="custom-control-input" id="active" name="active" @if(old('active', isset($product) ? $product->active : false)) checked @endif>
                  <label class="custom-control-label" for="active">Produit actif</label>
                </div>
              </div>

              <div class="form-group row mb-0">
                <div class="col-md-12">
                  <a class="btn btn-primary" href="{{ route('produits.index') }}" role="button"><i class="fas fa-arrow-left"></i> Retour à la liste des produits</a>
                   <button type="submit" class="btn btn-primary">Enregistrer</button>
                </div>
              </div>
              
            </div>            
          </form>

        </div>
      </div>
    </div>
  </div>
@endsection

@section('js')
  <script>
    $(document).ready(() => {
      $('form').on('change', '#image', e => {
        $(e.currentTarget).next('.custom-file-label').text(e.target.files[0].name);
      });
      $('#changeImage').click(e => {
        $(e.currentTarget).parent().hide();
        $('#wrapper').html(`
          <div id="image" class="custom-file">
            <input type="file" id="image" name="image" class="custom-file-input" required>
            <label class="custom-file-label" for="image"></label>
          </div>`
        );
      });
    });
  </script>
@endsection

On obtient ce formulaire avec l’url …/admin/produits/creation :

La seule particularité réside dans le champ pour l’image du produit.

La validation

Pour la validation on crée une requête de formulaire :

php artisan make:request ProductRequest

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ProductRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'name' => 'required|max:255',
            'image' => 'sometimes|required|image|mimes:jpeg,png,jpg,gif',
            'description' => 'required|string',
            'price' => 'required|numeric|regex:/^(\d+(?:[\.\,]\d{1,2})?)$/',
            'weight' => 'required|numeric|regex:/^(\d+(?:[\.\,]\d{1,3})?)$/',
            'quantity' => 'required|numeric',
            'quantity_alert' => 'required|numeric',
        ];
    }
}

Pour le champ image on a sometimes parce que la validation devra fonctionner aussi pour la modification.

Pour les champs price et witght on a un regex pour vérifier qu’on a une valeur décimale.

Le contrôleur

On code la méthode store du contrôleur et on crée également les méthodes getInputs et saveImages  :

use App\Http\Requests\ProductRequest;
use Intervention\Image\Facades\Image as InterventionImage;

...

public function store(ProductRequest $request)
{
    $inputs = $this->getInputs($request);

    Product::create($inputs);

    return back()->with('alert', config('messages.productcreated'));
}

protected function saveImages($request)
{
    $image = $request->file('image');
    $name = time() . '.' . $image->extension();
    $img = InterventionImage::make($image->path());
    $img->widen(800)->encode()->save(public_path('/images/') . $name);
    $img->widen(400)->encode()->save(public_path('/images/thumbs/') . $name);

    return $name;
}

protected function getInputs($request)
{
    $inputs = $request->except(['image']);

    $inputs['active'] = $request->has('active');

    if($request->image) {
        $inputs['image'] = $this->saveImages($request);
    }

    return $inputs;
}

La méthode getInputs sert à gérer les entrées. On ajoute le checkbox pour le mode actif du produit. Si le champ image est renseigné alors on appelle la méthode saveImages pour gérer l’image et au retour on a le nom de l’iamge qu’on ajoute aux entrées.

La méthode saveImages sert à générer un nom aléatoire pour l’image et enregistrer sur le disque les versions en 400 px (thumb) et 800 px. On retourne le nom qui doit être enregistré dans la base.

Les deux images avec le même nom vont dans leurs dossiers respectifs :

On ajoute les messages dans config.messages :

return [
    ...
    'productcreated' => 'Le produit a bien été créé.',
    'productupdated' => 'Le produit a bien été modifié.',
    'productdeleted' => 'Le produit a bien été supprimé.',
];

Quand on crée un produit on a bien le message :

Et on le retrouve dans la liste :

Et sa page produit :

Modification d’un produit

Le contrôleur

Pour modifier un produit on code la méthode edit du contrôleur :

public function edit(Product $produit)
{
    return view('back.products.form', ['product' => $produit]);
}

On utilise la même vue mais cette fois on envoie les données :

L’image apparaît et pour la modifier on peut cliquer sur le bouton Changer d’image. Dans ce cas l’image disparaît et on affiche le champ de saisie comme pour la création.

Si jamais on change l’iamge il est bien de purger l’image précédente dans ses deux versions (merci pour Lerado qui m’a suggéré ça en commentaire). On crée une méthode pour cette purge parce qu’elle va être utilisée aussi pour la suppression d’un produit :

use Illuminate\Support\Facades\File;

...

protected function deleteImages($produit)
{
    File::delete([
        public_path('/images/') . $produit->image, 
        public_path('/images/thumbs/') . $produit->image,
    ]);    
}

Pour la modification dans la base on code la méthode update du contrôleur :

public function update(ProductRequest $request, Product $produit)
{
    $inputs = $this->getInputs($request);

    if($request->has('image')) {
        $this->deleteImages($produit);        
    }

    $produit->update($inputs);

    return back()->with('alert', config('messages.productupdated'));
}

La validation est la même que pour la création et on utilise les même fonctions.

On a un message lorsque la mise à jour a eu lieu :

Supprimer un produit

Le contrôleur

Pour supprimer un produit on code la méthode destroy et on crée une méthode alert dans le contrôleur comme on l’avait fait pour les pays, les états et les pages (on aurait pu mutualiser du code) :

public function destroy(Product $produit)
{
    $this->deleteImages($produit); 

    $produit->delete();

    return redirect(route('produits.index'));
}

On ajoute la vue pour l’alerte :

@extends('back.layout')

@section('main') 
  <div class="container-fluid"> 
    <form id="deleteproduct" action="{{ route('produits.destroy', $product->id) }}" method="POST" style="display: none;">
      @csrf
      @method('DELETE')
    </form>
    <div class="row">
      <div class="col-sm-12 col-md-6 offset-md-3 col-lg-4 offset-lg-4">
        <div class="card text-white bg-dark mb-3">
          <div class="card-body">
            <h5 class="card-title text-center mb-3">Vous êtes sur le point de supprimer le produit <strong>{{ $product->name }}</strong></h5>
            <p class="card-text">
              <a class="btn btn-danger btn-lg btn-block" href="#" role="button"
              onclick="event.preventDefault(); 
              $('#deleteproduct').submit();"
              >Je confirme la suppression</a>
            </p>
          </div>
        </div>
      </div>
    </div>
  </div>
@endsection

On crée la route :

Route::prefix('admin')->middleware('admin')->namespace('Back')->group(function () {
    ...
    Route::name('produits.destroy.alert')->get('produits/{produit}', 'ProductController@alert');
});

On ajoute le lien dans les boutons de suppression dans ProductsDataTable :

->addColumn('destroy', function ($product) {
    return '<a href="' . route('produits.destroy.alert', $product->id) . '" class="btn btn-xs btn-danger btn-block">Supprimer</a>';
})

Quand on clique on a le message d’alerte :

Contrairement à ce qu’on avait fait pour les états, les pays et les pages cette fois j’ai prévu un titre. Ce n’est évidemment pas indispensable parce que le message est clair.

Le menu

Il ne nous reste plus qu’à compléter le menu de l’administration (back.layout) :

<li class="nav-item has-treeview {{ menuOpen('produits.index', 'produits.edit', 'produits.create' , 'produits.destroy.alert') }}">
  <a href="#" class="nav-link {{ currentRouteActive('produits.index', 'produits.edit', 'produits.create' , 'produits.destroy.alert') }}">
    <i class="nav-icon fas fa-store"></i>
    <p>
      Catalogue
      <i class="right fas fa-angle-left"></i>
    </p>
  </a>
  <ul class="nav nav-treeview">
    <x-menu-item :href="route('produits.index')" :sub=true :active="currentRouteActive('produits.index', 'produits.edit' , 'produits.destroy.alert')">
      Produits
    </x-menu-item>
    <x-menu-item :href="route('produits.create')" :sub=true :active="currentRouteActive('produits.create')">
      Nouveau produit
    </x-menu-item>
  </ul>
</li>

Comme c’est une rubrique importante on crée un menu Catalogue et deux items : le tableau et le formulaire de création.

Conclusion

Dans le prochain article nous verrons la gestion des clients et des adresses.

Print Friendly, PDF & Email

2 commentaires sur “Shopping : les produits

  1. Salut 🙂

    Je trouve qu’il serait judicieux de supprimer les images du produits à sa suppression ou à sa modification (lorsque l’image à été remplacée). Ca éviterai pas mal de hausse de tarifs chez l’hébergeur.

    Avant de faire $produit->delete() dans ProductController@delete, on pourrait faire:
    File::delete([ public_path(‘/images/’) . $produit->image, public_path(‘/images/thumbs/’) . $produit->image ]);

    Puis dans Product Controller@update, avant $produit->update(), on complète:
    if($request->has(‘image)) { // Si l’image a été changée
    File::delete([ public_path(‘/images/’) . $produit->image, public_path(‘/images/thumbs/’) . $produit->image ]);
    }

    Et le tour est joué. T’en penses quoi bestmomo ? 🙂

Laisser un commentaire