Plein la vue

image_pdfimage_print

Derrière ce titre un tantinet racoleur se cache une considération quotidienne : lorsqu’on crée un site on doit gérer beaucoup d’aspects. Côté serveur on peut diviser ça en deux parties :

  • la gestion purement PHP de l’application
  • la génération de la réponse

Si la première partie se gère de façon assez limpide grâce à Laravel il n’en est pas forcément pareil pour la seconde en particulier pour construire les vues dans le cas d’une application classique.

Le HTML et le CSS sont riches et variés, sans compter le nombre phénoménal de frameworks qui existent désormais. Alors on se retrouve à passer bien plus de temps sur cette partie que sur la première.

Le présent article est un exposé sur le sujet avec un passage en revue quelques solutions existantes en évaluant leur pertinence. C’est aussi le ferment d’une réflexion sur une hypothétique voie de clarification à même de simplifier le codage correspondant.

Une vue de base

Pour les besoins de la démonstration on va se contenter d’une installation de base de Laravel avec les éléments de l’authentification générés par php artisan make:auth.

Si on se rend sur la vue de login (…/login) on obtient cette aspect par défaut :

Laravel a construit la vue correspondante à partir d’un gabarit présent dans le framework, voici le code de la vue :

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <div class="panel panel-default">
                <div class="panel-heading">Login</div>

                <div class="panel-body">
                    <form class="form-horizontal" method="POST" action="{{ route('login') }}">
                        {{ csrf_field() }}

                        <div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
                            <label for="email" class="col-md-4 control-label">E-Mail Address</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control" name="email" value="{{ old('email') }}" required autofocus>

                                @if ($errors->has('email'))
                                    <span class="help-block">
                                        <strong>{{ $errors->first('email') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group{{ $errors->has('password') ? ' has-error' : '' }}">
                            <label for="password" class="col-md-4 control-label">Password</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control" name="password" required>

                                @if ($errors->has('password'))
                                    <span class="help-block">
                                        <strong>{{ $errors->first('password') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group">
                            <div class="col-md-6 col-md-offset-4">
                                <div class="checkbox">
                                    <label>
                                        <input type="checkbox" name="remember" {{ old('remember') ? 'checked' : '' }}> Remember Me
                                    </label>
                                </div>
                            </div>
                        </div>

                        <div class="form-group">
                            <div class="col-md-8 col-md-offset-4">
                                <button type="submit" class="btn btn-primary">
                                    Login
                                </button>

                                <a class="btn btn-link" href="{{ route('password.request') }}">
                                    Forgot Your Password?
                                </a>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

Par défaut elle utilise Bootstrap 3 et on trouve l’utilisation de la grille, un composant panel et une mise en forme du formulaire avec les classes qui vont bien.

Ce type de codage est le plus simple et direct qu’on puisse imaginer mais il est quand même assez laborieux parce qu’il implique de respecter toutes les règles de Bootstrap, de bien organiser la grille, de mettre en place les bonnes classes, de s’arranger pour que les erreurs de validations apparaissent…

Tout ce codage prend du temps, d’autant plus que la vue est chargée et complexe. Alors n’y a-t-il pas moyen de gagner du temps ?

LaravelCollective

Le premier package auquel on pense est celui de LaravelCollective. Je rappelle qu’historiquement cette fonctionnalité était intégrée à Laravel et a été supprimée avec la version 5 pour devenir un package indépendant.

L’installation est facile :

composer require laravelcollective/html

Ensuite le package est automatiquement reconnu par Laravel 5.5.

On trouve la documentation assez complète ici.

J’ai été longtemps un adepte de ce package puis j’ai fini par l’abandonner pour des raison que j’évoquerai plus loin.

Pour la vue de login la partie formulaire peut s’écrire ainsi grâce à ce package :

{!! Form::open(['route' => 'login', 'class' => 'form-horizontal']) !!}

    <div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
        {!! Form::label('email', 'E-Mail Address', ['class' => 'col-md-4 control-label']) !!}
        <div class="col-md-6">
            {!! Form::email('email', old('email'), ['class' => 'form-control', 'required' => true, 'autofocus' => true]) !!}

            @if ($errors->has('email'))
                <span class="help-block">
                    <strong>{{ $errors->first('email') }}</strong>
                </span>
            @endif
        </div>
    </div>

    <div class="form-group{{ $errors->has('password') ? ' has-error' : '' }}">
        {!! Form::label('password', 'Password', ['class' => 'col-md-4 control-label']) !!}
        <div class="col-md-6">
            {!! Form::password('email', ['class' => 'form-control', 'required' => true]) !!}

            @if ($errors->has('password'))
                <span class="help-block">
                    <strong>{{ $errors->first('password') }}</strong>
                </span>
            @endif
        </div>
    </div>  

    <div class="form-group">
        <div class="col-md-6 col-md-offset-4">
            <div class="checkbox">
                <label>
                    {!! Form::checkbox('remember', old('remember') ? 'checked' : '') !!} Remember Me
                </label>
            </div>
        </div>
    </div>  

    <div class="form-group">
        <div class="col-md-8 col-md-offset-4">
            {!! Form::submit('Login', ['class' => 'btn btn-primary']) !!}
            {!! link_to_route('password.request', $title = 'Forgot Your Password?', [], ['class' => 'btn btn-link']) !!}
        </div>
    </div>                                            

{!! Form::close() !!}

On peut encore affiner la syntaxe en créant des macros ou des composants.

Par exemple on pourrait déplorer la répétition du code pour les messages d’erreur de validation alors on crée un composant (view/components/validation-error.blade.php) :

@if ($errors->has($name))
    <span class="help-block">
        <strong>{{ $errors->first($name) }}</strong>
    </span>
@endif

On le déclare dans la méthode boot d’un provider :

Form::component('bsValidationError', 'components.validation-error', ['name']);

Et on peut l’utiliser dans le formulaire :

<div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
    {!! Form::label('email', 'E-Mail Address', ['class' => 'col-md-4 control-label']) !!}
    <div class="col-md-6">
        {!! Form::email('email', old('email'), ['class' => 'form-control', 'required' => true, 'autofocus' => true]) !!}
        {!! Form::bsValidationError('email') !!}   
    </div>
</div>

<div class="form-group{{ $errors->has('password') ? ' has-error' : '' }}">
    {!! Form::label('password', 'Password', ['class' => 'col-md-4 control-label']) !!}
    <div class="col-md-6">
        {!! Form::password('password', ['class' => 'form-control', 'required' => true]) !!}
        {!! Form::bsValidationError('password') !!} 
    </div>
</div>

On obtient quelque chose de plus concis. On pourrait pousser plus loin cette intégration mais vous avez je suppose saisit le principe…

Personnellement j’y perds en lisibilité et je préfère me passer de cette possibilité.

Bootstraper

Le package patricktalmadge/bootstrapper est une collection de classes qui permettent de générer tout ce qu’il faut pour Bootstrap. Voilà qui doit être intéressant pour notre vue !

On commence par l’installer :

composer require patricktalmadge/bootstrapper

Comme il n’est pas prévu un chargement automatique à la mode Laravel 5.5 il faut déclarer le provider :

Bootstrapper\BootstrapperL5ServiceProvider::class,

Et toutes les façades :

'Accordion' => 'Bootstrapper\Facades\Accordion',
...
'Thumbnail' => 'Bootstrapper\Facades\Thumbnail',

C’est un peu lourd mais bon…

On voit aussi qu’est installé comme dépendance le package laravelcollective/html dont j’ai parlé ci-dessus, on dispose donc de toutes ses possibilités enrichies par les nouvelles.

Par contre étant donné que LaravelCollective est lui chargé automatiquement il semble avoir la préséance pour la façade Form et ça pose un problème ! Pour mes essais j’ai « bloqué » la façade de LaravelCollective dans bootstrap/cache/packages.php.

On peut consulter l’abondante documentation.

Même en fouillant bien on ne trouve rien pour la grille, mais effectivement ce n’est sans doute pas évident à coder ce genre de chose…

Par contre on trouve une page pour les panels. La syntaxe est simple :

Panel::normal()
  ->withHeader('Normal')
  ->withBody('Panel body')
  ->footer('Panel footer')

Pour notre vue dans le header on a juste à mettre « Login » donc pas de souci. Par contre dans le body c’est une toute autre histoire… Transformer en chaîne de caractères tout le code correspondant, même allégé par LaravelCollective, est plutôt déprimant pour ne pas dire plus !

Si on n’avait rien de dynamique ça pourrait faire l’affaire, par exemple :

{!! Panel::normal()
   ->withHeader('Login')
   ->withBody('Mon body')
!!}

Mais dans notre cas ça nous donne plus de souci qu’autre chose…

Mais on trouve aussi ce qu’il faut pour générer un formulaire. Peut-être une voie…

Par rapport à LaravelCollective on dispose de la méthode horizontal :

{!! Form::horizontal(['route' => 'login']) !!}

C’est pas énorme mais toujours bon à prendre.

Pour le reste c’est pas vraiment intuitif et j’ai du mal à obtenir exactement ce que je veux. Par exemple pour les deux imputs :

{!! ControlGroup::generate(
    Form::label('email', 'E-Mail Address'),
    Form::email('email', old('email'), ['required' => true, 'autofocus' => true]),
    $errors->has('email') ? Form::help($errors->first('email')) : '',
    4
    )->withAttributes($errors->has('email') ? ['class' => 'has-error'] : [])
!!}

{!! ControlGroup::generate(
    Form::label('password', 'Password'),
    Form::password('password', old('password'), ['required' => true]),
    $errors->has('password') ? Form::help($errors->first('password')) : '',
    4
    )->withAttributes($errors->has('password') ? ['class' => 'has-error'] : [])
!!}

Je retrouve presque le même aspect, je n’arrive pas à imposer les 6 colonnes sur l’input mais pour le reste ça passe.

Pour le checkbox là j’ai bien bataillé mais rien à faire… En cherchant un peu j’ai trouvé qu’il valait mieux ne pas insister.

Pour le bouton c’est un peu pareil et autant ne rien toucher.

Voilà mon résultat en exploitant au maximum le package :

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            {!! Panel::normal()
                ->withHeader('Login')
                ->withBody(
                    Form::horizontal(['route' => 'login']) .
                    ControlGroup::generate(
                            Form::label('email', 'E-Mail Address'),
                            Form::email('email', old('email'), ['required' => true, 'autofocus' => true]),
                            $errors->has('email') ? Form::help($errors->first('email')) : '',
                            4
                            )->withAttributes($errors->has('email') ? ['class' => 'has-error'] : []) .
                    ControlGroup::generate(
                            Form::label('password', 'Password'),
                            Form::password('password', old('password'), ['required' => true]),
                            $errors->has('password') ? Form::help($errors->first('password')) : '',
                            4
                            )->withAttributes($errors->has('password') ? ['class' => 'has-error'] : []) .

                    '<div class="form-group">
                        <div class="col-md-6 col-md-offset-4">
                            <div class="checkbox">
                                <label>' .
                                    Form::checkbox('remember', old('remember') ? 'checked' : '') . ' Remember Me
                                </label>
                            </div>
                        </div>
                    </div>  

                    <div class="form-group">
                        <div class="col-md-8 col-md-offset-4">' .
                            Form::submit('Login', ['class' => 'btn btn-primary']) .
                            link_to_route('password.request', $title = 'Forgot Your Password?', [], ['class' => 'btn btn-link']) .
                        '</div>
                    </div>' .                                            

                    Form::close()
                    ) 
            !!}
        </div>
    </div>
</div>
@endsection

Franchement c’est pas très joli…

Je n’ai peut-être pas saisi toutes les subtilités du codage mais on ressent rapidement les limites de ce genre d’approche. On passe finalement pas mal de temps à contourner les limites et ça devient contre-productif.

Laravel Form Builder

Une autre approche est proposée par le package laravel-form-builder. Les éléments d’un formulaire sont créés au niveau du PHP et envoyés dans la vue.

Il faut l’installer :

composer require kris/laravel-form-builder

Il est ensuite automatiquement reconnu avec Laravel 5.5.

On a droit à une grosse documentation.

En gros on dispose d’une commande artisan :

php artisan make:form Forms/LoginForm --fields="email:email, password:password, remember:checkbox"

Et ça crée une classe :

Avec ce code :

<?php

namespace App\Forms;

use Kris\LaravelFormBuilder\Form;

class LoginForm extends Form
{
    public function buildForm()
    {
        $this
            ->add('email', 'email')
            ->add('password', 'password')
            ->add('remember', 'checkbox');
    }
}

je vais juste ajouter un bouton :

$this
    ->add('email', 'email')
    ->add('password', 'password')
    ->add('remember', 'checkbox')
    ->add('submit', 'submit', ['label' => 'Login']);

Ensuite dans le contrôleur :

public function showLoginForm(FormBuilder $formBuilder)
{
    $form = $formBuilder->create('App\Forms\LoginForm', [
        'method' => 'POST',
        'url' => route('login')
    ]);

    return view('auth.login', compact('form'));
}

En enfin dans la vue :

{!! form($form) !!}

Et on obtient cet aspect :

Avec ce code :

<form method="POST" action="http://monsite.org/login" accept-charset="UTF-8">
    <input name="_token" type="hidden" value="aQAQ3NznC0RbWAR6ZzkNCD1k8NDqeICguR2kEBSt">
    <div class="form-group"  >
        <label for="email" class="control-label">Email</label>
        <input class="form-control" name="email" type="email" id="email">
    </div>
    <div class="form-group">
        <label for="password" class="control-label">Password</label>
        <input class="form-control" name="password" type="password" id="password">
    </div>
    <div class="form-group">
        <input id="remember" name="remember" type="checkbox" value="1">
        <label for="remember" class="control-label">Remember</label>
    </div>
    <button class="form-control" type="submit">Login</button>
</form>

C’est pas si mal avec aussi peu d’effort !

Et la validation ?

C’est automatique ! Rien à coder !

Mais ce que j’aimerais c’est un peu de mise en forme maintenant. Par exemple :

public function buildForm()
{
    $this
        ->add('email', 'email', [
            'label' => 'E-Mail Address',
        ])
        ->add('password', 'password')
        ->add('remember', 'checkbox', [
            'label' => 'Remember Me',
        ])
        ->add('submit', 'submit', [
            'label' => 'Login',
            'attr' => ['class' => 'btn btn-primary'],
        ])
        ->add('forgot', 'static', [
            'label' => ' ',
            'tag' => 'a',
            'attr' => ['class' => 'btn btn-link pull-right'],
            'value' => 'Forgot Your Password?',
        ]);
}

On ne fait pas forcément ce qu’on veut avec facilité mais le concept est quand même intéressant d’autant qu’on peut combiner avec Bootstraper :

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            {!! Panel::normal() 
                ->withHeader('Login') 
                ->withBody(form($form)) 
            !!}
        </div>
    </div>
</div>
@endsection

Là c’est vraiment concis !

Blade

Maintenant voyons si finalement Blade n’est pas capable de gérer élégamment tout ça !

On sait qu’avec Blade on peu inclure une vue dans une autre, on appelle ça en général des vues partielles. Ce qui est gênant dans notre formulaire c’est la répétition du code ici :

<div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
    <label for="email" class="col-md-4 control-label">E-Mail Address</label>

    <div class="col-md-6">
        <input id="email" type="email" class="form-control" name="email" value="{{ old('email') }}" required autofocus>

        @if ($errors->has('email'))
            <span class="help-block">
                <strong>{{ $errors->first('email') }}</strong>
            </span>
        @endif
    </div>
</div>

<div class="form-group{{ $errors->has('password') ? ' has-error' : '' }}">
    <label for="password" class="col-md-4 control-label">Password</label>

    <div class="col-md-6">
        <input id="password" type="password" class="form-control" name="password" required>

        @if ($errors->has('password'))
            <span class="help-block">
                <strong>{{ $errors->first('password') }}</strong>
            </span>
        @endif
    </div>
</div>

Quand on rédige ce genre de code on en arrive forcément à copier et coller et ça c’est le mal !

Alors on peut créer une vue partielle (views/partials/input.blade.php) :

<div class="form-group{{ $errors->has($name) ? ' has-error' : '' }}">
    <label for="email" class="col-md-4 control-label">{{ $title }}</label>

    <div class="col-md-6">
        <input id="{{ $name }}" type="{{ $type }}" class="form-control" name="{{ $name }}" value="{{ old($name) }}" {{ $attributes }}>

        @if ($errors->has($name))
            <span class="help-block">
                <strong>{{ $errors->first($name) }}</strong>
            </span>
        @endif
    </div>
</div>

Et ensuite l’inclure deux fois dans le formulaire :

@include('partials.input', [
    'name' => 'email',
    'title' => 'E-Mail Address',
    'type' => 'email',
    'attributes' => 'required autofocus'
])

@include('partials.input', [
    'name' => 'password',
    'title' => 'Password',
    'type' => 'password',
    'attributes' => 'required'
])

C’est plus propre ainsi et c’est très lisible.

On peut pousser plus loin la granularité de cette approche. On peut ainsi imaginer un composant form :

<form class="{{ $class }}" method="POST" action="{{ $url }}">
    {{ csrf_field() }}
    {{  $slot }}
</form>

Un composant panel :

<div class="panel panel-default">
    <div class="panel-heading">{{ $title }}</div>
    <div class="panel-body">
        {{ $slot }}
    </div>
</div>

On peut ajouter une vue partielle checkbox :

<div class="form-group">
    <div class="col-md-6 col-md-offset-4">
        <div class="checkbox">
            <label>
                <input type="checkbox" name="{{ $name }}" {{ old($name) ? 'checked' : '' }}> {{ $title }}
            </label>
        </div>
    </div>
</div>

Et voici du coup la vue résultante :

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            @component('components.panel')
                
                @slot('title')
                    Login
                @endslot

                @component('components.form')

                    @slot('class')
                        form-horizontal
                    @endslot
                    
                    @slot('url')
                        route('login')
                    @endslot

                    @include('partials.input', [ 
                    	'name' => 'email', 
                    	'title' => 'E-Mail Address', 
                    	'type' => 'email', 
                    	'attributes' => 'required autofocus'
                    ]) 

                    @include('partials.input', [
                        'name' => 'password',
                        'title' => 'Password',
                        'type' => 'password',
                        'attributes' => 'required'
                    ])

                    @include('partials.checkbox', [
                        'name' => 'remember',
                        'title' => ' Remember Me',
                    ])

                    <div class="form-group">
                        <div class="col-md-8 col-md-offset-4">
                            <button type="submit" class="btn btn-primary">
                                Login
                            </button>

                            <a class="btn btn-link" href="{{ route('password.request') }}">
                                Forgot Your Password?
                            </a>
                        </div>
                    </div>

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

On peut trouver ça lisible… ou pas… c’est une question de préférence je pense.

J’ai découvert récemment un package qui offre des composants pour Bootstrap 4 et qui est présenté ici. Ils ont aussi un package intéressant de directives Blade.

Conclusion

Que faut-il retenir de ce petit tour d’horizon ? Que le sujet reste largement ouvert et que quelque chose est vraiment à inventer à ce niveau.

Selon les framework CSS il existe des éditeurs en ligne plus ou moins performants mais l’expérience m’a montré que c’est aussi bien (et souvent mieux) en piochant le code dans les documentations.

 

2 commentaires sur “Plein la vue

  1. « J’ai été longtemps un adepte de ce package puis j’ai fini par l’abandonner pour des raison que j’évoquerai plus loin »
    C’est ou, « plus loin » ? 🙂
    J’aime bien Laravel Collective, même les checkbox d’affichent bien, mais j’ai un gros problème avec ces checkbox *en édition*, qui la plupart du temps ne sont cochées même si la valeur est 1… mais parfois si (sur la même page), de façon très aléatoire… Je n’ai toujours pas compris compris pourquoi.

    1. Bonjour,

      Ça doit être trop loin pour être visible 🙂

      En fait je préfère ne pas trop empiler des couches intermédiaires et bien contrôler ce que je fais. Je m’y retrouve mieux en codage classique en jonglant au besoin avec des composants ou des vues à inclure.

Laisser un commentaire