Créer un formulaire dynamique avec Angular 2 et Spring

Nous allons voir comment ajouter des champs dynamiques dans un formulaire avec Angular 2. L’idée est de pouvoir, dans un formulaire contenant des champs statiques, ajouter des champs supplémentaires et configurables (que nous appellerons extra fields). C’est un scénario qui peut se présenter assez souvent notamment lorsque l’on souhaite laisser à l’utilisateur la discrétion de configurer ses propres champs ou si l’on veut une configuration différente par utilisateur.

L’environnement technique

Stack

Les composants techniques sont les suivants :

Partie serveur:

  • Spring MVC 4
  • Spring Boot
  • Hibernate 4
  • Annotations JPA
  • Base de donée H2 (in memory)
  • Jackson pour le parsing JSON

 Partie cliente:

  •   AngularJS 2.0.0 RC 1
  •   Typescript 1.8.9

La communication est faite via des webservices REST entre la partie front office (AngularJS) et la partie serveur (Spring) notamment grâce à Spring MVC.

Sources

Vous pouvez récupérer les sources du projet d’exemple sur notre github : Angular dynamic form

Installation

  • Aller dans le dossier src/main/webapp et redescendre les dépendances npm grâce à la commande npm install 
  • Pour lancer le serveur : mvn spring-boot:run puis ouvrir un navigateur à l’adresse : http://localhost:8080

Présentation

Notre application consiste à pouvoir gérer une liste de clients, ainsi si l’on devait rapidement décrire une spécification de ce que l’on attend :

  • Un écran qui affiche la liste des clients
  • Un formulaire pour modifier un client
  • Un formulaire d’ajout d’un client
  • Un affichage dynamique des extra fields (dans notre formulaire client) avec le bon type : Ex: input, textarea, select, field, etc..
  • Afficher la valeur par défaut de l’extra field si elle existe (en mode création et en mode édition si pas de valeur).
  • Gérer la validation du formulaire en prenant en compte les contraintes des champs dynamiques : Ex: Le formulaire n’est pas valide si un extra field est marqué required et est non renseigné.
  • Gérer les champs en lecture seule.
  • Lors de la sauvegarde de mon formulaire, les valeurs des extra fields doivent également être sauvegardées en base.
  • Lorsque je charge un client, les valeurs des extra fields préalablement renseignées doivent être affichées.

On pourrait bien entendu imaginer d’autres fonctionnalités mais on va se contenter de mettre en place celles-ci pour notre article.

Mais tout d’abord où et comment définir ces fameux champs dynamiques ? C’est coté serveur que ça se passe et il nous faut dans un premier temps rendre possible la configuration des champs.

Nous avons pris le parti d’utiliser un fichier JSON pour décrire la structure de nos champs car c’est un format universel, connu de tous et facilement exploitable même par un non initié.

Workflow

dynamic_form_angular

 

Les principales étapes sont les suivantes:

  • Création d’un fichier JSON pour chaque entité sur laquelle on souhaite rajouter des champs supplémentaires
  • Au lancement du serveur, on parse les fichiers Json en Java et on stocke le résultat en cache.
  • On crée un webservice rest pour pouvoir récupérer la liste des champs d’une entité. Ex: /customers/form
  • Au chargement d’un formulaire coté client, on appelle le webservice /customers/form et on ajoute dynamiquement dans le DOM les composants HTML
  • A la soumission d’un formulaire les données envoyées sont similaires à toute requête de type POST ou PUT à l’exception de tous les champs supplémentaires dont les valeurs sont regroupés dans la propriété extraFields. Ex: {« extraFields »:{« age »: »28″, »email »: »defaultemail@redfroggy.fr »}, »id »:1, »firstName »: »Jack », »lastName »: »Bauer »} 

Architecture projet

La structure de notre projet est la suivante:

Regardons de plus près la structure de notre fichier JSON dont le but est de décrire les champs que l’on souhaite ajouter à notre formulaire.

Descriptif du formulaire

Fichier JSON

Pour rappel l’idée est de créer un fichier JSON par entité JPA que l’on souhaite « étendre » avec des extra fields. Dans notre cas, nous avons un fichier: customer.json qui va étendre l’entité Customer.

Nom Description
entityName Nom de l’entité JPA correspondante
version Numéro de version, pourrait être utilisé pour implémenter un versionning du champ
fields Liste des champs
id Identifiant du champ
type Type html du champ: text, email, url, file, select, etc..
name Nom du champ html: Sera utilisé comme valeur des attributs id et name
value Valeur par défaut du champ
label Libellé du champ
required Champs requis ou non
minLength Taille minimum autorisée à la saisie. Uniquement pour un type text
maxLength Taille maximum autorisée à la saisie. Uniquement pour un type text.
placeholder Placeholder du champ. Si non renseigné, le label sera utilisé
pattern Pattern de validation à respecter lors de la saisie. Uniquement pour les input. Prendre comme valeur une expression régulière.
options Liste des valeurs d’une liste déroulante. Composé de deux propriétés: id et value
writable Champ en lecture seule si writable = false
min Valeur numérique minimum autorisée. Uniquement pour un input de type number
max Valeur numérique maximum autorisée. Uniquement pour un input de type number

Jeux de données

Les données sont chargées au lancement du serveur dans une base de données H2 in-memory. Ce qui veut dire qu’au lancement du serveur les données sont réinitialisées. C’est dans le fichier Application.java que l’on charge la liste des Customers.

Parsing JSON

Au lancement du serveur, l’idée est de lire tous les fichiers .json présent dans le dossier resources et de les parser en Java. C’est la classe ExtraFieldConfiguration qui s’en charge.

  • @Configuration: Indique à Spring qu’il s’agit d’une classe de configuration et elle sera donc chargée au lancement du serveur.
  • @PostContruct: Permet d’appeler la méthode init() une fois que le contexte Spring est opérationnel.
  • ApplicationContext: Contexte applicatif qui va nous servir à charger nos ressources JSON

Dans la méthode init, on liste les fichiers json présents et on les parse en objet Form grâce à la librairie Jackson:

Grâce à la classe FormUtils, on ajoute le formulaire à une liste statique nommée forms. Tous nos champs extraFields sont donc désormais accessibles à travers cette liste statique et nous allons pouvoir les exploiter notamment à travers des webservices.

Webservices

Grâce à Spring MVC, on expose un certain nombre de webservices REST permettant:

  • De lister les customers
  • De récupérer un customer par son identifiant
  • De créer un customer
  • De modifier un customer
  • De récupérer le descriptif « formulaire » de l’entité Customer

C’est dans la classe CustomerResource que ces webservices sont déclarés.  Rien de très technique mais arrêtons-nous un instant sur le webservice getForm :

Le webservice fait appel au service CustomerService et à la méthode getForm:

La classe FormUtils est une classe utilitaire et la méthode describe à deux fonctions :

  • Fait de la « reflection » sur les champs natifs d’une entité afin de retourner le descriptif de ces champs.
  • Lit la variables forms dans laquelle sont stockés l’ensemble des fichiers JSON parsés

Voici un exemple de JSON retourné par le webservice :

Affichage du formulaire

Une fois le descriptif du formulaire récupéré via le webservice /customers/form il nous reste à l’afficher dynamiquement. Mais avant cela commençons déjà par présenter la structure Angular 2.

Point d’entrée

Le fichier index.html référence un fichier init.js qui est notre point d’entrée. Il va importer et configurer, grâce à SystemJS, notre code TypeScript. Rappelons que SystemJS est un UMD (Universal Module Loader) permettant de charger des modules de manière synchrone (à la manière de CommonJS) ou asynchrone (à la manière d’AMD) et qu’il est utilisé par Angular 2 comme gestionnaire de modules.  

C’est dans le fichier systemjs.config.js que SystemJs va charger les dépendances nécessaires:

SystemJS va ainsi charger les différentes scripts de manière asynchrones au lancement de l’application.

 

On retrouve dans le fichier init.js des informations comme notre point d’entrée applicatif:

SystemJS va donc charger un fichier app/main.ts dans lequel on va démarrer (bootstraper) notre application. Il fait appel à la méthode bootstrap qui nous permet, entre autres, de charger notre composant principal : AppComponent. Composant qui, comme l’import l’indique, se trouve dans le même dossier dans le fichier app.component.ts :

Le composant AppComponent va nous permettre notamment de définir notre routeur.

Routeur

L’annotation @Routes nous permet de définir les différentes routes et les composants à instancier lorsque ces routes sont appelées. 

Nous ne nous attarderons pas sur la liste des customers puisque notre objectif est de voir comment afficher dynamiquement une portion de formulaire. Voyons donc plus précisément le composant Customer.

Formulaire customer

Le composant Customer est bien évidement lié à notre formulaire grâce à la propriété templateUrl de l’annotation @Component:

 

Comme on peut le voir, on a deux champs statiques : firstName et lastName  dans notre formulaire. La balise extra-form fait référence à un composant donc l’objectif va être de charger le descriptif du formulaire Customer et d’ajouter les champs à la volée. Restons-en là pour l’instant avec notre formulaire et regardons de plus près le composant extra-form.

Composant extra-form

Le composant extra-form a pour but de consommer le webservice de description d’un formulaire et d’ajouter dans le DOM les composants correspondants. D’un point de vue purement technique, il s’agit d’un simple Component avec une balise div parente :

Le composant prend un paramètre en entrée: Une promise. En effet, en cas d’édition d’un customer, il nous faut d’abord charger le customer pour ensuite récupérer la description du formulaire et ajouter visuellement les composants. Ce qui permet de pouvoir renseigner les valeurs des différents inputs avec les informations du champ extraFields du customer (cf schema).

On utilise @Input  pour déclarer le paramètre d’entrée :

Les autres propriétés de la classe DynamicForm sont :

  • form : Le type ExtraForm est le « model » de notre flux JSON et représente la description TypeScript du flux notamment à travers des classes.
  • onlyExtraFields : Booléen pour déterminer si l’api doit nous retourner uniquement la description des champs extra fields de l’entité Customer ou alors tous les champs.
  • extraFieldRef:ViewContainerRef: Représente l’élément HTML courant de notre composant (<div #extraField></div> )

Le constructor de la classe DynamicForm va nous permettre de faire de l’injection de dépendance  sur différentes classes :

  • Http  : Utilisé pour effectuer des requêtes HTTP
  • DynamicComponentLoader : Cette classe va nous permettre d’ajouter dans le DOM des composants Angular

 

Pour rappel, le mot clé private sur les paramètres du constructeur permet de déclarer automatiquement un champ comme propriété privée de la classe et donc accessible par this.elementRef par exemple.

Dans la méthode ngOnInit  on appelle le webservice de description du formulaire Customer :

Lorsque les deux promises sont résolues (la promise de détail d’un customer passée en paramètre via @Input()  et la promise de description du formulaire customer) on récupère les valeurs et on affecte aux variables concernées le résultat. La variable form contient ainsi la structure du flux JSON et la variable entity le résultat de l’appel à la récupération d’un customer (par exemple: /api/cutomers/3). En  création d’un customer, une promise contenant la référence vers « customer » est retournée (voir customer.ts) :

 

Enfin, dans la dernière partie du composant, on itère sur la liste des champs du formulaire customer (qui dans notre exemple sont uniquement des champs extra fields) et on ajouter grâce à DynamicComponentLoader  les composants correspondants :

InputExtraField, TextAreaExtraField, FileInputExtraField, SelectExtraField et DateInputExtraField sont tous des @Component  angular et vous l’aurez compris sont associés aux inputs HTML correspondants.  

Par exemple avec le type InputExtraField  :

 

Chaque composant prend deux paramètres d’entrée :

  • field : ExtraFormField qui représente l’extra field courant
  • entity : c’est l’entité chargée depuis la beckend ou initialisée si en création (dans notre cas un objet customer)

De plus chaque composant étend la classe abstraite ExtraField qui contient une logique générique:

  • Ajout des contrôles Angular pour que la validation des extra fields soit prise en compte : Champs requis, email, pattern, taille minimum etc…
  • Mise à jour des valeurs de l’input avec la donnée présente dans customer. Ainsi entity.extraFields[’email’] = ‘test@redfroggy’  deviendra la valeur du champ email correspondant dans le DOM.

dynamic_form_email

La directive [(ngModel)]  nous assure le binding bidirectionnel entre le composant visuel et la propriété de l’objet extraFields associée. Par exemple: entity.extraFields[’email’] . Le binding est aussi assuré sur d’autres attributs tout aussi important : for, id, label, placeholder etc…

Validation du formulaire

Ce pose forcément la question de la validation du formulaire avec des champs ajoutés dynamiquement dans le DOM. Dans Angular 2, un champ (un input par exemple) peut être associé à un Control.  Ce dernier est identifié par un nom et peut être associé à une liste de validateurs. La directive ngFormControl permet d’associer un composant visuel à un control défini dans le code. Il y a un exemple sur le composant InputExtraField présenté ci-dessus. Comment sont créés les contrôles et les validateurs pour nos champs dynamiques ?

Control

Revenons sur notre fichier form.ts où je le rappelle sont définies deux classes: ExtraForm  et ExtraFormField  qui représente la stucture TypeScript du flux JSON de description d’un formulaire.

Grâce à TypeScript on peut ajouter sur nos classes de la logique et notamment la création d’un contrôle associé au champ extra field. Ainsi la classe ExtraFormField possède une méthode getControl dans laquelle est instancié un nouveau contrôle :

On passe au constructeur de la classe Control le nom de l’extra field et une liste de validateurs.

Validators

Cette liste est initialisée dans le constructeur de la classe ExtraFormField à travers la méthode initValidators :

La classe Validators proposée par Angular 2 permet de définir un ensemble de validateurs à associer à notre contrôle. La liste n’est pas très exhaustive mais il est heureusement possible de créer ses propres validateurs. C’est ce qui est fait dans la classe ValidatorService

Notre instance de la classe ExtraFormfield bénéficie donc d’un contrôle et de validateurs associés correspondant à son type et sa configuration.

Mais comment faire le lien avec le composant visuel ? Comme dit plus haut c’est grâce à la directive ngFormControl  qui doit être associée à une instance de la classe Control. Si l’on prend l’exemple du composant InputExtraField on constate que la directive ngFormControl est associée à une variable de type Control nommée fieldControl.

C’est dans la classe parente ExtraField qu’est initialisé ce contrôle, ainsi toutes les classes enfants (InputExtraField, TextAreaExtraField, FileInputExtraField, SelectExtraField et DateInputExtraField) en hérite et on accès à ce champ :

En plus du binding entre ngFormControl et notre control, il faut également ajouter au formulaire courant le control. Il ne s’agit pas ici d’un ajout visuel mais bien d’indiquer à Angular qu’un nouveau control est disponible. 

Initialisation

Enfin, après avoir récupéré mon customer il me faut initialiser la valeur des champs visuels extra fields avec la valeur de la propriété extraFields de mon objet customer en édition. En création, c’est la valeur par défaut qui est renseignée si elle existe.

Par exemple en édition, si /api/customers/3 me renvoie comme extra fields:  {« extraFields »:{« readable »: »Readable value », »email »: »michael.desigaud@redfroggy.fr »,(…)}} , alors le champs email doit être renseigné avec comme valeur: michael.desigaud@redfroggy.fr. En revanche, en création, la valeur est celle par défaut renseignée dans la propriété « defaultValue » du fichierJSON.

C’est toujours dans la classe ExtraField que l’on effectue cette vérification. La méthode updateValue de la classe Control nous permet de mettre à jour la donnée du composant.

Sauvegarde du formulaire

Coté client

Coté Angular, la sauvegarde des données extra fields est automatique notamment grâce au binding bidirectionnel. Ainsi tous les composants défini ont un attribut ngModel associé à la propriété extra fields correspondante : [(ngModel)]= »entity.extraFields[field.name] « .

Si field.name =’email’  et que entity représente notre objet customer, alors on obtient entity.extraFields[’email’] . Ce qui signifie que chaque changement de la valeur du composant va modifier la valeur de la propriété customer.extraFields[’email’] . Ce qui est très pratique puisqu’il nous suffit simplement, à la sauvegarde du formulaire, d’envoyer notre objet customer qui contiendra alors tous les extra fields (dans la propriété extraFields ) sous forme clé-valeur.

Exemple de requête PUT sur /api/customers/5:

{"extraFields":{"email":"michael.desigaud@redfroggy.fr",},"id":5,"firstName":"Michael","lastName":"Desigaud"} 

Coté serveur

Coté Spring, le principe consiste à pouvoir étendre une entité JPA pour lui rajouter notre champ extraFields. L’idée est donc d’avoir une classe parente contenant un champ extraFields duquel on pourra hériter. JPA offre pour cela l’annotation @MappedSuperclass qui permet de définir des champs JPA « héritable ».

C’est le rôle de notre classe FieldExtensible qui possède donc un champ extraFields bien évidemment de type String.

Seulement voilà, d’un point de vue api rest il est intéressant d’avoir la propriété extraFields  sous forme clé-valeur et non sous forme purement chaîne de caractères.

C’est le rôle du converter HashMapToStringConverter qui est un converter JPA permettant de convertir un String en HashMap  et inversement.

  • On envoie donc au backend une entité (customer dans notre cas) avec un champ extraFields  sous forme de clé-valeur
  • Pour la sauvegarde en BDD, JPA utilise le converter et sauvegarde la donnée sous forme brut JSON.
  • A l’inverse à la récupération de la donnée depuis la BDD (lorsque l’on charge un customer), JPA utilise de nouveau le converter et transforme la donnée brute en donnée clé-valeur et le tout est retourné au client.

 

Voilà pour les grandes lignes de ce projet de création dynamique de formulaire avec Angular 2. L’article n’est évidemment pas exhaustif sur l’explication du code mais se concentre sur les points essentiels.

N’hésitez pas à commenter et à lever des issues sur notre github !