Laravel 7

Shopping : le compte client 2/3

Dans cet article on va continuer à créer le compte client pour notre boutique. Après lui avoir permis de modifier ses données personnelles et nous êtres mis en conformité avec le RGPD nous allons à présent lui permettre de créer et modifier des adresses.

Le code présent dans cet article est un excellent exemple de gestion complète d’une ressource.

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

Contrôleur et routes

Pour gérer les adresses on crée un contrôleur de ressource :

php artisan make:controller AddressController --resource --model=Models\Address

On va conserver toutes les méthodes sauf show. On prévoit les routes :

Route::middleware('auth')->group(function () {
    // Gestion du compte
    Route::prefix('compte')->group(function () {
        ...
        Route::resource('adresses', 'AddressController')->except('show');

On ajoute le lient dans la vue account.index :

<div class="col s12 m6"><a href="{{ route('adresses.index') }}" class="btn-large"><i class="material-icons left">location_on</i>Mes Adresses</a></div>

L’affichage des adresses

Pour afficher toutes les adresses on code la méthode index :

public function index(Request $request)
{
    $addresses = $request->user()->addresses()->with('country')->get();

    if($addresses->isEmpty()) {
        return redirect(route('adresses.create'))->with('message', config('messages.oneaddress'));
    }
    
    return view('account.addresses.index', compact('addresses'));
}

On récupère dans la base toutes les adresses du client connecté avec les pays associés. S’il n’y a pas d’adresse enregistrée on redirige sur la page de création d’une adresse. Sinon on ouvre une vue que l’on va maintenant créer :

@extends('layouts.app')

@section('content')
<div class="container">
  <h2>Mes adresses</h2>
  <div class="row">
    @foreach($addresses as $address)
      <div class="col s12 m6 l4">
        <div class="card">
          <div class="card-content address">
            @include('account.addresses.partials.address')
          </div>
          <div class="card-action">
            <a href="{{ route('adresses.edit', $address->id) }}">Mettre à jour</a>
            <a class="delete" href="{{ route('adresses.destroy', $address->id) }}">Supprimer</a>
          </div>
        </div>
      </div>
    @endforeach
  </div>
  <div class="row">
    @if(url()->previous() === route('commandes.create'))
      <a class="waves-effect waves-light btn" href="{{ route('commandes.create') }}"> <i class="material-icons left">chevron_left</i>Retour à ma commande</a>      
    @else
      <a class="waves-effect waves-light btn" href="{{ route('account') }}"> <i class="material-icons left">chevron_left</i>Retour à mon compte</a>
    @endif
    <a class="waves-effect waves-light btn" href="{{ route('adresses.create') }}">Créer une adresse</a>
  </div>
</div>
@endsection

@section('javascript')
  <script>

      const del = async url => {
        const response = await fetch(url, { 
          method: 'DELETE',
          headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
        });
        location.reload(true);        
      };

      document.addEventListener('DOMContentLoaded', () => {
        @if(session()->has('alert'))
          M.toast({ html: '{{ session('alert') }}' });
        @endif

        const deleteButtons = document.querySelectorAll('.delete');
        deleteButtons.forEach( button => {
          button.addEventListener('click', e => {
            e.preventDefault();
            del(e.target.getAttribute('href'));
          });
        });
      });
    
  </script>
@endsection

Normalement la page doit s’afficher avec toutes les adresses :

Création d’une adresse

On code la méthode create :

use App\Models\ { Address, Country };

...

public function create()
{
    $countries = Country::all();

    return view('account.addresses.create', compact('countries')); 
}

Et on crée la vue :

@extends('layouts.app')

@section('content')
<div class="container">
  <h2>Nouvelle adresse</h2>
  @if(session()->has('message'))
    <h5 class="center-align blue-text">{{ session('message') }}</h5>
    <br>
  @endif
  <div class="row">
    <div class="card" id="account">
      <form  method="POST" action="{{ route('adresses.store') }}">
        @include('account.addresses.partials.form')
      </form>
    </div>
  </div>
  <div class="row">
    <a class="waves-effect waves-light btn" href="{{ route('adresses.index') }}"> <i class="material-icons left">chevron_left</i>Retour à mes adresses</a>
  </div>
</div>
@endsection

@section('javascript')
  @include('account.addresses.partials.script')
@endsection

Le code est léger parce qu’on va utiliser deux vues partielles pour mutualiser le code avec la modification d’une adresse qui aura un formulaire similaire.

Une vue partielle pour le formulaire

On crée une vue partielle pour le formulaire :

<div class="card-content">
  @csrf

  <div class="row col s12">
    <label>
      <input type="checkbox" name="professionnal" id="professionnal" {{ old('professionnal', isset($adress) ? $adress->professionnal : false) ? 'checked' : '' }}>
      <span>C'est une adresse professionnelle</span>
    </label>
  </div>

  <div class="row col s12">
    <label>
      <input name="civility" type="radio" value="Mme" {{ old('civility', isset($adress) ? $adress->civility : '') == 'Mme' ? 'checked' : '' }} />
      <span>Mme.</span>
    </label>
    <label>
      <input name="civility" type="radio" value="M." {{ old('civility', isset($adress) ? $adress->civility : '') == 'M.' ? 'checked' : '' }} />
      <span>M.</span>
    </label>
  </div>

  <x-input
    name="name"
    type="text"
    icon="person"
    label="Nom"
    :value="isset($adress) ? $adress->name : ''"
  ></x-input>

  <x-input
    name="firstname"
    type="text"
    icon="person"
    label="Prénom"
    :value="isset($adress) ? $adress->firstname : ''"
  ></x-input>

  <x-input
    name="company"
    type="text"
    icon="business"
    label="Raison sociale"
    :value="isset($adress) ? $adress->company : ''"
  ></x-input>

  <x-input
    name="address"
    type="text"
    icon="home"
    label="N° et libellé de la voie"
    :value="isset($adress) ? $adress->address : ''"
    required="true"
  ></x-input>

  <x-input
    name="addressbis"
    type="text"
    icon="home"
    label="Bâtiment, Immeuble (optionnel)"
    :value="isset($adress) ? $adress->addressbis : ''"
  ></x-input>

  <x-input
    name="bp"
    type="text"
    icon="location_on"
    label="Lieu-dit ou BP (optionnel)"
    :value="isset($adress) ? $adress->bp : ''"
  ></x-input>

  <x-input
    name="postal"
    type="text"
    icon="location_on"
    label="Code postal"
    :value="isset($adress) ? $adress->postal : ''"
    required="true"
  ></x-input>

  <x-input
    name="city"
    type="text"
    icon="location_on"
    label="Ville"
    :value="isset($adress) ? $adress->city : ''"
    required="true"
  ></x-input>

  <div class="row">
    <div class="input-field col s12">
      <i class="material-icons prefix">location_on</i>
      <select name="country_id"">
        @foreach($countries as $country)
          <option 
            value="{{ $country->id }}" 
            @if(old('country_id', isset($adress) ? $adress->country_id : '') == $country->id) selected @endif>{{ $country->name }}
          </option>
        @endforeach
      </select>
      <label>Pays</label>
    </div>
  </div>

  <x-input
    name="phone"
    type="text"
    icon="phone"
    label="N° de téléphone"
    :value="isset($adress) ? $adress->phone : ''"
    required="true"
  ></x-input>

  <p>
    <button class="btn waves-effect waves-light" style="width: 100%" type="submit">
      Enregistrer
    </button>
  </p>
</div>

On utilise le composant qu’on avait déjà créé.

Une vue partielle pour le script du formulaire

Pour bien organiser les choses on crée aussi une vue partielle pour le Javascript :

<script>
  const toggleProfessionnal = () => {
    if(document.querySelector('#professionnal').checked) {
        document.querySelector('#company').parentNode.parentNode.classList.remove('hide');
        document.querySelector('label[for="name"]').firstChild.textContent = "Nom (optionnel)";
        document.querySelector('label[for="firstname"]').firstChild.textContent = "Prénom (optionnel)";
      } else {
        document.querySelector('#company').parentNode.parentNode.classList.add('hide');
        document.querySelector('label[for="name"]').firstChild.textContent = "Nom";
        document.querySelector('label[for="firstname"]').firstChild.textContent = "Prénom";
      }     
  }

  document.addEventListener('DOMContentLoaded', () => {
    toggleProfessionnal();    
    document.querySelector('#professionnal').addEventListener('click', () => toggleProfessionnal());
  });
</script>

Le seul but de ce script sera de rendre des champs actifs ou non selon que c’est une adresse professionnelle ou pas.

Maintenant le formulaire doit s’afficher :

Lorsque c’est une adresse professionnelle certains champs changent :

Le nom est le prénom deviennent optionnels et un champ Raison sociale apparaît comme obligatoire.

La validation

On crée une requête de formulaire pour la validation :

php artisan make:request StoreAddress

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreAddress 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_unless:professionnal,"on"|string|max:100',
            'firstname' => 'required_unless:professionnal,"on"|string|max:100',
            'company' => 'exclude_unless:professionnal,"on"|string|max:100',
            'address' => 'required|string|max:255',
            'addressbis' => 'nullable|string|max:255',
            'bp' => 'nullable|string|max:100',
            'postal' => 'required|numeric',
            'city' => 'required|string|max:100',
            'phone' => 'required|numeric',
        ];
    }
}

Pour les champs name, firstname et company il faut tenir compte de la valeur de la case à cocher professionnal. Le reste est classique.

La création

Il ne resste plus qu’à code la méthode store du contrôleur :

use App\Http\Requests\StoreAddress;

...

public function store(StoreAddress $storeAddress)
{
    $storeAddress->merge(['professionnal' => $storeAddress->has('professionnal')]);

    $storeAddress->user()->addresses()->create($storeAddress->all());

    return redirect(route('adresses.index'))->with('alert', config('messages.addresssaved'));
}

On ajoute le champ professionnal qui est une case à cocher. On utilise la relation pour créer l’adresse avec toutes les entrées. Enfin on redirige avec un message. Donc il faut créer ce message dans config.messages et on va anticiper pour la suite :

return [
    ...
    'addresssaved' => "L'adresse a bien été enregistrée.",
    'addressupdated' => "L'adresse a bien été modifiée.",
    'addressdeleted' => "L'adresse a bien été supprimée.",
];

On peut maintenant créer une adresse :

Il y a toutefois un peti souci avec les textes de la validation :

Ce n’est pas très explicite et élégant !

Si on regarde dans la fichier resources/lang/fr/validation.php on a :

'attributes' => [
    ...
    'first_name'            => 'prénom',

On peut déjà changer ça :

'attributes' => [
    ...
    'firstname'             => 'prénom',

On va aussi ajouter :

'professionnal'         => 'adresse professionnelle'
'company'               => 'raison sociale',

C’est déjà mieux :

C’est la fin qui n’est pas très parlante, une valeur on

On va ajouter ça dans le menu des traductions :

'values' => [
    'professionnal' => [
        'on' => 'active',
    ],
],

Maintenant c’est parfait !

Modification d’une adresse

Une autorisation

On va s’arranger pour que seul le propriétaire d’une adresse puisse la modifier (et aussi pour la supprimer) :

php artisan make:policy AddressPolicy

<?php

namespace App\Policies;

use App\Models\ { User, Address };
use Illuminate\Auth\Access\HandlesAuthorization;

class AddressPolicy
{
    use HandlesAuthorization;

    /**
     * Create a new policy instance.
     *
     * @return void
     */
    public function manage(User $user, Address $address)
    {
        return $user->id === $address->user_id;
    }
}

Et dans AuthServiceProvider :

use App\Models\{ Address, Order };
use App\Policies\{ AddressPolicy, OrderPolicy };

...

protected $policies = [
    Address::class => AddressPolicy::class,
    ...
];

La méthode edit

Dans le contrôleur on code la méthode edit :

public function edit(Address $adress)
{
    $this->authorize('manage', $adress);

    $countries = Country::all();

    return view('account.addresses.edit', compact('adress', 'countries'));
}

La vue

On ajoute la vue :

Et là on bénéficie de ce qu’on a codé auparavent :

@extends('layouts.app')

@section('content')
<div class="container">
  <h2>Mettre à jour mon adresse</h2>
  <div class="row">
    <div class="card">
      <form  method="POST" action="{{ route('adresses.update', $adress->id) }}">
        @method('PUT')
        @include('account.addresses.partials.form')
      </form>
    </div>
  </div>
  <div class="row">
    <a class="waves-effect waves-light btn" href="{{ route('adresses.index') }}"> <i class="material-icons left">chevron_left</i>Retour à mes adresses</a>
  </div>
</div>
@endsection

@section('javascript')
  @include('account.addresses.partials.script')
@endsection

La méthode update

Il ne reste plus qu’à coder la méthode update pour mémoriser les changements :

public function update(StoreAddress $storeAddress, Address $adress)
{
    $this->authorize('manage', $adress);

    $storeAddress->merge(['professionnal' => $storeAddress->has('professionnal')]);

    $adress->update($storeAddress->all());

    return redirect(route('adresses.index'))->with('alert', config('messages.addressupdated')); 
}

On utilise la même validation que pour la création. On lance un petit toast pour informer le client que les changements ont bien été effectués :

Suppression d’une adresse

Pour supprimer une adresse on code la méthode destroy :

public function destroy(Address $adress)
{
    $this->authorize('manage', $adress);

    $adress->delete();

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

Là aussi on lance un toast d’information :

Petit retour sur les commandes

Lorsqu’on a codé la méthdoe create du contrôleur OrderController on a laissé un petit commentaire :

public function create(Request $request, Shipping $ship)
{        
    $addresses = $request->user()->addresses()->get();

    if($addresses->isEmpty()) {
        // Là il faudra renvoyer l'utilisateur sur son compte quand on l'aura créé
    }

Ca a d’ailleurs donné lieu à des questions parce qu’il y a forcément un bug lorsqu’un client n’a pas encore créé d’adresse. On va maintenant mettre en place une redirection :

if($addresses->isEmpty()) {
    return redirect()->route('adresses.create')->with('message', 'Vous devez créer au moins une adresse pour passer une commande.');
}

Maintenant quand un client sans adresse veut faire une commande il est dirigé sur la page de création d’une adresse avec un message :

Conclusion

On en a terminé avec la gestion des adresses. Il ne nous restera plus qu’à prévoir la gestion des commandes pour terminer cette partie qui concerne le compte du client.

Print Friendly, PDF & Email

7 commentaires

  • dogma52

    J’ai l’impression que la validation du formulaire de création d’adresse ne fonctionne pas pour les champs nom et prénom si la checkbox n’est pas cochée :
    ‘name’ => ‘required_unless:professionnal, »on »|string|max:100’,
    ‘firstname’ => ‘required_unless:professionnal, »on »|string|max:100’,
    Le validator précise que le nom et le prénom doivent être des chaînes de caractères.
    Ces champs deviennent donc toujours obligatoires.

    Je crois arriver au résultat souhaité avec les règles :
    ‘name’ => ‘required_without:professionnal|nullable|string|max:100’,
    ‘firstname’ => ‘required_without:professionnal|nullable|string|max:100’,

    J’ai lu des commentaires sur le web concernant la règle ‘sometimes’, je ne la comprends pas.

    • bestmomo

      Salut,
      Bien vu mais je vois plutôt cette solution :

      'name' => 'required_unless:professionnal,"on"|nullable|string|max:100',
      'firstname' => 'required_unless:professionnal,"on"|nullable|string|max:100',

      D’ailleurs j’ai vu aussi que le firstname n’est pas traduit, donc il faut l’ajouter dans les traductions :
      'firstname' => 'prénom',

  • jeromeborg

    Salut,
    J’ai un bug en utilisant ta méthode edit dans AddressController
    public function edit (Address $adress) {
    $this->authorize(‘manage’, $adress);

    cela ne devrait pas être (cela ne concerne pas address au lieu de adress) :
    public function edit($id) {
    $address = Address::findorFail($id);
    $this->authorize(‘manage’, $address);
    Merci de ta réponse ?
    bonne journée

      • jeromeborg

        Salut je ne comprend pas, comment le routeur peut injecter le modèle a la méthode edit alors que dans la vue on envoie seulement l’id

        adresses.edit -> compte/adresses/{adress}/modification {adress} est une simple variable dans notre cas, un int représentant l’id de l’adresse
        dans le controlleur
        public function edit (Address $adress)
        C’est ici que je ne comprend pas, comment peut on caster $adress en objet Address ??? je fais un dd($adress), et je n’ai pas d’id, J’ai du rater un truc
        Merci de tes lumières

Leave a Reply