Une liste de choix dynamique

image_pdfimage_print

On m’a demandé récemment comment créer simplement une liste de choix dynamique dans un formulaire avec Laravel. Je me suis dit de prime abord que ce devait être quelque chose de très simple et j’ai orienté cette personne vers des exemples existants, par exemple celui-ci. Je n’avais pas regardé de très près le code mais en y revenant je me suis aperçu d’une part qu’il était incorrect et que d’autre part il était incomplet.

Je me suis donc dit que ça serait peut-être une bonne chose de faire un article sur le sujet en présentant les problématiques rencontrées et une façon de les résoudre.

Une base d’exemple

Je suis parti des 3 tables de mon exemple Les Relations avec Eloquent. Je n’ai conservé que les trois tables : countries, cities et authors :

img59

Un pays a plusieurs villes et une ville a plusieurs auteurs.

J’ai sélectionné les migrations et seeds suffisants pour ces tables :

img60

Migration pour la table authors

<?php

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

class CreateAuthorsTable extends Migration {

	public function up()
	{
		Schema::create('authors', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->string('name')->unique();
			$table->integer('city_id')->unsigned();
		});
	}

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

Migration pour la table cities

<?php

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

class CreateCitiesTable extends Migration {

	public function up()
	{
		Schema::create('cities', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->string('name')->unique();
			$table->integer('country_id')->unsigned();
		});
	}

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

Migration pour la table countries

<?php

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

class CreateCountriesTable extends Migration {

	public function up()
	{
		Schema::create('countries', function(Blueprint $table) {
			$table->increments('id');
			$table->timestamps();
			$table->string('name')->unique();
		});
	}

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

Migration pour les clés étrangères

<?php

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

class CreateForeignKeys extends Migration {

	public function up()
	{
		Schema::table('cities', function(Blueprint $table) {
			$table->foreign('country_id')->references('id')->on('countries')
						->onDelete('cascade')
						->onUpdate('cascade');
		});
		Schema::table('authors', function(Blueprint $table) {
			$table->foreign('city_id')->references('id')->on('cities')
						->onDelete('cascade')
						->onUpdate('cascade');
		});
	}

	public function down()
	{
		Schema::table('cities', function(Blueprint $table) {
			$table->dropForeign('cities_country_id_foreign');
		});
		Schema::table('authors', function(Blueprint $table) {
			$table->dropForeign('authors_city_id_foreign');
		});
	}
}

Population des tables

<?php

use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;

class DatabaseSeeder extends Seeder {

    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        for ($i = 1; $i < 11; $i++) {
            DB::table('countries')->insert(['name' => 'Country ' . $i]);
        }
        for ($i = 1; $i < 21; $i++) {
            DB::table('cities')->insert(['name' => 'City ' . $i, 'country_id' => rand(1, 10)]);
        }
        for ($i = 1; $i < 21; $i++) {
            DB::table('authors')->insert(['name' => 'Author ' . $i, 'city_id' => rand(1, 20)]);
        }
    }

}

Lancez migrations et seed (php artisan migrate –seed), vous devriez avoir ces 4 tables :

img61

Elles devraient être garnies de façon aléatoire pour les clés étrangères avec des incohérences (une ville peut se retrouver dans deux pays) mais ce n’est pas important pour l’exemple.

Les modèles

Les modèles sont rangés dans un dossier app/Models :

img62

Il faut créer les modèles avec les relations correctes.

Le modèle Country

<?php namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Country extends Model {

	/**
	 * The fillable attributes.
	 *
	 * @var string
	 */
	public $fillable = ['name'];

	/**
	 * Has Many relation
	 *
	 * @return Illuminate\Database\Eloquent\Relations\hasMany
	 */
	public function cities()
	{
		return $this->hasMany('App\Models\City');
	}

	/**
	 * Has Many Through relation
	 *
	 * @return Illuminate\Database\Eloquent\Relations\hasManyThrough
	 */
	public function authors()
	{
		return $this->hasManyThrough('App\Models\Author', 'App\Models\City');
	}

}

Le modèle City

<?php namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class City extends Model {

	/**
	 * The fillable attributes.
	 *
	 * @var string
	 */
	public $fillable = ['name', 'country_id'];

	/**
	 * One to Many relation
	 *
	 * @return Illuminate\Database\Eloquent\Relations\BelongsTo
	 */
	public function country()
	{
		return $this->belongsTo('App\Models\Country');
	}

	/**
	 * Has Many relation
	 *
	 * @return Illuminate\Database\Eloquent\Relations\hasMany
	 */
	public function authors()
	{
		return $this->hasMany('App\Models\Author');
	}

}

Le modèle Author

<?php namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Author extends Model {

	public $fillable = ['name', 'city_id'];

	/**
	 * One to Many relation
	 *
	 * @return Illuminate\Database\Eloquent\Relations\BelongsTo
	 */
	public function city()
	{
		return $this->belongsTo('App\Models\City');
	}

}

Le problème

On veut réaliser le formulaire de modification des auteurs qui se présente ainsi :

img63

Au chargement du formulaire on doit avoir :

  • le nom de l’auteur
  • la liste complète des pays et le bon pays sélectionné
  • la liste des villes du pays sélectionné avec la ville de l’auteur sélectionné.

Si on change de pays la liste des villes doit se synchroniser avec le nouveau pays sélectionné. C’est l’objet même de cet article. On va procéder en Ajax pour actualiser cette liste.

Tout ça n’est pas trop compliqué à réaliser mais… il y a aussi la validation à prendre en compte !

Si il y a un souci de validation le formulaire est renvoyé et là on aimerait bien se retrouver avec :

  • le nom de l’auteur s’il a changé, ça c’est du classique
  • le pays sélectionné, ça c’est déjà moins classique
  • la ville sélectionnée avec toutes les villes du pays dans la liste, ça ça va nous poser un problème un peu plus délicat encore parce que la liste est générée dynamiquement.

Les routes

On commence par le plus simple, les routes :

Route::get('cities/{id}', 'TestController@cities');

Route::group(['middleware' => 'web'], function () {

 Route::resource('test', 'TestController', ['only' => [
 'edit', 'update'
 ]]);

});

On crée une route pour répondre à la synchronisation des villes : cities/{id}, avec l’id du pays. Notez que je n’ai pas mis cette route dans le groupe du middleware « web », on peut la considérer comme une API. On pourrait d’ailleurs utiliser le middleware « api » pour l’occasion.

On crée une ressource avec juste edit et update.

Si tout se passe bien ça donne ça :

img64

Le contrôleur

Voici le contrôleur :

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

use App\Http\Requests;
use App\Http\Controllers\Controller;

use App\Models\Country;
use App\Models\City;
use App\Models\Author;

class TestController extends Controller
{
    /**
     * Show the form for editing the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function edit($id)
    {
        // Récupération des informations pour le formulaire
        $author = Author::with('city.country')->find($id);
        $countries = Country::all();

        // Envoi du formulaire
        return view('edit', compact('author', 'countries'));
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, $id)
    {
        // Validation
        $this->validate($request, [
            'name' => 'required|max:255'
        ]);

        // Mise à jour de l'auteur
        $author = Author::find($id);
        $author->name = $request->name;
        $author->city_id = $request->city;
        $author->save();

        // Redirection sur le formulaire
        return redirect(route('test.edit', $id))->with('success', 'L\'auteur a bien été mis à jour !');
    }

    /**
     * Get country's cities.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function cities($id)
    {
        // Retour des villes pour le pays sélectionné 
        return City::whereCountryId($id)->get();
    }   
}

On a 3 méthodes pour les 3 routes.

edit

Là il faut récupérer les informations dans la base :

$author = Author::with('city.country')->find($id);
$countries = Country::all();

L’auteur avec le pays parce qu’on a besoin de l’id de celui-ci.

Tous les pays pour remplir la liste.

On envoie tout ça dans la vue edit :

return view('edit', compact('author', 'countries'));

update

Là on commence par la validation :

$this->validate($request, [
    'name' => 'required|max:255'
]);

J’ai juste demandé la présence du nom et limité sa taille à 255 caractères.

Si la validation passe on met à jour l’auteur :

$author = Author::find($id);
$author->name = $request->name;
$author->city_id = $request->city;
$author->save();

Et pour finir on redirige sur le même formulaire avec un message en session flash :

return redirect(route('test.edit', $id))->with('success', 'L\'auteur a bien été mis à jour !');

cities

Là c’est la partie API, on renvoie toutes les villes qui correspondent à l’id du pays transmis :

return City::whereCountryId($id)->get();

Vous voyez que côté Laravel on garde l’esthétique propre à ce framework !

Les vues

On va avoir un template et une vue :

img65

Le template

Là c’est du grand classique :

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Test de liste dynamique</title>

    <link href="https://fonts.googleapis.com/css?family=Lato:100,300,400,700" rel='stylesheet' type='text/css'>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" rel="stylesheet">

    <style>
        body { font-family: 'Lato'; }
    </style>
</head>

<body>
    <br>
    @yield('content')

    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>

    @yield('scripts')
</body>
</html>

La vue

C’est là que se joue le plus délicat. Voici le code complet avec le Javascript intégré :

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-10 col-md-offset-1">
            <div class="panel panel-default">
                <div class="panel-heading">Edition d'un auteur</div>

                <div class="panel-body">
                    @if (session()->has('success'))
                        <div class="alert alert-success" role="alert">{{ session('success') }}</div>
                    @endif
                    <form method="POST" action="{{ route('test.update', $author->id )}} " accept-charset="UTF-8" class="form-horizontal panel">
                        
                        {!! csrf_field() !!}
                        <input name="_method" type="hidden" value="PUT">

                        <div class="form-group ">
                            <label for="name" class="col-md-4 control-label">Nom :</label>
                            <div class="col-md-6">
                                <input class="form-control" name="name" type="text" id="name" value="{{ old('name', $author->name) }}">
                                @if ($errors->has('name'))
                                    <span class="help-block">
                                        <strong>{{ $errors->first('name') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>
                
                        <div class="form-group">
                            <label for="country" class="col-md-4 control-label">Pays :</label>
                            <div class="col-md-6">
                                <select name="country" id="country" class="form-control">
                                    @foreach($countries as $country)
                                        <option value="{{ $country->id }}">{{ $country->name }}</option>
                                    @endforeach
                                </select>
                            </div>
                        </div>

                        <div class="form-group">
                            <label for="city" class="col-md-4 control-label">Ville :</label>
                            <div class="col-md-6">
                                <select name="city" id="city" class="form-control"></select>
                            </div>
                        </div>

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

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

@section('scripts')
<script>
$(function() {

    // Récupération des id pour pays et ville
    var country_id = {{ old('country', $author->city->country->id) }};
    var city_id = {{ old('city', $author->city->id) }};

    // Sélection du pays
    $('#country').val(country_id).prop('selected', true);
    // Synchronisation des villes
    cityUpdate(country_id);

    // Changement de pays
    $('#country').on('change', function(e) {
        var country_id = e.target.value;
        city_id = false;
        cityUpdate(country_id);
    });

    // Requête Ajax pour les villes
    function cityUpdate(countryId) {
        $.get('{{ url('cities') }}/'+ countryId + "'", function(data) {
            $('#city').empty();
            $.each(data, function(index, cities) {
                $('#city').append($('<option>', { 
                    value: cities.id,
                    text : cities.name 
                }));
            });
            if(city_id) {
                $('#city').val(city_id).prop('selected', true);
            }
        });
    }
    
});
</script>
@endsection

J’ai utilisé du Html classique pour cet exemple sans passer par LaravelCollective.

Le remplissage des pays se fait directement avec Blade :

<select name="country" id="country" class="form-control">
    @foreach($countries as $country)
        <option value="{{ $country->id }}">{{ $country->name }}</option>
    @endforeach
</select>

Par contre les villes sont vides au départ puisqu’on va les remplir de façon dynamique :

<select name="city" id="city" class="form-control"></select>

Le Javascript

Au chargement il nous faut les id du pays et de la ville, soit directement issus de la base, soit les anciennes valeurs issues de la validation :

var country_id = {{ old('country', $author->city->country->id) }};
var city_id = {{ old('city', $author->city->id) }};

Ensuite on sélectionne le pays et on commande la synchronisation des villes :

// Sélection du pays
$('#country').val(country_id).prop('selected', true);
// Synchronisation des villes
cityUpdate(country_id);

Synchronisation des villes

Voyons la routine de synchronisation des villes :

// Requête Ajax pour les villes
function cityUpdate(countryId) {
    $.get('{{ url('cities') }}/'+ countryId + "'", function(data) {
        $('#city').empty();
        $.each(data, function(index, cities) {
            $('#city').append($('<option>', { 
                value: cities.id,
                text : cities.name 
            }));
        });
        if(city_id) {
            $('#city').val(city_id).prop('selected', true);
        }
    });
}

On a une classique requête GET gérée avec jQuery. On commence par vider la liste des villes :

$('#city').empty();

Puis avec une boucle on crée la nouvelle liste :

$.each(data, function(index, cities) {
    $('#city').append($('<option>', { 
        value: cities.id,
        text : cities.name 
    }));
});

Pour finir si on est en situation de retour de validation on sélectionne la bonne ville :

if(city_id) {
    $('#city').val(city_id).prop('selected', true);
}

Changement de pays

Si on change de pays il faut aussi commander la synchronisation des villes :

$('#country').on('change', function(e) {
    var country_id = e.target.value;
    city_id = false;
    cityUpdate(country_id);
});

Et tout devrait fonctionner !

Conclusion

Ce n’est pas la seule façon de traiter cette situation mais elle me paraît simple et lisible. Une autre façon plus élégante consisterait à faire le traitement de synchronisation uniquement côté client mais ça imposerait d’envoyer aussi toutes les villes dès le départ. Il faudrait alors peut-être oublier jQuery et adopter par exemple Vue.js.

 

8 commentaires sur “Une liste de choix dynamique

  1. Bonjour,
    J’essaye de vous contacter pour vous demander de l’aide. Je n’ai pas trouver de formulaire de contact. Je pense donc que cette article est le lieu tout indiquer pour vous posez ma question.

    Voila, je développe depuis un mois, enfin j’essaye, un site web de gestion de base de données de façon totalement gratuite (je précise au cas ou). J’ai commencer par apprendre le SQL pour faire la base, qui soit dit en passant est une sacrée usine a gaz. Cette partie la est ok, enfin je crois…

    Puis pour la suite j’ai choisi d’utiliser Laravel et grâce a toute vos contributions, principalement, j’arrive petit a petit à comprendre et à faire le site web. Des fois c’est pas bien claire mais en avançant je finis par comprendre un peu tout les rouages et fonctionnement. J’ai utilisé InfyOm pour m’aider dans la génération des différents fichiers (Modéle, vue, controller, repository, request). InfyOm m’aide énormément mais il ne fait pas tout. D’ailleurs j’ai eu un problème car il faut avec le paquage L5-repository que la clef primaire des tables s’apelle obligatoirement id sinon ça ne marche pas… mais bref la n’est pas mon problème.

    Mon problème le voici, enfin ce n’est pas a proprement parler un problème car ce que j’ai fait fonctionne. Ce qu’il y a c’est que je ne sait pas si c’est la bonne façon de faire. Alors voila pour le moment je m’occupe de la gestion des relations 1:n. InfyOm dans les vues de création ne gère pas le fait de venir chercher une ligne correspondante dans la table en relation. Par exemple, j’ai ma table principale (huile_essentielle) qui est lier à une autre table qui sont des images. Il faut donc que dans ma vue de création d’une huile essentielle j’aille chercher une image présente dans la table image. InfyOm m’a simplement proposé un champ numerique qui correspond au type de l’id de la table image. Ce n’est pas bon. Ce que j’ai donc fait c’est dans mon controleur d’huile essentielle dans la methode create fait cela pour passer la liste d’image a ma vue :

    use App\Models\Image;
    …..
    public function create()
    {
    $images = Image::lists(‘NOM’, ‘id’);
    return view(‘huile_essentielles.create’, compact(‘images’));
    }

    Évidemment cela fonctionne, mais étant donner que j’ai un ImageRepository est ce que je ne devrais pas plutôt passer par lui pour récupérer ma liste d’image ? Et si oui, comment faire ? Je suis un peu perdu sur cette question…

    Je vous remercie par avance de bien vouloir m’éclairer de vos compétences et en profite pour vous remercier de toutes vos contributions ici et là qui me sont d’une précieuse aide.

    Au plaisir de vous lire.

    Sparrow

    1. Bonjour,

      L’organisation du code est toujours une source de nombreuses questions mais ce qui doit guider est le pragmatisme. A mes yeux un contrôleur se doit d’être simple et faire appel à des ressources, comme par exemple un repository, lorsque le code devient un peu chargé, ou alors si les sources de données peuvent changer de nature ou d’origine, dans ce cas évidemment il vaut mieux que le contrôleur en sache le moins possible pour éviter des modifications laborieuses.

      Dans le cas présent il serait un peu vain d’aller dire à un repository « donne moi la liste des images » alors qu’on peut l’avoir avec une simple ligne de code et qu’on a peu de risque de changer le modèle concerné.

      J’avais regardé InfyOm et j’ai d’ailleurs écrit un article sur le sujet l’an dernier mais je ne l’ai jamais utilisé sur un projet parce que je préfère maîtriser tout le code. Si on gratte un peu ses possibilités on doit certainement trouver une méthode lists dans BaseRepository mais je ne pense pas qu’elle apporte vraiment une plus-value.

      Il faut donc voir de quelle manière utiliser intelligemment ce genre de package. Tant qu’il apporte un plus alors on peut s’en servir, mais si ça ajoute de la complexité alors autant rester sur des choses simples.

      1. Bonjour,
        Je vous remercie beaucoup pour votre réponse. C’est très clair. Et oui effectivement pour cette partie là, c’est à dire simplement le CRUD de chacune des tables, je pense que mon bout de code fait l’affaire. Mais bientôt je vais avoir à construire des vues beaucoup plus complexe avec des jointures et des critères de recherche pointu (je suis pas sortie de l’auberge). Et si j’ai bien compris je vais pouvoir faire cela avec des « criteria ». Je vous avoue que pour le moment niveau conceptuelle je ne sais pas du tout comment je vais m’y prendre. Je vais déjà gérer le CRUD table par table et ensuite on verra. Mais voila je m’interroge quand même sur le comment faire si je veux passer par le repository… j’ai vraiment un niveau très bas en programmation !
        1/ Je pense que pour commencer je doit l’inclure en haut de mon contrôleur avec un use.
        2/ Ensuite il est dit dans la doc que le repository de base inclue un certain nombre de fonction. La fonction ici est simplement all($columns = array(‘*’)) . Mais je ne sais même pas comment l’appeler. Sur qu’elle objet ? quand on est directement dans le controleur on a une variable :

        /** @var HuileEssentielleRepository */
        private $huileEssentielleRepository;

        Et on fait des appelles dessus comme ça :
        1/ dans le constructeur : $this->huileEssentielleRepository = $huileEssentielleRepo;
        2/ et ensuite fonction index par exemple :

        public function index(Request $request)
        {
        $this->huileEssentielleRepository->pushCriteria(new RequestCriteria($request));
        $huileEssentielles = $this->huileEssentielleRepository->all();

        return view(‘huile_essentielles.index’)
        ->with(‘huileEssentielles’, $huileEssentielles);
        }

        Mais si dans ce même contrôleur je dois faire appelle a un autre repository (ImageRespositoty par exemple). Comment je dois faire ?

        En vous remerciant

    1. Bonjour,

      Tous les fichiers pour les migrations doivent se placer dans le dossier database/migrations. Pour les clé étrangères on peut les mettre dans un fichier à part comme je le fais pour cet article, en le nommant par exemple 2017_06_18_145916_create_foreign_keys.php (avec une date adaptée). Dans ce cas il faut faire attention à l’ordre des migrations pour que les colonnes existent déjà effectivement.

      Cordialement

  2. Bonjour, salutations.

    Merci pour le tutoriel.

    Lorsque les données dans la base de données est 0 ou NULL donne l’erreur suivante: ErrorException essayant d’obtenir la propriété de non-objet (Voir: edit.blade.php) Line 30

    Une idée?

    Sauf que tout détail fonctionne très bien.

    mes scripts sont les suivants:

    $(function () {

    var country = {{ old(‘country’, $person->city->state->country->id) }}; //LINE 30
    var state = {{ old(‘state’, $person->city->state->id) }}; //LINE 31
    var city = {{ old(‘city’, $person->city->id) }}; //LINE 32

    $(‘#country_id’).val(country).prop(‘selected’, true);
    stateUpdate(country);

    $(‘#state_id’).val(state).prop(‘selected’, true);
    cityUpdate(state);

    $(‘#country_id’).on(‘change’, function(e) {
    var country = e.target.value;
    state = false;
    city = false;
    stateUpdate(country);
    cityUpdate(state);
    });

    $(‘#state_id’).on(‘change’, function(e) {
    var state = e.target.value;
    city = false;
    cityUpdate(state);
    });

    function stateUpdate(countryID) {
    $.get(‘{{ url(‘updateState’) }}/’ + countryID + « ‘ », function(data) {
    $(‘#state_id’).empty();
    $(« #state_id »).append(‘{{trans(‘translate.select’)}}’);
    $.each(data, function (index, states) {
    $(‘#state_id’).append($( », {
    value: states.id,
    text: states.state
    }));
    });
    if (state) {
    $(‘#state_id’).val(state).prop(‘selected’, true);
    }
    });
    }

    function cityUpdate(stateID) {
    $.get(‘{{ url(‘updateCity’) }}/’ + stateID + « ‘ », function(data) {
    $(‘#city_id’).empty();
    $(« #city_id »).append(‘{{trans(‘translate.select’)}}’);
    $.each(data, function (index, cities) {
    $(‘#city_id’).append($( », {
    value: cities.id,
    text: cities.city
    }));
    });
    if (city) {
    $(‘#city_id’).val(city).prop(‘selected’, true);
    }
    });
    }

    });

    1. Bonjour,

      Je n’ai pas considéré ce cas dans mon code en partant du principe que les tables sont normalement informées sans trou. Si c’est le cas il faut prévoir un test soit au niveau du contrôleur, soit dans la vue. Tout dépend également où si tituent ces valeurs nulles.

Laisser un commentaire