Changer le framework CSS bis

Il y a un peu plus d’un an j’ai publié un article qui montre comment remplacer Bootstrap qui est utilisé par défaut par Materialize. Un commentaire récent pour cet article m’a montré que les choses évoluent très vite et que ce que j’avais dit était devenu obsolète. Entre temps Laravel a changé et Materialize est enfin passé à la version 1. Je vous présente donc dans le présent article la procédure actualisée.

Pour vous faciliter la vie le projet complet est téléchargeable ici.

Installation par défaut

Partez d’une nouvelle installation de Laravel :

composer create-project --prefer-dist laravel/laravel laravel5

Puis générez les vue pour l’authentification :

php artisan make:auth

Renseignez le fichier .env pour la connexion à la base puis générez les migrations :

php artisan migrate

Si tout se passe bien vous avez 3 tables :

Ensuite installez les modules avec npm :

npm install

Lancez en mode développement pour voir si ça fonctionne :

npm run dev

Vous devez avoir la compilation des assets dans les fichiers public/css/app.css et public/js/app.js comme c’est prévu dans le fichier webpack.mix.js :

mix.js('resources/js/app.js', 'public/js')
.sass('resources/sass/app.scss', 'public/css');

Pour mémoire les sources des assets sont ici :

Pour le CSS on voit que dans le fichier resources/sass/app.scss on importe bootstrap :

@import '~bootstrap/scss/bootstrap';

Et qu’on le modifie un peu en chargeant de nouvelles variables :

@import "variables";

Pour le Javascript on voit que dans le fichier resources/assets/js/bootstrap.js on charge bootstrap :

require('bootstrap-sass');

D’autre part on charge aussi lodash, jQuery et Vue.js avec un composant par défaut mais ce n’est pas l’objet du présent article.

Voilà donc un petit état des lieux à l’installation de Laravel et on se retrouve pour les vues de l’authentification avec un aspect typique de Bootstrap :

On va maintenant procéder aux modifications pour utiliser non plus Bootstrap mais Materialize que j’aime bien. Mais les explications restent valables pour n’importe quel framework CSS.

On passe à Materialize

L’intendance

Sur le site de npm on cherche le module :

Et comme on veut ce module uniquement pour le développement on va utiliser cette syntaxe :

npm install materialize-css --save-dev

Si tout va bien le fichier package.json est actualisé :

"devDependencies": {
    ...
    "materialize-css": "^1.0.0",
    ...
}

On va maintenant mettre à jour le fichier resources/sass/app.scss pour importer Materialize plutôt que Bootstrap :

// Fonts
//@import url('https://fonts.googleapis.com/css?family=Nunito');
@import url("https://fonts.googleapis.com/icon?family=Material+Icons");

// Variables
//@import 'variables';

// Bootstrap
//@import '~bootstrap/scss/bootstrap';

//Materialize
@import "~materialize-css/sass/materialize.scss";

.card { margin-top: 80px; };

Et pour le Javascript ça se passe dans le fichier resources/js/bootstrap.js, on va se contenter de ça :

window.$ = window.jQuery = require('jquery');
require('materialize-css');

On va lancer en mode développement avec actualisation automatique :

npm run watch

Si on regarde maintenant la page de login on va lui trouver évidemment un salle aspect. C’est normal parce qu’on a les classes de Bootstrap déclarées et qu’on a plus ce framework disponible. Il nous reste maintenant à modifier les vues avec les classes de Materialize.

Layout

Voici la nouvelle vue resources/views/layouts/app.blade.php :

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0"/>

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Styles -->
    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
    @yield('css')
</head>
<body>
    <div id="app">

        <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
            @csrf
        </form>

        @auth
            <ul id="dropdown1" class="dropdown-content">
                <li><a href="{{ route('logout') }}" class="logout">{{ __('Logout') }}</a></li>
            </ul>
        @endauth

        <nav>
            <div class="nav-wrapper">
                <a href="{{ url('/') }}" class="brand-logo">&nbsp{{ config('app.name', 'Laravel') }}</a>
                <a href="#" data-target="mobile-demo" class="sidenav-trigger"><i class="material-icons">menu</i></a>
                @guest
                    <ul class="right hide-on-med-and-down">
                        <li><a href="{{ route('login') }}">{{ __('Login') }}</a></li>
                        <li><a href="{{ route('register') }}">{{ __('Register') }}</a></li>
                    </ul>
                @else
                    <ul class="right hide-on-med-and-down">
                        <li><a class="dropdown-trigger" href="#!" data-target="dropdown1">{{ Auth::user()->name }}<i class="material-icons right">arrow_drop_down</i></a></li>
                    </ul>
                @endguest
            </div>
        </nav>


        <ul class="sidenav" id="mobile-demo">
            @guest
                <li><a href="{{ route('login') }}">{{ __('Login') }}</a></li>
                <li><a href="{{ route('register') }}">{{ __('Register') }}</a></li>
            @else
                <li><a href="{{ route('logout') }}" class="logout">{{ __('Logout') }}</a></li>
            @endguest
        </ul>

        @yield('content')
    </div>

    <!-- Scripts -->
    <script src="{{ asset('js/app.js') }}"></script>

    <script>
        $(document).ready(function(){
            $('.sidenav').sidenav();
            $('.dropdown-trigger').dropdown();
            $('.logout').click(function(e) {
                e.preventDefault();
                $('#logout-form').submit();
            });
        });
    </script>
</body>
</html>

J’ai changé essentiellement la barre de navigation.

Il faut maintenant modifier les autres vues…

Login

Voici la nouvelle vue de login (resources/views/auth/login.blade.php) :

@extends('layouts.app')

@section('content')
<div class="container">

    <div class="row">
        <div class="col s12 m10 offset-m1 l8 offset-l2">
            <div class="card">
            <form  method="POST" action="{{ route('login') }}">
                <div class="card-content">
                    {{ csrf_field() }}
                    <span class="card-title">{{ __('Login') }}</span>

                    <hr>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">mail</i>
                            <input id="email" type="email" name="email" value="{{ old('email') }}" class="{{ $errors->has('email') ? 'invalid' : '' }}" required autofocus>
                            <label for="email">{{ __('E-Mail Address') }}</label>
                            <span class="red-text">{{ $errors->has('email') ? $errors->first('email'): '' }}</span>
                        </div>
                    </div>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">lock</i>
                            <input id="password" type="password" name="password" class="{{ $errors->has('password') ? 'invalid' : '' }}" required>
                            <label for="password">{{ __('Password') }}</label>
                            <span class="red-text">{{ $errors->has('password') ? $errors->first('password'): '' }}</span>
                        </div>
                    </div>

                    <p>
                        <label>
                            <input type="checkbox" name="remember" id="remember" {{ old('remember') ? 'checked' : '' }}>
                            <span>{{ __('Remember Me') }}</span>
                        </label>
                    </p>

                    <p>
                        <button class="btn waves-effect waves-light right" type="submit" name="action">{{ __('Login') }}
                            <i class="material-icons right">lock_open</i>
                        </button>
                    </p>

                    <br>

                </div>
                <div class="card-action">
                    @if (Route::has('password.request'))
                        <a href="{{ route('password.request') }}">{{ __('Forgot Your Password?') }}</a>
                    @endif
                </div>
            </form>
        </div>
        </div>
    </div>
</div>
@endsection

Avec cet aspect :

C’est quand même présentable !

Register

Voici la nouvelle vue d’enregistrement (resources/views/auth/register.blade.php) :

@extends('layouts.app')

@section('content')
<div class="container">

    <div class="row">
        <div class="col s12 m10 offset-m1 l8 offset-l2">
            <div class="card">
            <form  method="POST" action="{{ route('register') }}">
                <div class="card-content">
                    {{ csrf_field() }}
                    <span class="card-title">{{ __('Register') }}</span>

                    <hr>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">person</i>
                            <input id="name" type="text" name="name" value="{{ old('name') }}" class="{{ $errors->has('name') ? 'invalid' : '' }}" required autofocus>
                            <label for="email">{{ __('Name') }}</label>
                            <span class="red-text">{{ $errors->has('name') ? $errors->first('name'): '' }}</span>
                        </div>
                    </div>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">mail</i>
                            <input id="email" type="email" name="email" value="{{ old('email') }}" class="{{ $errors->has('email') ? 'invalid' : '' }}" required>
                            <label for="email">{{ __('E-Mail Address') }}</label>
                            <span class="red-text">{{ $errors->has('email') ? $errors->first('email'): '' }}</span>
                        </div>
                    </div>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">lock</i>
                            <input id="password" type="password" name="password" class="{{ $errors->has('password') ? 'invalid' : '' }}" required>
                            <label for="password">{{ __('Password') }}</label>
                            <span class="red-text">{{ $errors->has('password') ? $errors->first('password'): '' }}</span>
                        </div>
                    </div>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">lock</i>
                            <input id="password-confirm" type="password" name="password_confirmation" required>
                            <label for="password-confirm">{{ __('Confirm Password') }}</label>
                        </div>
                    </div>

                    <p>
                        <button class="btn waves-effect waves-light" type="submit" name="action">{{ __('Register') }}
                            <i class="material-icons right">create</i>
                        </button>
                    </p>

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

Avec cet aspect :

Email

Voici la nouvelle vue de demande de renouvellement du mot de passe (resources/views/auth/passwords/email.blade.php) :

@extends('layouts.app')

@section('content')
<div class="container">

    @if (session('status'))
        <div class="card">
            <div class="card green darken-1">
                <div class="card-content white-text">
                    {{ session('status') }}
                </div>
            </div>
        </div>
    @endif

    <div class="row">
        <div class="col s12 m10 offset-m1 l8 offset-l2">
            <div class="card">

            <form  method="POST" action="{{ route('password.email') }}">
                <div class="card-content">
                    {{ csrf_field() }}
                    <span class="card-title">{{ __('Reset Password') }}</span>

                    <hr>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">mail</i>
                            <input id="email" type="email" name="email" value="{{ old('email') }}" class="{{ $errors->has('email') ? 'invalid' : '' }}" required autofocus>
                            <label for="email">{{ __('E-Mail Address') }}</label>
                            <span class="red-text">{{ $errors->has('email') ? $errors->first('email'): '' }}</span>
                        </div>
                    </div>

                    <p>
                        <button class="btn waves-effect waves-light right" type="submit" name="action">{{ __('Send Password Reset Link') }}
                            <i class="material-icons right">lock_open</i>
                        </button>
                    </p>

                    <br><br>

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

Avec cet aspect :

Reset

Voici la nouvelle vue de de renouvellement du mot de passe (resources/views/auth/passwords/reset.blade.php) :

@extends('layouts.app')

@section('content')
<div class="container">

    <div class="row">
        <div class="col s12 m10 offset-m1 l8 offset-l2">
            <div class="card">
            <form  method="POST" action="{{ route('password.update') }}">
                <div class="card-content">
                    {{ csrf_field() }}
                    <input type="hidden" name="token" value="{{ $token }}">

                    <span class="card-title">{{ __('Reset Password') }}</span>

                    <hr>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">mail</i>
                            <input id="email" type="email" name="email" value="{{ old('email') }}" class="{{ $errors->has('email') ? 'invalid' : '' }}" required>
                            <label for="email">{{ __('E-Mail Address') }}</label>
                            <span class="red-text">{{ $errors->has('email') ? $errors->first('email'): '' }}</span>
                        </div>
                    </div>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">lock</i>
                            <input id="password" type="password" name="password" class="{{ $errors->has('password') ? 'invalid' : '' }}" required>
                            <label for="password">{{ __('Password') }}</label>
                            <span class="red-text">{{ $errors->has('password') ? $errors->first('password'): '' }}</span>
                        </div>
                    </div>

                    <div class="row">
                        <div class="input-field col s12">
                            <i class="material-icons prefix">lock</i>
                            <input id="password-confirm" type="password" name="password_confirmation" required>
                            <label for="password-confirm">{{ __('Confirm Password') }}</label>
                        </div>
                    </div>

                    <p>
                        <button class="btn waves-effect waves-light" type="submit" name="action">{{ __('Reset Password') }}
                            <i class="material-icons right">lock_open</i>
                        </button>
                    </p>

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

Avec cet aspect :

Home

Pour finir maintenant le code pour la vue resources/views/home.blade.php :

@extends('layouts.app')

@section('content')
<div class="container">
  <div class="row">
    @if (session('status'))
        <div class="card green darken-1">
            <div class="card-content white-text">
                {{ session('status') }}
            </div>
        </div>
    @endif
      <div class="card red lighten-2">
        <div class="card-content white-text">

            <span class="card-title">Dashboard</span>

            You are logged in!
        </div>
      </div>
    </div>
</div>
@endsection

Avec cet aspect sur mobile :

La vérification de l’email

Laravel offre la possibilité de faire une vérification de l’email lorsque quelqu’un s’enregistre. Il faut le préciser dans les routes de l’authentification :

Auth::routes(['verify' => true]);

Ensuite on définit les routes à protéger, par exemple celle de la vue home :

Route::get('/home', 'HomeController@index')->name('home')->middleware('verified');

Enfin il faut dans le modèle User implémenter l’interface :

class User extends Authenticatable implements MustVerifyEmail

On va aussi adapter la vue resources/views/auth/verify.blade.php pour Materialize :

@extends('layouts.app')

@section('content')
<div class="container">
  <div class="row">
    @if (session('resent'))
        <div class="card green darken-1">
            <div class="card-content white-text">
                {{ __('A fresh verification link has been sent to your email address.') }}
            </div>
        </div>
    @endif
    <div class="card">
        <div class="card-content">
            <div class="card-title">{{ __('Verify Your Email Address') }}</div>
            <hr>
            {{ __('Before proceeding, please check your email for a verification link.') }}
            {{ __('If you did not receive the email') }}, <a href="{{ route('verification.resend') }}">{{ __('click here to request another') }}</a>.
        </div>
      </div>
    </div>
</div>
@endsection

Avec cet aspect :

Vous voyez que le changement de framework CSS n’est pas bien difficile mais il demande un peu d’attention au niveau de Laravel Mix si on veut profiter de cet outil…




Laravel et MDL

Material Design est un langage visuel mis au point par Google. Concernant la partie Material elle évoque le fait que le visuel se rapproche de la réalité matérielle : le papier, l’encre, les ombres… L’initiative avait pour objectif d’unifier leurs applications et de les rendre homogènes sur tous les supports.

Le Material Design c’est essentiellement des règles de design avec des formes simple et lisibles et des effets visuels pour renseigner l’utilisateur de ses actions. On trouve toutes les règles et explications sur le site concerné. C’est une lecture très saine et instructive !

Google nous offre de nombreux outils pour mettre en œuvre le Material Design mais le plus pratique est certainement Materiel Design Lite (MDL). C’est une librairie CSS et Javascript légère et facile à utiliser.

Je me suis dit qu’il serait intéressant de la mettre en œuvre avec Laravel.

Par défaut Laravel arrive avec Bootstrap et JQuery mais on n’est évidemment pas obligés de les utiliser. Dans cet article je montre comment transformer l’installation de base pour utiliser MDL.

Installation de Laravel

Si comme moi vous utilisez Laragon il est facile de créer rapidement une installation toute neuve de Laravel :

Sinon vous devez taper vous-même les commandes :

composer create-project laravel/laravel laravel-mdl --prefer-dist

Si tout se passe bien vous devez obtenir la page d’accueil :

On va ajouter les éléments pour l’authentification :

php artisan make:auth

On va aussi créer une base (Laragon s’en charge aussi). On renseigne le fichier .env pour pointer sur la bonne base :

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel-mdl
DB_USERNAME=root
DB_PASSWORD=

Et on lance les migrations par défaut :

php artisan migrate

On va aussi charger les librairies avec npm :

npm install

Voilà on a maintenant un Laravel tout neuf qu’on va pouvoir triturer…

Material Design Lite

On trouve tous les renseignements sur ce site. Pour mettre en œuvre MDL il suffit de faire appel aux librairies :

<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://code.getmdl.io/1.3.0/material.indigo-pink.min.css">
<script defer src="https://code.getmdl.io/1.3.0/material.min.js"></script>

Dans l’appel à la librairie CSS on choisit le thème coloré désiré. Et c’est tout !

Mais pour rendre le CSS plus facile à modifier pour nos besoins il vaut mieux utiliser la version Sass et la compiler avec mix. Pour utiliser la version Sass on nous explique la procédure ici. On ne va pas la suivre exactement pour Laravel.

Il faut télécharger le Web Starter Guide. Le seul fichier qui va nous intéresser est main.css :

Ensuite on télécharge Material Design Lite. Là on va récupérer tout ce qui se trouve dans le dossier src :

Dans l’installation de Laravel on a un dossier avec des fichiers Sass :

On supprime ces deux fichiers et on copie tout le contenu de material-design-lite-1.3.0/src (en ne conservant que les fichiers scss) ainsi que le fichier web-starter-kit-0.6.5/app/styles/main.css. Changez le nom de ce dernier fichier en app.scss. Changez le code de ce fichier pour  celui-ci :

@import "material-design-lite";

html, body {
  Roboto', 'Helvetica', sans-serif;
  margin: 0;
  padding: 0;
}

Vous devez vous retrouver finalement avec ça :

Au niveau du Javascript par défaut Laravel prévoit Bootstrap, JQuery, lodash, popper, et axios. On va se passer de tout ça en se contentant de la librairie Javascript de MDL.

Supprimez tout ce qui se trouve dans resources/js et copiez le fichier material-design-lite-1.3.0/material.js :

Dans le fichier webpack.mix.js changez ainsi le code :

mix.js('resources/js/material.js', 'public/js')
   .sass('resources/sass/app.scss', 'public/css');

Il ne reste plus qu’à relancer la compilation en mode développement :

npm run dev

Normalement vous avez un fichier public/css/app.css tout neuf ainsi qu’un nouveau fichier public/js/matrial.js (supprimez l’ancien app.js) :

Mais évidemment si la page d’accueil de Laravel demeure inchangée parce qu’indépendante du CSS global il n’en va pas de même de toutes les autres vues, par exemple pour le login :

Il faut maintenant reprendre toutes les vues pour MDL…

Le layout

On va commencer par mettre à jour le layout (resources/views/layouts/app.blade.php) :

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Fonts -->
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">

    <!-- Styles -->
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">

    <!-- Scripts -->
    <script src="{{ asset('js/material.js') }}" defer></script>

</head>
<body>

    <div class="mdl-layout mdl-js-layout mdl-layout--fixed-header">
        <header class="mdl-layout__header">
            <div class="mdl-layout__header-row">
            <!-- Title -->
            <span class="mdl-layout-title"><a href="{{ url('/') }}">{{ config('app.name', 'Laravel') }}</a></span>
            <!-- Add spacer, to align navigation to the right -->
            <div class="mdl-layout-spacer"></div>
            <!-- Navigation. We hide it in small screens. -->
            <nav class="mdl-navigation mdl-layout--large-screen-only">
                <!-- Authentication Links -->
                @guest
                    <a class="mdl-navigation__link" href="{{ route('login') }}">{{ __('Login') }}</a>
                    @if (Route::has('register'))
                        <a class="mdl-navigation__link" href="{{ route('register') }}">{{ __('Register') }}</a>
                    @endif
                @else
                    <button class="mdl-button mdl-js-button" id="logout">
                        {{ Auth::user()->name }}
                    </button>
                    <ul class="mdl-menu mdl-js-menu mdl-js-ripple-effect mdl-menu--bottom-right" for="logout">
                        <li class="mdl-menu__item"
                            onclick="event.preventDefault();
                            document.getElementById('logout-form').submit();">
                            {{ __('Logout') }}
                        </li>
                    </ul>
                    <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
                        @csrf
                    </form>
                @endguest
            </nav>
            </div>
        </header>
        <div class="mdl-layout__drawer">
            <span class="mdl-layout-title"><a href="{{ url('/') }}">{{ config('app.name', 'Laravel') }}</a></span>
            <nav class="mdl-navigation">

                <!-- Authentication Links -->
                @guest
                    <a class="mdl-navigation__link" href="{{ route('login') }}">{{ __('Login') }}</a>
                    @if (Route::has('register'))
                        <a class="mdl-navigation__link" href="{{ route('register') }}">{{ __('Register') }}</a>
                    @endif
                @else
                    <a class="mdl-navigation__link" href="{{ route('logout') }}"
                        onclick="event.preventDefault();
                        document.getElementById('logout-form').submit();">
                        {{ __('Logout') }}
                    </a>
                @endguest

            </nav>
        </div>

        @yield('content')

    </div>

</body>
</html>

On utilise le composant layout de MDL. Il permet de créer une barre de navigation :

On a juste un petit souci avec le titre Laravel qu’il faut un peu arranger. On ajoute ces règles dans resources/sass/app.scss :

.mdl-layout-title {
    a {
        color: inherit;
    }
    a:link {
        text-decoration: none;
    }
}

Relancez la compilation (ou mettez en mode watch pour que ce soit automatique).

Maintenant notre barre de navigation est parfaite.

On va créer un layout spécifique pour la partie authentification parce qu’on veut un centrage correct des formulaires, ce qui ne sera pas forcément le cas pour le reste du site :

Avec ce code :

@extends('layouts.app')

@section('content')

<div class="mdl-layout" id="main_layout">
    <main class="mdl-layout__content">
        <div class="page-content">
            @yield('authContent')
        </div>
    </main>
</div>

@endsection

Et ces règles dans resources/sass/app.scss :

#main_layout {

    align-items: center;
    justify-content: center;

    .mdl-layout__content {
        padding: 24px;
        flex: none;
    }

    .mdl-textfield__error {
        visibility: visible;
    }
}

Le login

On va maintenant changer le code pour la vue de login (resources/views/auth/login.blade.php) :

@extends('layouts.auth')

@section('authContent')

    <div class="mdl-card mdl-shadow--6dp">
        <div class="mdl-card__title mdl-color--primary mdl-color-text--white">
            <h2 class="mdl-card__title-text">{{ __('Login') }}</h2>
        </div>
        <div class="mdl-card__supporting-text">
            <form method="POST" action="{{ route('login') }}">
                @csrf

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="email" id="email" name="email" value="{{ old('email') }}" required autofocus>
                    <label class="mdl-textfield__label" for="email">{{ __('E-Mail Address') }}</label>
                    @if($errors->has('email'))
                        <span class="mdl-textfield__error">{{ $errors->first('email') }}</span>
                    @endif
                </div>

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="password" id="password" name="password" required>
                    <label class="mdl-textfield__label" for="password">{{ __('Password') }}</label>
                    @if($errors->has('password'))
                        <span class="mdl-textfield__error">{{ $errors->first('password') }}</span>
                    @endif
                </div>

                <label class="mdl-switch mdl-js-switch mdl-js-ripple-effect" for="remember">
                    <input type="checkbox" id="remember" class="mdl-switch__input" {{ old('remember') ? 'checked' : '' }}>
                    <span class="mdl-switch__label">{{ __('Remember Me') }}</span>
                </label>

                <button id="action-button" type="submit" class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-button--colored mdl-color-text--white">
                    {{ __('Login') }}
                </button>

            </form>
        </div>
        <div class="mdl-card__actions mdl-card--border">
            @if (Route::has('password.request'))
                <a class="mdl-button mdl-button--colored mdl-js-button mdl-js-ripple-effect" href="{{ route('password.request') }}">
                    {{ __('Forgot Your Password?') }}
                </a>
            @endif
        </div>
    </div>

@endsection

On utilise le composant Cards de MDL. D’autre part dans le formulaire on utilise le composant Text Fields.

Voilà le résultat bien centré dans la page :

On va juste arranger un peu le bouton :

#action-button {
    width: 100%;
    height: 40px;
    min-width: initial;
    margin: 20px 0 10px;
}

Maintenant c’est parfait !

On vérifie que les messages d’erreurs s’affichent bien :

L’enregistrement

On va faire la même chose pour la vue resources/views/auth/register.blade.php :

@extends('layouts.auth')

@section('authContent')

    <div class="mdl-card mdl-shadow--6dp">
        <div class="mdl-card__title mdl-color--primary mdl-color-text--white">
            <h2 class="mdl-card__title-text">{{ __('Register') }}</h2>
        </div>
        <div class="mdl-card__supporting-text">
            <form method="POST" action="{{ route('register') }}">
                @csrf

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="text" id="email" name="name" value="{{ old('name') }}" required autofocus>
                    <label class="mdl-textfield__label" for="name">{{ __('Name') }}</label>
                    @if($errors->has('name'))
                        <span class="mdl-textfield__error">{{ $errors->first('name') }}</span>
                    @endif
                </div>

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="email" id="email" name="email" value="{{ old('email') }}" required autofocus>
                    <label class="mdl-textfield__label" for="email">{{ __('E-Mail Address') }}</label>
                    @if($errors->has('email'))
                        <span class="mdl-textfield__error">{{ $errors->first('email') }}</span>
                    @endif
                </div>

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="password" id="password" name="password" required>
                    <label class="mdl-textfield__label" for="password">{{ __('Password') }}</label>
                    @if($errors->has('password'))
                        <span class="mdl-textfield__error">{{ $errors->first('password') }}</span>
                    @endif
                </div>

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="password" id="password_confirmation" name="password_confirmation" required>
                    <label class="mdl-textfield__label" for="password_confirmation">{{ __('Confirm Password') }}</label>
                </div>

                <button id="action-button" type="submit" class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-button--colored mdl-color-text--white">
                    {{ __('Register') }}
                </button>

            </form>
        </div>
    </div>

@endsection

On en profite pour créer un user :

On y arrive bien mais on a un petit souci avec la vue lorsqu’on est connecté.

Le nom apparaît bien mais il faudrait qu’il soit en blanc :

#logout {
    color: white;
}

Ensuite on change le code de la vue resources/views/home.blade.php :

@extends('layouts.auth')

@section('authContent')

    <div class="mdl-card mdl-shadow--6dp">
        <div class="mdl-card__title mdl-color--primary mdl-color-text--white">
            <h2 class="mdl-card__title-text">Dashboard</h2>
        </div>

        <div class="mdl-card__supporting-text">

            @if (session('status'))
                <div class="mdl-card__supporting-text mdl-color-text--primary">
                    {{ session('status') }}
                </div>
                <hr>
            @endif
            You are logged in!

        </div>
    </div>

@endsection

C’est maintenant plus présentable :

Pour le logout on a un menu déroulant :

Il est ajouté à la barre de navigation avec le composant Menus.

Le renouvellement du mot de passe

Pour le renouvellement du mot de passe on met à jour la vue resources/views/auth/passwords/email.blade.php :

@extends('layouts.auth')

@section('authContent')

    <div class="mdl-card mdl-shadow--6dp">
        <div class="mdl-card__title mdl-color--primary mdl-color-text--white">
            <h2 class="mdl-card__title-text">{{ __('Reset Password') }}</h2>
        </div>

        <div class="mdl-card__supporting-text">
            <form method="POST" action="{{ route('password.email') }}">
                @csrf

                @if (session('status'))
                    <div class="mdl-card__supporting-text mdl-color-text--primary">
                        {{ session('status') }}
                    </div>
                    <hr>
                @endif

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="email" id="email" name="email" value="{{ old('email') }}" required autofocus>
                    <label class="mdl-textfield__label" for="email">{{ __('E-Mail Address') }}</label>
                    @if($errors->has('email'))
                        <span class="mdl-textfield__error">{{ $errors->first('email') }}</span>
                    @endif
                </div>

                <button id="action-button" type="submit" class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-button--colored mdl-color-text--white">
                    {{ __('Send Password Reset Link') }}
                </button>

            </form>
        </div>
    </div>

@endsection

On vérifie que l’email part bien et qu’on a le message :

On change aussi le code de la vue pour modifier le mot de passe (resources/views/auth/passwords/reset.blade.php) :

@extends('layouts.auth')

@section('authContent')

    <div class="mdl-card mdl-shadow--6dp">
        <div class="mdl-card__title mdl-color--primary mdl-color-text--white">
            <h2 class="mdl-card__title-text">{{ __('Reset Password') }}</h2>
        </div>
        <div class="mdl-card__supporting-text">
            <form method="POST" action="{{ route('password.update') }}">
                @csrf

                <input type="hidden" name="token" value="{{ $token }}">

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="email" id="email" name="email" value="{{ old('email') }}" required autofocus>
                    <label class="mdl-textfield__label" for="email">{{ __('E-Mail Address') }}</label>
                    @if($errors->has('email'))
                        <span class="mdl-textfield__error">{{ $errors->first('email') }}</span>
                    @endif
                </div>

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="password" id="password" name="password" required>
                    <label class="mdl-textfield__label" for="password">{{ __('Password') }}</label>
                    @if($errors->has('password'))
                        <span class="mdl-textfield__error">{{ $errors->first('password') }}</span>
                    @endif
                </div>

                <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
                    <input class="mdl-textfield__input" type="password" id="password_confirmation" name="password_confirmation" required>
                    <label class="mdl-textfield__label" for="password_confirmation">{{ __('Confirm Password') }}</label>
                </div>

                <button id="action-button" type="submit" class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-button--colored mdl-color-text--white">
                    {{ __('Reset Password') }}
                </button>

            </form>
        </div>
    </div>

@endsection

Parfait !

La vérification de l’email

Laravel offre la possibilité de faire une vérification de l’email lorsque quelqu’un s’enregistre. Il faut le préciser dans les routes de l’authentification :

Auth::routes(['verify' => true]);

Ensuite on définit les routes à protéger, par exemple celle de la vue home :

Route::get('/home', 'HomeController@index')->name('home')->middleware('verified');

Enfin il faut dans le modèle User implémenter l’interface :

class User extends Authenticatable implements MustVerifyEmail

On va aussi adapter la vue resources/views/auth/verify.blade.php pour MDL :

@extends('layouts.auth')

@section('authContent')

    <div class="mdl-card mdl-shadow--6dp">
        <div class="mdl-card__title mdl-color--primary mdl-color-text--white">
            <h2 class="mdl-card__title-text">{{ __('Verify Your Email Address') }}</h2>
        </div>

        <div class="mdl-card__supporting-text">
            {{ __('Before proceeding, please check your email for a verification link.') }}
            {{ __('If you did not receive the email') }}, <a href="{{ route('verification.resend') }}">{{ __('click here to request another') }}</a>.
        </div>
    </div>

@endsection

Et c’est bon pour cette partie aussi !

Conclusion

On a vu qu’il n’est pas si difficile d’utiliser MDL avec Laravel. Les fichiers générés en production sont très légers :

D’autre part on dispose de tous les composants nécessaires pour construire un site plutôt esthétique. Évidemment il faudra ensuite certainement ajouter une librairie Javascript pour gérer efficacement le client, il serait alors dommage d’utiliser JQuery. On peut bien sûr opter pour Vue.js mais aussi se contenter d’une librairie légère comme RE:DOM.

Pour ceux qui n’arriveraient pas à construire l’application ou trop fainéants pour tout faire voici un zip du projet final.




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.