Page dynamique

Un fil récent sur le forum Laravel m’a paru suffisamment intéressant et didactique pour donner l’occasion d’un article. Le cas évoqué est assez fréquent et mérite qu’on s’y penche un peu. On a des données structurées dans un fichier JSON et on veut afficher une liste de nom et ensuite par un clic sur un nom afficher des détails.

On peut envisager plusieurs façons de réaliser cela. De façon traditionnelle on va passer par jQuery, commencer par envoyer la liste des noms et ensuite utiliser Ajax pour récupérer les informations sélectionnées. Évidemment on aura ainsi une requête pour chaque clic.

Une autre approche consiste à envoyer une page simple, celle-ci est équipée d’une routine Javascript pour aller chercher toutes les informations en bloc et les mémoriser. Ensuite chaque clic est traité en local de façon efficace.

Les données sont ici. Mais pour des raisons que je n’ai pas trop comprises on rapatrie ce fichier sur le serveur.

Le code du projet est disponible ici.

Version traditionnelle

J’ai déjà décrit cette version sur le forum mais ça sera plus lisible ici…

On va partir d’une installation fraîche de Laravel 5.7.

On place le fichier JSON en public/json/data.json. On change dans config/filesystems.php pour faciliter l’accès :

'disks' => [

    'local' => [
        'driver' => 'local',
        'root' => public_path(),
    ],

On prend ces deux routes (routes/web.php) :

Route::get('/', 'HomeController@index')->name('home');
Route::get('infos/{id}', 'HomeController@infos');

On crée ce code dans HomeController :

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class HomeController extends Controller
{
    public function index()
    {
        $names = $this->getData()->pluck('name');

        return view('home', compact('names'));
    }

    public function infos($id)
    {
        $values = $this->getData()->forPage($id + 1, 1)->first();

        return view('values', compact('values'));
    }

    protected function getData()
    {
        $data = Storage::get('json/data.json');

        return collect(json_decode($data, true)['data']);
    }
}

On modifie ainsi la vue home :

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">Recherche par nom</div>
                <div class="card-body">
                    <form>
                        <div class="form-group">
                            <select id="names" class="custom-select">
                                <option selected>Choisissez un nom</option>
                                @foreach($names as $name)
                                    <option value="{{ $loop->index }}">{{ $name }}</option>
                                @endforeach
                            </select>
                        </div>
                    </form>
                    <div id="infos"></div>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

@section('scripts')

<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>

<script>
    $(function(){
        $('#names').change(function() {
            $.get('{{ url('infos') }}/'+ $(this).val(), function(data) {
                $('#infos').html(data);
            });
        });
    })
</script>

@endsection

Et on crée une nouvelle vue values :

<p>Nom : {{ $values['name']}}</p>
<p>Description : {!! $values['description'] !!}</p>
<p>Image : {{ $values['image']['full'] }}</p>

On se retrouve avec cette page en accueil :

Et quand on clique sur un nom de la liste :

Simple et efficace. Je ne détaille pas le code qui est classique. Juste une remarque concernant la simplicité du code permise par les collections de Laravel et l’approche déclarative bien plus lisible.

Version avec Vue.js

On va voir maintenant comment réaliser ça avec une approche SPA pilotée par Vue.js.

Il faut déjà installer les dépendances Javascript :

npm i

On crée une nouvelle route pour l’occasion :

Route::get('vueversion', 'HomeController@vue');

Dans HomeController on va juste envoyer une vue :

public function vue()
{
    return view('vue');
}

Et voici la vue :

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <league></league>
        </div>
    </div>
</div>
@endsection

Évidemment tout ça est très léger pour le moment. On voit qu’il est prévu un composant league de Vue. On va créer ce composant :

Avec ce code :

<template>
    <div class="card">
        <div class="card-header">Recherche par nom</div>
        <div class="card-body">
            <form>
                <div class="form-group">
                    <select v-model="selected" id="names" class="custom-select">
                        <option selected>Choisissez un nom</option>
                        <option v-for="(value, key) in items" :value="key" :key="key">{{ value.name }}</option>
                    </select>
                </div>
            </form>
            <div v-if="elements">
                <p>Nom : {{ elements.name}}</p>
                <p >Description : <span v-html=" elements.description"></span></p>
                <p>Image : {{ elements.image.full }}</p>
            </div>
        </div>
    </div>
</template>

<script>

export default {
    data () {
        return {
            selected: 'Choisissez un nom',
            items: []
        }
    },
    computed: {
        elements () {
            return this.items[this.selected];
        }
    },
    mounted () {
        window.axios.get('json/data.json').then(({ data }) => {
            let that = this;
            _.forEach(data.data, function(value, key) {
                that.items.push(value)
            });
        });
    }
}

</script>

On renseigne resources/js/app.js pour charger ce composant :

Vue.component('league', require('./components/League.vue'));

Il ne reste plus qu’à recompiler :

npm run dev (ou watch)

Maintenant à l’adresse …/vueversion on se retrouve avec la même page d’accueil :

Et le même fonctionnement qu’avec la version traditionnelle mais maintenant tout se joue en local et on n’a plus de requête au serveur pour chaque clic.

Voyons un peu plus en détail le composant créé…

On attend le chargement du DOM (mounted) pour lancer la requête Ajax pour récupérer toutes les données :

mounted() {
    window.axios.get('json/data.json').then(({ data }) => {
        let that = this;
        _.forEach(data.data, function(value, key) {
            that.items.push(value)
        });
    });
}

Dans l’installation de base de Laravel on dispose d’Axios. On récupère donc directement le fichier JSON et ensuite on remplit le tableau items.

Dans le template on remplit la liste de noms avec ce code :

<select v-model="selected" id="names" class="custom-select">
    <option selected>Choisissez un nom</option>
    <option v-for="(value, key) in items" :value="key" :key="key">{{ value.name }}</option>
</select>

La directive v-for parcourt la liste des items et on crée une option pour chaque nom. On crée une liaison avec la directive v-model. Ainsi la valeur de selected change à chaque changement de valeur sélectionnée dans la liste.

On a une propriété calculée pour l’affichage des éléments :

computed: {
    elements () {
        return this.items[this.selected];
    }
},

Donc quand selected change la valeur est recalculée et on a l’affichage dans le template :

<div v-if="elements">
    <p>Nom : {{ elements.name}}</p>
    <p >Description : <span v-html=" elements.description"></span></p>
    <p>Image : {{ elements.image.full }}</p>
</div>

On voit que la syntaxe de Vue.js est assez simple à mettre en œuvre.

Conclusion

Alors quelle version préférez-vous ?

Il est évident que la version SPA demande plus de travail et est peut-être moins intuitive mais côté efficacité il n’y a pas photo !




Laravel Telescope

Telescope est un nouvel assistant pour déboguer une application Laravel. IL nous donne accès à une foule d’informations sur les requêtes qui entrent dans l’application, sur les exceptions, les requêtes à la base de données, les files d’attente (queues), les mails, les notifications, le cache…

Telescope en est encore au stade beta mais il est déjà largement utilisable. Je vous propose dans cet article de regarder un peu ses possibilités. On va ainsi enfin disposer pour Laravel d’un outil digne de ce nom !

Installation

Pour installer Telescope c’est tout simple, mais il faut au minimum la version 5.7.7 de Laravel :

composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate

On se retrouve avec un fichier de configuration :

Dans ce fichier on trouve par défaut comme driver database avec une connexion MySQL. Avec la migration qu’on a faite on se retrouve avec ces 3 tables :

On a tous ces observateurs par défaut :

'watchers' => [
    Watchers\CacheWatcher::class => env('TELESCOPE_CACHE_WATCHER', true),
    Watchers\CommandWatcher::class => env('TELESCOPE_COMMAND_WATCHER', true),
    Watchers\DumpWatcher::class => env('TELESCOPE_DUMP_WATCHER', true),
    Watchers\EventWatcher::class => env('TELESCOPE_EVENT_WATCHER', true),
    Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
    Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
    Watchers\LogWatcher::class => env('TELESCOPE_LOG_WATCHER', true),
    Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
    Watchers\ModelWatcher::class => env('TELESCOPE_MODEL_WATCHER', true),
    Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),

    Watchers\QueryWatcher::class => [
        'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
        'slow' => 100,
    ],

    Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),
    Watchers\RequestWatcher::class => env('TELESCOPE_REQUEST_WATCHER', true),
    Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
],

On peut donc enlever ceux qui ne nous sont pas utiles.

Un réglage important est le nombre maximum d’enregistrements par observateur :

'limit' => env('TELESCOPE_LIMIT', 100),

Par défaut on a 100, évidemment si on augmente cette valeur il y aura un impact sur les données stockées dans la base.

Pour accéder à Telescope il faut utiliser cette url :

http://mondomaine/telescope

Cette url est aussi réglable dans la configuration :

'path' => 'telescope',

Par défaut Telescope n’est accessible que dans l’environnement local :

APP_ENV=local

C’est logique puisque que c’est comme ça qu’on développe mais il peut arriver d’en avoir besoin dans l’environnement de production. Telescope crée un provider :

Dans ce provider on trouve une autorisation où vous pouvez ajouter les mails des personnes autorisées dans l’environnement de production :

protected function gate()
{
    Gate::define('viewTelescope', function ($user) {
        return in_array($user->email, [
            'bestmomo@chezlui.net'
        ]);
    });
}

Maintenant qu’on a fait le tour pour l’installation et les réglage ouvrons un peu la boîte…

Les requêtes

On a la liste des requêtes avec le verbe, l’url, le code HTTP et quand elle est passée. On dispose aussi de l’icône d’un œil pour obtenir plus de renseignements concernant une requête :

On dispose alors de tous les détails comme les headers :

La session :

Les requêtes générées à la base :

Avec pour chacune le détail en cliquant sur l’icône :

Vraiment pratique tout ça !

Les commandes

Pour les commande Artisan c’est le même principe : on a la liste des commandes passées et on peut obtenir le détail :

Les exceptions

On a la liste des exceptions :

Et le détail pour chacune :

On a un lien pour accéder à la requête concernée et aux autres occurrences.

Les requêtes à la base

Là aussi on a la liste avec un formulaire de recherche :

Et pour chaque requête le détail :

On dispose de l’utilisateur connecté et du détail de la requête.

Les événements

On a aussi la liste :

Et le détail :

On a un lien pour accéder au job associé (ici pour l’envoi du mail de confirmation) et à la requête.

Les jobs

Pour les jobs on a aussi la liste avec le statut pour chacun :

Là on voit que c’est en attente, après exécution on a cet aspect :

On peut aussi accéder au détail :

Les mails

On a la liste des mails envoyés :

Il est précisé s’ils sont dans une file d’attente. Par contre je n’ai pas réussi à accéder au détail des mails… On verra dans la prochaine version… Je suis resté coincé ici :

Les notifications

On a la liste des notifications :

On peut accéder aux détails :

Conclusion

Je n’ai pas passé en revue toutes les possibilités, je vous laisse les découvrir !




Laravel 5.7 par la pratique – Les langues

Dans ce chapitre on va s’intéresser à l’aspect multi-langage. Pour le moment notre galerie est en français mais on a fait en sorte que les textes soient faciles à traduire en utilisant dans le code les helpers de Laravel. On va donc ajouter maintenant l’anglais à notre galerie. Ça ne concernera évidemment que l’interface et pas les données, ce qui serait une autre histoire…

La configuration

Dans le fichier config/app.php on a des réglages pour les langues :

'locale' => 'fr',

'fallback_locale' => 'en',

On a fixé la locale au français (fr) et la langue par défaut en cas d’absence de traduction à l’anglais (en).

On va ajouter un réglage avec les langues qu’on va mettre en œuvre et qui devront avoir les traductions présentes :

'locales' => ['fr', 'en',],

On va ajouter un fichier de configuration pour les locales :

Ce fichier est issu de ce dépôt Github. Il permet d’avoir par pays le code pour Carbon et celui pour setLocale().

Route, contrôleur et middleware

Route

On ajoute la route pour le changement de la langue :

Route::name('language')->get('language/{lang}', 'HomeController@language');

Contrôleur

Et on ajoute la fonction dans HomeController :

public function language(String $locale)
{
    $locale = in_array($locale, config('app.locales')) ? $locale : config('app.fallback_locale');

    session(['locale' => $locale]);

    return back();
}

On reçoit une locale en paramètre. Si elle est présente dans le tableau de la configuration on fixe cette locale en session, sinon on se rabat sur la locale par défaut.

Middleware

Il nous faut maintenant un middleware qui va vérifier si on a une locale en session pour réellement l’affecter. Si ce n’est pas le cas essayer de définir la langue de l’utilisateur. On va aussi fixer la locale pour les dates.

php artisan make:middleware Locale

Et on code ainsi :

public function handle($request, Closure $next)
{
    if (!session ()->has ('locale')) {
        session (['locale' => $request->getPreferredLanguage (config ('app.locales'))]);
    }
    $locale = session ('locale');
    app ()->setLocale ($locale);
    setlocale (LC_TIME, app()->environment('local') ? $locale : config('locale.languages')[$locale][1]);
    return $next ($request);
}

Et on le référence dans app/Http/Kernel :

protected $middlewareGroups = [
    'web' => [
        ... 

        \App\Http\Middleware\Locale::class,
        \App\Http\Middleware\Settings::class,
    ],

    ...
];

Le menu

On va ajouter un menu déroulant pour le choix de la locale dans views/layouts/app :

<ul class="navbar-nav mr-auto">
    <li class="nav-item dropdown">
        <a class="nav-link" href="#" id="navbarDropdownFlag" role="button" data-toggle="dropdown"
            aria-haspopup="true" aria-expanded="false">
            <img width="32" height="32" alt="{{ session('locale') }}"
                    src="{!! asset('images/flags/' . session('locale') . '-flag.png') !!}"/>
        </a>
        <div id="flags" class="dropdown-menu" aria-labelledby="navbarDropdownFlag">
            @foreach(config('app.locales') as $locale)
                @if($locale != session('locale'))
                    <a class="dropdown-item" href="{{ route('language', $locale) }}">
                        <img width="32" height="32" alt="{{ session('locale') }}"
                                src="{!! asset('images/flags/' . $locale . '-flag.png') !!}"/>
                    </a>
                @endif
            @endforeach
        </div>
    </li>

Et on va voir le résultat :

Je ne trouve pas trop élégant la largueur de la zone pour le drapeau.On va arranger ça dans resources/sass/_variables.scss. On va en profiter pour changer la typographie et un peu la pagination :

// Body
$body-bg: #343a40;

// Menu
$dropdown-min-width: 5rem;

// Typography
$Raleway", sans-serif;
$font-size-base: 0.9rem;
$line-height-base: 1.6;
$text-color: #636b6f;

// Card
$card-border-width: 4px;
$card-border-radius: .3rem;
$card-border-color: rgba(255, 242, 242, .3);

// Pagination
$pagination-active-bg: grey;
$pagination-active-border-color: grey;

On lance npm…

Et c’est maintenant plus équilibré :

Pour le moment le changement de langue ne se voit que dans les validations :

Un package

Il nous faut créer un fichier JSON avec toutes les traductions pour l’anglais. On pourrait faire ça en explorant tout le code avec des copier/coller, ça serait vraiment laborieux !

On va plutôt utiliser un package pour nous aider. Comme je n’en ai pas vraiment trouvé un qui me plaise j’en ai créé un. On va commencer par l’installer :

composer require bestmomo/laravel5-artisan-language --dev

On a maintenant 4 commandes de plus dans artisan :

On va utiliser la deuxième :

php artisan language:make en

Le fichier JSON a été créé et on a tous les textes par ordre alphabétique qui attendent leur traduction :

{
    "A l'url :": "",
    "A propos": "",
    "Administration": "",
    "Adresse email": "",
    "Adresse web :": "",
    "Adulte": ""

    ...

On ajoute donc les traductions. Comme le fichier est assez gros vous pouvez le récupérer sur github.

Maintenant si on passe à l’anglais on a bien les textes dans cette langue :

Vous pourrez aussi remarquer que les dates sont maintenant correctes.

C’est le dernier chapitre de cette série. Il reste quelques éléments dont je n’ai pas parlé, comme les pages d’information, mais que vous pouvez retrouver dans le dépôt Github.

Conclusion

Dans ce chapitre on a :

  • prévu la configuration pour les locales
  • ajouté la route, la fonction du contrôleur et un middleware pour le changement de locale
  • ajouté un menu déroulant avec les drapeaux des langues disponibles
  • installé un package pour créer le fichier de la nouvelle langue et ajouté ainsi les traductions

 




Laravel 5.7 par la pratique – Les notifications

Notre galerie est désormais bien équipée avec ses catégories, ses albums, son administration, sa gestion du profil utilisateur, la notation des photos… Dans ce chapitre nous allons voir les notifications. Les utilisateurs seront prévenus si on a noté leurs photos. L’administrateur sera aussi prévenu lors de l’inscription d’un nouvel utilisateur. Dans le premier cas ce sera avec la présence d’une icône avec le nombre de notifications dans la barre de menu. Dans le second cas avec l’envoi d’un mail.

Notification par la base de données

La table des notifications

Lorsqu’on veut stocker les notifications dans la base de données en attendant que l’utilisateur en ait connaissance il faut commencer par créer une table spécifique :

php artisan notifications:table
php artisan migrate

Voyons les champs de cette table :

  • id : un identifiant unique
  • notifiable_type : le modèle en relation (dans notre cas de notation d’image ça sera User)
  • notifiable_id : l’id du modèle en relation (donc l’id de l’utilisateur notifié)
  • data : les données qui seront sous forme JSON (là on met ce qu’on veut)
  • read_at : le moment où la notification est marquée comme étant lue
  • created_at : le moment où la notification est créée
  • updated_at : le moment où la notification est modifiée

Histoire d’avoir déjà des données on va créer ce seeder :

Avec ce code :

<?php

use Illuminate\Database\Seeder;

class NotificationsTableSeeder extends Seeder
{
    public function run()
    {
        \DB::table('notifications')->insert([
            0 => [
                'id' => '6bd79182-0d88-48b7-8e4e-59dbf3371763',
                'type' => 'App\Notifications\ImageRated',
                'notifiable_type' => 'App\Models\User',
                'notifiable_id' => '2',
                'data' => '{"image":"hVCKABCaItIPhop9nQZBoZb7CFFwgGCYYTLgQEvE.jpeg","image_id":31,"rate":3,"user":3}'
            ],
            1 => [
                'id' => '6c7b833c-4a12-44d5-8fbe-f542e688b865',
                'type' => 'App\Notifications\ImageRated',
                'notifiable_type' => 'App\Models\User',
                'notifiable_id' => '2',
                'data' => '{"image":"RvlsdZqwNw6fIWoQCsb13uFw1W4DiDRHuU4tZONT.jpeg","image_id":32,"rate":5,"user":3}'
            ],
        ]);
    }
}

Mettez à jour DatabaseSeeder :

public function run()
{
    ...

    $this->call(NotificationsTableSeeder::class);
}

Puis rafraichissez la base :

php artisan migrate:fresh --seed

On a maintenant deux notifications :

La notification

On crée maintenant la notification :

php artisan make:notification ImageRated

On change ainsi le code :

<?php

namespace App\Notifications;
use Illuminate\Notifications\Notification;

class ImageRated extends Notification
{
    protected $image;
    protected $rate;
    protected $user_id;

    public function __construct($image, $rate, $user_id)
    {
        $this->image = $image;
        $this->rate = $rate;
        $this->user_id = $user_id;
    }

    public function via()
    {
        return ['database'];
    }

    public function toArray()
    {
        return [
            'image' => $this->image->name,
            'image_id' => (integer)$this->image->id,
            'rate' => (integer)$this->rate,
            'user' => (integer)$this->user_id
        ];
    }
}

On va transmettre à la notification 3 valeurs :

  • l’image concernée ($image)
  • la note donnée ($rate)
  • le propriétaire de l’image notée ($user_id)

Dans la fonction via on définit le canal, donc dans notre cas la base de données (database).

Dans la fonction toArray on définit les données transmises dans la base.

L’envoi de la notification

Dans le contrôleur ImageController, lorsqu’on reçoit une note pour une photo on doit créer la notification :

use App\Repositories\ { ImageRepository, NotificationRepository, AlbumRepository, CategoryRepository };
use App\Notifications\ImageRated;

    ...

public function rate(Request $request, Image $image)
{
    ...
    $this->imageRepository->setImageRate ($image);

    // Notification
    $notificationRepository->deleteDuplicate($user, $image);
    $image->user->notify(new ImageRated($image, $request->value, $user->id));

    return ...
}

On envoie la notification avec la méthode notify appliquée sur l’instance de User. On sait que User est déjà équipé du trait Notifiable sinon il aurait fallu l’ajouter.

On fait appel à un repository (NotificationRepository) qui n’existe pas encore pour accomplir une action : si on reçoit une note pour une photo déjà notifiée et non lue on supprime le doublon. On crée ce repository :

Avec ce code :

<?php

namespace App\Repositories;

use Illuminate\Support\Facades\DB;

class NotificationRepository
{
    public function deleteDuplicate($user, $image)
    {
        DB::table('notifications')
            ->whereNotifiableId($image->user->id)
            ->whereNull('read_at')
            ->where('data->image_id', $image->id)
            ->where('data->user', $user->id)
            ->delete();
    }
}

L’affichage des notifications

On va ajouter deux routes :

Route::middleware ('auth', 'verified')->group (function () {

    ...

    Route::name ('notification.')->prefix('notification')->group(function () {
        Route::name ('index')->get ('/', 'NotificationController@index');
        Route::name ('update')->patch ('{notification}', 'NotificationController@update');
    });
});

Dans la vue layouts.app on ajoute ce code :

@endmaintenance
@unless(auth()->user()->unreadNotifications->isEmpty())
    <li class="nav-item">
        <a class="nav-link" href="{{ route('notification.index') }}">
            <span class="fa-layers fa-fw">
                <span style="color: yellow" class="fas fa-bell fa-lg" data-fa-transform="grow-2"></span>
                <span class="fa-layers-text fa-inverse" data-fa-transform="shrink-4 up-2 left-1" style="color: black; font-weight:900">{{ auth()->user()->unreadNotifications->count() }}</span>
            </span>
        </a>
    </li>
@endunless

Maintenant si on se connecte avec Dupont on a la petite icône et le nombre 2 associé :

On crée le nouveau contrôleur :

php artisan make:controller NotificationController

Avec ces deux méthodes :

<?php

namespace App\Http\Controllers;

use Illuminate\ {
    Http\Request,
    Notifications\DatabaseNotification
};

class NotificationController extends Controller
{
    public function index(Request $request)
    {
        $user = $request->user();

        return view('notifications.index', compact('user'));
    }

    public function update(Request $request, DatabaseNotification $notification)
    {
        $notification->markAsRead();

        if($request->user()->unreadNotifications->isEmpty()) {
            return redirect()->route('home');
        }

        return back();
    }
}

La première (index) sert à afficher la page des notifications. Créons la vue :

Codée ainsi :

@extends('layouts.app')

@section('content')
    <main class="container-fluid">
        <h1>@lang('Notation de vos photos')</h1>
        <div class="card">
            <div class="card-body">
                <div class="table-responsive">
                    <table class="table" style="margin-bottom: 140px">
                        <thead>
                            <tr>
                                <th>@lang('Photo')</th>
                                <th>@lang('Note')</th>
                                <th></th>
                            </tr>
                        </thead>
                        <tbody>
                            @foreach ($user->unreadNotifications as $notification)
                                <tr>
                                    <td>
                                        <div class="hover_img">
                                            <a href="{{ url('images/' . $notification->data['image']) }}" target="_blank">{{ url('images/' . $notification->data['image']) }}<span><img src="{{ url('thumbs/' . $notification->data['image']) }}" alt="image" height="150" /></span></a>
                                        </div>
                                    </td>
                                    <td>{{ $notification->data['rate'] }}</td>
                                    <td>
                                        <form action="{{ route('notification.update', $notification->id) }}" method="POST">
                                            @csrf
                                            @method('PATCH')
                                            <input type="submit" class="btn btn-success btn-sm" value="@lang('Marquer comme lu')">
                                        </form>
                                    </td>
                                </tr>
                            @endforeach
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
        <br>
    </main>
@endsection

On a le lien des photos notées ,la note, et un bouton pour dire qu’on a bien lu la notification. Le survol du lien fait apparaître une miniature de l’image.

Notification par mail

L’administrateur est averti de l’inscription d’un nouvel utilisateur par mail. On crée une nouvelle notification :

On va compléter le code :

<?php

namespace App\Notifications;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;

class UserCreated extends Notification
{
    protected $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function via($notifiable)
    {
        return ['mail'];
    }

    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->subject(__('Nouvel utilisateur'))
                    ->line(__("Un nouvel utilisateur s'est enregistré."))
                    ->line(__('Nom : ') . $this->user->name)
                    ->line(__('Email : ') . $this->user->email);
    }
}

On transmet juste l’utilisateur à notifier, donc l’administrateur.

Cette fois dans la méthode via on précise par mail.

On a une méthode toMail pour définir ce que doit contenir le mail.

Comment savoir quand un utilisateur est créé ? Laravel va nous en informer. On commence par créer un événement :

php artisan make:event UserCreated

On le code ainsi :

<?php

namespace App\Events;

use Illuminate\Queue\SerializesModels;
use App\Models\User;

class UserCreated
{
    use  SerializesModels;

    public $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }
}

On crée ensuite un listener :

php artisan make:listener UserCreated

Avec ce code :

<?php

namespace App\Listeners;

use App\ {
    Events\UserCreated as UserCreatedEvent,
    Notifications\UserCreated as SendNotificationUserCreated,
    Models\User
};
use Illuminate\Support\Facades\Notification;

class UserCreated
{
    public function handle(UserCreatedEvent $event)
    {
        Notification::send(User::whereRole('admin')->first(), new SendNotificationUserCreated($event->user));
    }
}

C’est ici qu’on envoie la notification, donc le mail.

On établit la liaison entre les deux dans EventServiceProvider :

protected $listen = [
    ...

    'App\Events\UserCreated' => ['App\Listeners\UserCreated',],
];

Il ne reste plus qu’à déclencher l’événement, on va le faire dans le modèle User :

use App\Events\UserCreated;

    ...

protected $dispatchesEvents = [
    'created' => UserCreated::class,
];

Et maintenant quand un nouvel utilisateur est créé l’administrateur (ou les administrateurs) reçoit un mail :

Pour gagner en efficacité on peut établir une file d’attente (queue) parce que l’envoi d’un mail prend du temps. On ajoute ce trait dans la notification (et on implémente ShouldQueue) :

class UserCreated extends Notification implements ShouldQueue
{
    use Queueable;

Dans le fichier .env on choisit un driver, par exemple redis :

QUEUE_CONNECTION=redis

Il faut également ajouter ce package :

composer require predis/predis

Plus qu’à lancer :

php artisan queue:work

En résumé

Dans ce chapitre on a créé deux sortes de notification :

  • avec la base de données pour mémoriser la notation des photos et en informer le propriétaire
  • avec un mail pour avertir l’administrateur de l’inscription des nouveaux utilisateurs

Pour vous simplifier la vie vous pouvez charger le projet dans son état à l’issue de ce chapitre.




Laravel 5.7 par la pratique – Notation des photos

Dans ce chapitre on va permettre aux utilisateurs authentifier de noter les photos des autres utilisateurs. On va adopter une approche visuelle classique avec une série de 5 étoiles.

La base

Pour mémoriser les notes on va créer une nouvelle table. Un utilisateur peut noter plusieurs photos et une photo peut être notée par plusieurs utilisateurs, on a donc une relation de type ManyToMany avec une table pivot. Créons cette table :

php artisan make:migration create_image_user_table

On complète le code :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class CreateImageUserTable extends Migration
{
    public function up()
    {
        Schema::create('image_user', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->integer('rating');
            $table->unsignedInteger('user_id')->index();
            $table->unsignedInteger('image_id')->index();
            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
            $table->foreign('image_id')->references('id')->on('images')->onDelete('cascade');
        });
    }

    public function down()
    {
        Schema::drop('image_user');
    }
}

Histoire d’avoir déjà des notes on crée un seeder :

Avec ce code :

<?php

use Illuminate\Database\Seeder;

class RatingsTableSeeder extends Seeder
{
    public function run()
    {
        \DB::table('image_user')->insert([
            0 => [
                'image_id' => 39,
                'user_id' => 3,
                'rating' => 1,
            ],
            1 => [
                'image_id' => 40,
                'user_id' => 3,
                'rating' => 2,
            ],
            2 => [
                'image_id' => 37,
                'user_id' => 3,
                'rating' => 2,
            ],
            3 => [
                'image_id' => 43,
                'user_id' => 3,
                'rating' => 2,
            ],
            4 => [
                'image_id' => 39,
                'user_id' => 2,
                'rating' => 5,
            ],
            5 => [
                'image_id' => 37,
                'user_id' => 2,
                'rating' => 5,
            ],
            6 => [
                'image_id' => 41,
                'user_id' => 2,
                'rating' => 3,
            ],
            7 => [
                'image_id' => 36,
                'user_id' => 2,
                'rating' => 2,
            ],
            7 => [
                'image_id' => 31,
                'user_id' => 3,
                'rating' => 3,
            ],
            8 => [
                'image_id' => 32,
                'user_id' => 3,
                'rating' => 3,
            ]
        ]);
    }
}

On complète le code de DatabaseSeeder :

public function run()
{
    ...

    $this->call(RatingsTableSeeder::class);
}

On n’a plus qu’à rafraîchir la base :

php artisan migrate:fresh --seed

Vous aurez peut-être besoin de lancer un composer dumpautoload pour charger la classe.

Les relations

Dans le modèle User on ajoute la relation :

public function imagesRated()
{
    return $this->belongsToMany (Image::class);
}

Ainsi que dans le modèle Image :

public function users()
{
    return $this->belongsToMany (User::class)->withPivot('rating');
}

Route et contrôleur

On va ajouter une route pour gérer la notation :

Route::name ('image.')->middleware ('ajax')->group (function () {
    Route::prefix('image')->group(function () {

        ...

    });
    Route::name ('rating')->put ('rating/{image}', 'ImageController@rate');
});

On va crée une méthode dans ImageController :

public function rate(Request $request, Image $image)
{
    $user = $request->user();

    // Is user image owner ?
    if($this->imageRepository->isOwner ($user, $image)) {
        return response()->json(['status' => 'no']);
    }

    // Rating
    $rate = $this->imageRepository->rateImage ($user, $image, $request->value);
    $this->imageRepository->setImageRate ($image);

    return [
        'status' => 'ok',
        'id' => $image->id,
        'value' => $image->rate,
        'count' => $image->users->count(),
        'rate' => $rate
    ];
}

On vérifie que celui qui note n’est pas le propriétaire de la photo, parce qu’il n’a pas le droit de noter ses propres photos.

Comme d’habitude on délègue au repository (ImageRepository) la gestion des données :

public function rateImage($user, $image, $value)
{
    $rate = $image->users()->where('users.id', $user->id)->pluck('rating')->first();

    if($rate) {
        if($rate !== $value) {
            $image->users ()->updateExistingPivot ($user->id, ['rating' => $value]);
        }
    } else {
        $image->users ()->attach ($user->id, ['rating' => $value]);
    }

    return $rate;
}

public function isOwner($user, $image)
{
    return $image->user()->where('users.id', $user->id)->exists();
}

On vérifie si l’utilisateur en question a déjà noté la photo, dans ce cas il faut mettre à jour sa notation, sinon il faut la créer.

On a ainsi la gestion d’une note qui arrive mais il faut aussi envoyer la note de chaque image qu’on affiche pour savoir combien d’étoiles prévoir !

Ajouter la note aux photos

Donc chaque fois qu’on envoie les informations d’une photos à partir du serveur il faut maintenant ajouter la note. Ça va se passer dans ImageRepository. On commence par créer ces 3 fonctions :

public function paginateAndRate($query)
{
    $images = $query->paginate (config ('app.pagination'));
    return $this->setRating ($images);
}

public function setRating($images)
{
    $images->transform(function ($image) {
        $this->setImageRate ($image);
        return $image;
    });
    return $images;
}

public function setImageRate($image)
{
    $number = $image->users->count();
    $image->rate = $number ? $image->users->pluck ('pivot.rating')->sum () / $number : 0;
}

Et partout où on se contentait de paginer on va ajouter la note aux images :

public function getAllImages()
{
    return $this->paginateAndRate (Image::latestWithUser());
}

public function getImagesForCategory($slug)
{
    $query = Image::latestWithUser ()->whereHas ('category', function ($query) use ($slug) {
        $query->whereSlug ($slug);
    });
    return $this->paginateAndRate ($query);
}

public function getImagesForUser($id)
{
    $query = Image::latestWithUser ()->whereHas ('user', function ($query) use ($id) {
        $query->whereId ($id);
    });
    return $this->paginateAndRate ($query);
}

public function getImagesForAlbum($slug)
{
    $query = Image::latestWithUser ()->whereHas ('albums', function ($query) use ($slug) {
        $query->whereSlug ($slug);
    });
    return $this->paginateAndRate ($query);
}

Vous n’allez encore rien remarquer à l’affichage de la galerie mais les données sont bien là :

La vue

Il ne nous reste plus qu’à gérer le côté client…

On commence par placer les étoiles dans la vue home :

<div class="star-rating" id="{{ $image->id }}">
    <span class="count-number">({{ $image->users->count() }})</span>
    <div id="{{ $image->id . '.5' }}" data-toggle="tooltip" title="5" @if($image->rate > 4) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <div id="{{ $image->id . '.4' }}" data-toggle="tooltip" title="4" @if($image->rate > 3) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <div id="{{ $image->id . '.3' }}" data-toggle="tooltip" title="3" @if($image->rate > 2) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <div id="{{ $image->id . '.2' }}" data-toggle="tooltip" title="2" @if($image->rate > 1) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <div id="{{ $image->id . '.1' }}" data-toggle="tooltip" title="1" @if($image->rate > 0) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <span class="pull-right">
        @adminOrOwner($image->user_id)

Bon, ce n’est pas encore très fun :

On va ajouter quelques règles CSS dans resources/sass/app.scss :

.star-rating div {
  display: inline-block;
  font-size: 15px;
  -webkit-transition: all .3s ease-in-out;
  transition: all .3s ease-in-out;
  cursor: pointer;
}
.star-yellow,
.star-rating div:hover,
.star-rating div:hover ~ div {
  color: #f2b600
}
.hover_img a { position:relative; }
.hover_img a span { position:absolute; display:none; z-index:99; }
.hover_img a:hover span { display:block; }

On relance la compilation avec npm run dev.

Ça s’est un peu arrangé :

On ajoute le Javascript pour gérer tout ça dans home :

let memoStars = []

$('.star-rating div').click((e) => {
    @auth
        let element = $(e.currentTarget)
        let values = element.attr('id').split('.')
        element.addClass('fa-spin')
        $.ajax({
            url: "{{ url('rating') }}" + '/' + values[0],
            type: 'PUT',
            data: {value: values[1]}
        })
        .done((data) => {
            if (data.status === 'ok') {
                let image = $('#' + data.id)
                memoStars = []
                image.children('div')
                    .removeClass('star-yellow')
                    .each(function (index, element) {
                        if (data.value > 4 - index) {
                            $(element).addClass('star-yellow')
                            memoStars.push(true)
                        }
                        memoStars.push(false)
                    })
                    .end()
                    .find('span.count-number')
                    .text('(' + data.count + ')')
                if(data.rate) {
                    if(data.rate == values[1]) {
                        title = '@lang("Vous avez déjà donné cette note !")'
                    } else {
                        title = '@lang("Votre vote a été modifié !")'
                    }
                } else {
                    title = '@lang("Merci pour votre vote !")'
                }
                swal({
                    title: title,
                    type: 'warning'
                })
            } else {
                swal({
                    title: '@lang('Vous ne pouvez pas voter pour vos photos !')',
                    type: 'error'
                })
            }
            element.removeClass('fa-spin')
        })
        .fail(() => {
            swallAlertServer()
            element.removeClass('fa-spin')
        })
    @else
        swal({
            title: '@lang('Vous devez être connecté pour pouvoir voter !')',
            type: 'error'
        })
    @endauth
})

$('.star-rating').hover(
    (e) => {
        memoStars = []
        $(e.currentTarget).children('div')
            .each((index, element) => {
                memoStars.push($(element).hasClass('star-yellow'))
            })
            .removeClass('star-yellow')
    }, (e) => {
    $.each(memoStars, (index, value) => {
        if(value) {
            $(e.currentTarget).children('div:eq(' + index + ')').addClass('star-yellow')
        }
    })
})

Maintenant au survol on a les étoiles qui réagissent correctement. Su un utilisateur non connecté clique il a ce message :

Si on essaie de voter pour ses photos :

Si on change son vote :

Et si on ajoute un vote :

Le chiffre entre parenthèses indique le nombre de votes et évidemment on fait la moyenne des votes pour afficher la bonne étoile.

Le nombre de vues

On va terminer ce chapitre en ajoutant le nombre de fois qu’une photo a été cliquée. On n’a pas beosin de toucher à la base parce qu’on a déjà prévu un champ clicks dans la table images.

On ajoute cette route :

Route::middleware('ajax')->name('image.click')->patch('image/{image}/click', 'ImageController@click');

On ajoute cette fonction dans ImageController :

public function click(Request $request, Image $image)
{
    if ($request->session()->has('images') && in_array ($image->id, session ('images'))) {
        return response ()->json (['increment' => false]);
    }
    $request->session()->push('images', $image->id);
    $image->increment('clicks');
    return ['increment' => true];
}

On mémorise le clic en session pour ne pas comptabiliser plusieurs fois le clic d’un même utilisateur.

Dans la vue home on ajoute la valeur à côté de la date :

<div class="pull-right">
    <em>
        (<span class="image-click">{{ $image->clicks }}</span> {{ trans_choice(__('vue|vues'), $image->clicks) }}) {{ $image->created_at->formatLocalized('%x') }}
    </em>
</div>

On gère le pluriel avec trans_choice.

On ajoute le Javascript pour gérer ça :

$('a.image-link').click((e) => {
    e.preventDefault()
    let that = $(e.currentTarget)
    $.ajax({
        method: 'patch',
        url: that.attr('data-link')
    }).done((data) => {
        if(data.increment) {
            let numberElement = that.siblings('div.card-footer').find('.image-click')
            numberElement.text(parseInt(numberElement.text()) + 1)
        }
    })
})

Pour récupérer la bonne url on ajoute une référence pour chaque image (data-link) :

@foreach($images as $image)
    <div class="card @if($image->adult) border-danger @endif" id="image{{ $image->id }}">
        <a href="{{ url('images/' . $image->name) }}" class="image-link" data-link="{{ route('image.click', $image->id) }}">

Et maintenant ça devrait fonctionner :

En résumé

Dans ce chapitre on a :

  • ajouté la notation des images
  • ajouté le nombre de vues

Pour vous simplifier la vie vous pouvez charger le projet dans son état à l’issue de ce chapitre.




Laravel 5.7 par la pratique – L’administration

La galerie est maintenant bien avancée. Les utilisateurs peuvent gérer des albums personnels, changer leur profil, modifier toutes les caractéristiques de leurs images. Dans ce chapitre nous allons mettre en place des outils d’administrations : suppression des images orphelines, galerie en mode maintenance et gestion des utilisateurs.

Les images orphelines

Lorsqu’on supprime une image ça a pour effet de supprimer la ligne dans la table images mais les deux versions de la photo (haute et basse résolution) restent sur le disque. Ce n’est pas vraiment gênant mais ça pourrait le devenir en cas de nombreuses suppressions et puis ça serait quand même plus élégant de s’en occuper.

On pourrait ajouter cette action systématiquement quand on supprime une photo mais j’ai préféré créer une partie maintenance réservée à l’administrateur.

On va créer deux nouvelles routes :

Route::middleware ('admin')->group (function () {

    ...

    Route::name ('orphans.')->prefix('orphans')->group(function () {
        Route::name ('index')->get ('/', 'AdminController@orphans');
        Route::name ('destroy')->delete ('/', 'AdminController@destroy');
    });
});

On ajoute un item au menu de l’administration (views/layouts/app) :

@admin
<li class="nav-item dropdown">
    <a class="nav-link dropdown-toggle{{ currentRoute(
                            route('category.create'),
                            route('category.index'),
                            route('category.edit', request()->category?: 0),
                            route('orphans.index'),
                        )}}" href="#" id="navbarDropdownGestCat" role="button" data-toggle="dropdown"
        aria-haspopup="true" aria-expanded="false">
        @lang('Administration')
    </a>
    <div class="dropdown-menu" aria-labelledby="navbarDropdownGestCat">
        <a class="dropdown-item" href="{{ route('category.create') }}">
            <i class="fas fa-plus fa-lg"></i> @lang('Ajouter une catégorie')
        </a>
        <a class="dropdown-item" href="{{ route('category.index') }}">
            <i class="fas fa-wrench fa-lg"></i> @lang('Gérer les catégories')
        </a>
        <a class="dropdown-item" href="{{ route('orphans.index') }}">
            <i class="fas fa-images fa-lg"></i> @lang('Photos orphelines')
        </a>
    </div>
</li>
@endadmin

On crée un nouveau contrôleur :

php artisan make:controller AdminController --resource

Affichage des orphelines

Pour l’affichage des orphelines on crée une méthode orphans dans AdminController et on déclare le repository ImageRepository :

<?php

namespace App\Http\Controllers;

use App\Repositories\ImageRepository;
use Illuminate\Http\Request;

class AdminController extends Controller
{
    protected $repository;

    public function __construct(ImageRepository $repository)
    {
        $this->repository = $repository;
    }

    public function orphans()
    {
        $orphans = $this->repository->getOrphans ();
        $orphans->count = count($orphans);

        return view ('maintenance.orphans', compact ('orphans'));
    }
}

Et dans ImageRepository :

public function getOrphans()
{
    return collect (Storage::files ('images'))->transform(function ($item) {
        return basename($item);
    })->diff (Image::select ('name')->pluck ('name'));
}

On en profite pour voir la puissance des collections de Laravel !

On crée la vue :

Avec ce code :

@extends('layouts.app')

@section('content')

    <main class="container-fluid">
        <h1>
            {{ $orphans->count }} {{ trans_choice(__('image orpheline|images orphelines'), $orphans->count) }}
            @if($orphans->count)
                <a class="btn btn-danger pull-right" href="{{ route('orphans.destroy') }}"
                   role="button">@lang('Supprimer')</a>
            @endif
        </h1>

        <div class="card-columns">
            @foreach($orphans as $orphan)
                <div class="card">
                    <img class="img-fluid" src="{{ url('thumbs/' . $orphan) }}" alt="image">
                </div>
            @endforeach
        </div>
    </main>

@endsection

@section('script')

    @include('partials.script-delete', ['text' => __('Vraiment supprimer toutes les photos orphelines ?'), 'return' => 'reload'])

@endsection

Suppression des orphelines

On a un bouton pour la suppression :

On code AdminController :

    
    ...

public function __construct(ImageRepository $repository)
{
    $this->repository = $repository;
    $this->middleware('ajax')->only('destroy');
}

public function destroy()
{
    $this->repository->destroyOrphans ();
    return response ()->json ();
}

    ...

Et ImageRepository :

public function destroyOrphans()
{
    $orphans = $this->getOrphans ();

    foreach ($orphans as $orphan) {
        Storage::delete ([
            'images/' . $orphan,
            'thumbs/' . $orphan,
        ]);
    }
}

Si on supprime on se retrouve avec ça :

Remarquez la gestion du pluriel au niveau de la vue :

{{ trans_choice(__('image orpheline|images orphelines'), $orphans->count) }}

Le mode maintenance

Maintenant voyons comment mettre en place le code pour pouvoir mettre notre galerie en maintenance et qu’ainsi elle ne soit plus accessible que par l’administrateur en se fondant sur son adresse IP.

On va créer deux nouvelles routes :

Route::middleware ('admin')->group (function () {

    ...

    Route::name ('maintenance.')->prefix('maintenance')->group(function () {
        Route::name ('index')->get ('/', 'AdminController@edit');
        Route::name ('update')->put ('/', 'AdminController@update');
    });

});

On ajoute un item au menu de l’administration (views/layouts/app) :

@admin
<li class="nav-item dropdown">
    <a class="nav-link dropdown-toggle{{ currentRoute(
                            route('category.create'),
                            route('category.index'),
                            route('category.edit', request()->category?: 0),
                            route('orphans.index'),
                            route('maintenance.index')
                        )}}" href="#" id="navbarDropdownGestCat" role="button" data-toggle="dropdown"
        aria-haspopup="true" aria-expanded="false">
        @lang('Administration')
    </a>
    <div class="dropdown-menu" aria-labelledby="navbarDropdownGestCat">
        <a class="dropdown-item" href="{{ route('category.create') }}">
            <i class="fas fa-plus fa-lg"></i> @lang('Ajouter une catégorie')
        </a>
        <a class="dropdown-item" href="{{ route('category.index') }}">
            <i class="fas fa-wrench fa-lg"></i> @lang('Gérer les catégories')
        </a>
        <a class="dropdown-item" href="{{ route('orphans.index') }}">
            <i class="fas fa-images fa-lg"></i> @lang('Photos orphelines')
        </a>
        <a class="dropdown-item" href="{{ route('maintenance.index') }}">
            <i class="fas fa-cogs fa-lg"></i> @lang('Mode maintenance')
        </a>
    </div>
</li>
@endadmin

Affichage du formulaire

On ajoute cette méthode dans AdminController :

use Symfony\Component\HttpFoundation\IpUtils;
use Illuminate\Contracts\Foundation\Application;

    ...

public function edit(Request $request, Application $app)
{
    $maintenance = $app->isDownForMaintenance();
    $ipChecked = true;
    $ip = $request->ip();

    if($maintenance) {
        $data = json_decode(file_get_contents($app->storagePath().'/framework/down'), true);
        $ipChecked = isset($data['allowed']) && IpUtils::checkIp($ip, (array) $data['allowed']);
    }

    return view ('maintenance.maintenance', compact ('maintenance', 'ip', 'ipChecked'));
}

On crée la vue :

Avec ce code :

@extends('layouts.form')

@section('card')

    @component('components.card')

        @slot('title')
            @lang('Mode maintenance')
        @endslot

        <form method="POST" action="{{ route('maintenance.update') }}">
            @csrf
            @method('PUT')

            @component('components.checkbox', [
                    'name' => 'maintenance',
                    'label' => __('Mode maintenance'),
                    'checked' => $maintenance ? 'checked' : ''
                ])
            @endcomponent

            @component('components.checkbox', [
                    'name' => 'ip',
                    'label' => __('Autoriser mon IP ') . '(' . $ip . ')',
                    'checked' => $ipChecked ? 'checked' : ''
                ])
            @endcomponent

            @component('components.button')
                @lang('Envoyer')
            @endcomponent

        </form>

    @endcomponent            

@endsection

On ajoute un composant pour les cases à cocher :

Avec ce code :

<div class="form-group">
    <div class="custom-control custom-checkbox">
        <input type="checkbox" class="custom-control-input" id="{{ $name }}" name="{{ $name }}" @if($checked) checked @endif>
        <label class="custom-control-label" for="{{ $name }}"> {{ $label }}</label>
    </div>
</div>

Et le formulaire devrait apparaître :

Traitement du formulaire

On ajoute la méthode update dans AdminController :

use Illuminate\Support\Facades\Artisan;

    ...

public function update(Request $request)
{
    if($request->maintenance) {
        Artisan::call ('down', $request->ip ? ['--allow' => $request->ip()] : []);
    } else {
        Artisan::call ('up');
    }

    return redirect()->route('maintenance.index')->with ('ok', __ ('Le mode a bien été actualisé.'));
}

Une alerte nous informe du succès de l’opération :

Une alerte

Maintenant ce qui serait bien serait de disposer de quelque chose qui nous rappelle qu’on est en mode maintenance parce qu’on risque d’oublier !

On va créer une nouvelle commande Blade dans AppServiceProvider :

public function boot()
{
   
    ...

    Blade::if ('maintenance', function () {
        return auth ()->check () && auth ()->user ()->admin && app()->isDownForMaintenance();
    });

    ...

}

Et on ajoute une petite icône dans layouts.app :

<ul class="navbar-nav ml-auto">
    @guest
    <li class="nav-item{{ currentRoute(route('login')) }}"><a class="nav-link" href="{{ route('login') }}">@lang('Connexion')</a></li>
    <li class="nav-item{{ currentRoute(route('register')) }}"><a class="nav-link" href="{{ route('register') }}">@lang('Inscription')</a></li>
    @else
        @maintenance
            <li class="nav-item">
                <a class="nav-link" href="{{ route('maintenance.index') }}" data-toggle="tooltip" title="@lang('Mode maintenance')">
                    <span class="fas fa-exclamation-circle  fa-lg" style="color: red;">

                    </span>
                </a>
            </li>
        @endmaintenance
        <li class="nav-item{{ currentRoute(
                    route('profile.edit', auth()->id()),
                    route('profile.show', auth()->id())
                )}}">
            <a class="nav-link" href="{{ route('profile.edit', auth()->id()) }}">@lang('Profil')</a>
        </li>
        <li class="nav-item">
            <a id="logout" class="nav-link" href="{{ route('logout') }}">@lang('Déconnexion')</a>
            <form id="logout-form" action="{{ route('logout') }}" method="POST" class="hide">
                {{ csrf_field() }}
            </form>
        </li>
    @endguest
</ul>

Maintenant on risque moins d’oublier !

On en profite pour qu’un clic sur l’icône renvoie dans la page de maintenance.

La gestion des utilisateurs

Pour gérer les utilisateurs on va encore créer des routes :

Route::middleware ('admin')->group (function () {

    ...

    Route::resource ('user', 'UserController', [
        'only' => ['index', 'edit', 'update', 'destroy']
    ]);

    ...

});

On crée le contrôleur associé :

php artisan make:controller UserController --resource

Avec ce code de base :

<?php

namespace App\Http\Controllers;

use App\Repositories\UserRepository;
use App\Models\User;

class UserController extends Controller
{
    protected $repository;

    public function __construct(UserRepository $repository)
    {
        $this->repository = $repository;
    }
}

 

On crée aussi un repository :

Avec ce code de base :

<?php

namespace App\Repositories;

use App\Models\User;

class UserRepository extends BaseRepository
{
    public function __construct(User $user)
    {
        $this->model = $user;
    }
}

Le menu

On ajoute encore un item au menu (layouts.app) :

@admin
<li class="nav-item dropdown">
    <a class="nav-link dropdown-toggle{{ currentRoute(
                            route('category.create'),
                            route('category.index'),
                            route('category.edit', request()->category?: 0),
                            route('orphans.index'),
                            route('maintenance.index'),
                            route('user.index')
                        )}}" href="#" id="navbarDropdownGestCat" role="button" data-toggle="dropdown"
        aria-haspopup="true" aria-expanded="false">
        @lang('Administration')
    </a>
    <div class="dropdown-menu" aria-labelledby="navbarDropdownGestCat">
        <a class="dropdown-item" href="{{ route('category.create') }}">
            <i class="fas fa-plus fa-lg"></i> @lang('Ajouter une catégorie')
        </a>
        <a class="dropdown-item" href="{{ route('category.index') }}">
            <i class="fas fa-wrench fa-lg"></i> @lang('Gérer les catégories')
        </a>
        <a class="dropdown-item" href="{{ route('orphans.index') }}">
            <i class="fas fa-images fa-lg"></i> @lang('Photos orphelines')
        </a>
        <a class="dropdown-item" href="{{ route('maintenance.index') }}">
            <i class="fas fa-cogs fa-lg"></i> @lang('Mode maintenance')
        </a>
        <a class="dropdown-item" href="{{ route('user.index') }}">
            <i class="fas fa-users fa-lg"></i> @lang('Utilisateurs')
        </a>
    </div>
</li>
@endadmin

Afficher les utilisateurs

On va commencer par afficher une page avec la liste des utilisateurs et de leurs renseignements ainsi que des boutons pour modifier leurs données ou même les supprimer. On commence par coder UserController :

public function index()
{
    $users = $this->repository->getAllWithPhotosCount();
    return view ('users.index', compact('users'));
}

Et dans UserRepository :

public function getAllWithPhotosCount()
{
    return User::withCount('images')->oldest('name')->get();
}

On crée la vue :

Avec ce code :

@extends('layouts.form-wide')

@section('css')

    <style>
        .fa-check { color: green; }
    </style>

@endsection


@section('card')

    @component('components.card')

        @slot('title')
            @lang('Gestion des utilisateurs (administrateurs en rouge)')
        @endslot

        <div class="table-responsive">
            <table class="table table-dark text-white">
                <thead>
                    <tr>
                        <th scope="col">@lang('Nom')</th>
                        <th scope="col">@lang('Email')</th>
                        <th scope="col">@lang('Inscription')</th>
                        <th scope="col">@lang('Vérifié')</th>
                        <th scope="col">@lang('Adulte')</th>
                        <th scope="col">@lang('Photos')</th>
                        <th></th>
                        <th></th>
                    </tr>
                </thead>
                <tbody>
                @foreach($users as $user)
                    <tr @if($user->admin) style="color: red" @endif>
                        <td>{{ $user->name }}</td>
                        <td>{{ $user->email }}</td>
                        <td>{{ $user->created_at->formatLocalized('%x') }}</td>
                        <td>@if($user->email_verified_at){{ $user->email_verified_at->formatLocalized('%x') }}@endif</td>
                        <td>@if($user->adult)<i class="fas fa-check fa-lg"></i>@endif</td>
                        <td>{{ $user->images_count }}</td>
                        <td>
                            <a type="button" href="{{ route('user.edit', $user->id) }}"
                               class="btn btn-warning btn-sm pull-right mr-2 invisible" data-toggle="tooltip"
                               title="@lang("Modifier l'utilisateur") {{ $user->name }}"><i
                                        class="fas fa-edit fa-lg"></i></a>
                        </td>
                        <td>
                            @unless($user->admin)
                            <a type="button" href="{{ route('user.destroy', $user->id) }}"
                               class="btn btn-danger btn-sm pull-right invisible" data-toggle="tooltip"
                               title="@lang("Supprimer l'utilisateur") {{ $user->name }}"><i
                                        class="fas fa-trash fa-lg"></i></a>
                            @endunless
                        </td>
                    </tr>
                @endforeach
                </tbody>
            </table>
        </div>

    @endcomponent

@endsection

@section('script')

    <script>
        $(() => {
            $('a').removeClass('invisible')
        })
    </script>

    @include('partials.script-delete', ['text' => __('Vraiment supprimer cet utilisateur ?'), 'return' => 'removeTr'])

@endsection

Pour l’occasion on crée un nouveau layout pour ce formulaire plus large que les autres parce qu’on a beaucoup d’informations à y faire tenir :

Avec ce code :

@extends('layouts.app')

@section('content')
    <div class="container py-5">
        <div class="row">
            <div class="col">
                @yield('card')
            </div>
        </div>
    </div>
@endsection

Pour afficher correctement les dates on va compléter ainsi le modèle User :

protected $dates = [
    'created_at',
    'updated_at',
    'email_verified_at',
];

Ainsi email_verified_at sera lui aussi automatiquement converti en instance de Carbon.

Et enfin on a la page :

Modifier un utilisateur

On code la méthode update de UserController :

public function edit(User $user)
{
    return view ('users.edit', compact ('user'));
}

On crée la vue pour le formulaire de modification :

Avec ce code :

@extends('layouts.form')

@section('card')

    @component('components.card')

        @slot('title')
            @lang('Modifier un utilisateur')
        @endslot

        <form method="POST" action="{{ route('user.update', $user->id) }}">
            @csrf
            @method('PUT')

            @include('partials.form-group', [
                'title' => __('Nom'),
                'type' => 'text',
                'name' => 'name',
                'value' => $user->name,
                'required' => true,
                ])

            @include('partials.form-group', [
                'title' => __('Email'),
                'type' => 'email',
                'name' => 'email',
                'value' => $user->email,
                'required' => true,
                ])

            <div class="form-group">
                <div class="custom-control custom-checkbox">
                    <input type="checkbox" class="custom-control-input" id="adult" name="adult" {{ $user->settings->adult ? 'checked' : '' }}>
                    <label class="custom-control-label" for="adult"> @lang('Adulte')</label>
                </div>
            </div>

            <div class="form-group">
                <div class="custom-control custom-checkbox">
                    <input type="checkbox" class="custom-control-input" id="verified" name="verified" {{ $user->hasVerifiedEmail() ? 'checked' : '' }}>
                    <label class="custom-control-label" for="verified"> @lang('Vérifié')</label>
                </div>
            </div>

            @component('components.button')
                @lang('Envoyer')
            @endcomponent

        </form>

    @endcomponent            

@endsection

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

hp artisan make:request UserRequest

On complète le code :

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    public function rules()
    {
        return $rules = [
            'name' => 'required|string|max:255|unique:users,name,' . $this->user->id,
            'email' => 'required|string|email|max:255|unique:users,email,' . $this->user->id,
        ];
    }
}

On code la méthode update dans UserController :

use App\Http\Requests\UserRequest;

    ...

public function update(UserRequest $request, User $user)
{
    $this->repository->update ($user, $request);

    return redirect ()->route('user.index')->with ('ok', __ ("L'utilisateur a bien été modifié"));
}

Le traitement se fait dans UserRepository :

use Illuminate\Http\Request;
use Carbon\Carbon;

    ...

public function update(User $user, Request $request)
{
    if($user->hasVerifiedEmail() && !$request->verified) {
        $request->merge(['email_verified_at' => null]);
    }

    if(!$user->hasVerifiedEmail() && $request->verified) {
        $request->merge(['email_verified_at' => new Carbon]);
    }

    $user->adult = $request->adult;
    $user->update ($request->all());
}

Pour le changement du statut adulte on crée un mutateur dans le modèle User :

public function setAdultAttribute($value)
{
    $this->attributes['settings'] = json_encode ([
        'adult' => $value,
        'pagination' => $this->settings->pagination
    ]);
}

Ca clarifie bien la syntaxe !

Une petite alerte pour rassurer :

Supprimer un utilisateur

Pour terminer on va ajouter la possibilité de supprimer un utilisateur.

On code la méthode destroy dans UserController :

    ...

public function __construct(UserRepository $repository)
{
    $this->repository = $repository;

    $this->middleware('ajax')->only('destroy');
}

public function destroy(User $user)
{
    $user->delete ();

    return response ()->json ();
}

Evidemment on affiche une alerte avant modification :

Et si on dit oui alors il disparaît de la liste et de la base ainsi que toutes ses photos !

En résumé

Dans ce chapitre on a :

  • ajouté la gestion des images orphelines
  • ajouté le mode maintenance
  • mis en place la gestion des utilisateurs

Pour vous simplifier la vie vous pouvez charger le projet dans son état à l’issue de ce chapitre.