Créer un formulaire dynamique avec AngularJS et Spring
Dans cet article, l’objectif est de présenter une manière possible de gérer dynamiquement l’affichage et la sauvegarde des champs d’un formulaire via AngularJS et Spring/Hibernate simplement en modifiant un ficher de configuration XML.
L’environnement technique
Les composants techniques sont les suivants:
Partie serveur:
- Java 1.7
- Spring MVC v4.0.3
- Spring Boot v1.02
- Hibernate v4.3.1
- Annotations JPA v1.01
- MySQL v5.1.30
Partie cliente:
- AngularJS v1.2.13 (JavaScript)
- HTML 5 et CSS 3
La communication est faite via des webservices RESTFULL entre la partie front office (AngularJS) et la partie serveur (Spring/Hibernate) notamment grâce a Spring MVC.
De quoi a t-on besoin ?
- Fichiers XML :
- Création d’un ou plusieurs fichiers XML décrivant les champs supplémentaires à ajouter (libellé,type,champs requis,etc..).
- Le fichier contiendra un numéro de version (par entité décrite) afin de détecter les changements.
- Des classes de mapping JAXB pour convertir nos fichiers XML en beans Java.
- Modification de la base de données:
- Il est nécessaire de rajouter un champ (que nous appelerons
extraFields) sur chaque table de la base de données où l’on souhaite insérer des champs supplémentaires. - Les données présentes dans le champ
extraFieldssont au format JSON.
- Il est nécessaire de rajouter un champ (que nous appelerons
- Modification du mapping relationnel:
- Modification des classes de mapping relationnelle (dans notre cas Hibernate) : il faut mapper le nouveau champ
extraFields.
- Modification des classes de mapping relationnelle (dans notre cas Hibernate) : il faut mapper le nouveau champ
- Une classe Java pour lire les fichiers XML et synchroniser les champs supplémentaires entre la base de données et les informations des fichiers.
- Modification du webservice REST:
- Il faut s’assurer que le nouveau champ
extraFieldssoit bien rajouté dans les webservices REST concernés.
- Il faut s’assurer que le nouveau champ
- De rajouter du code angularJS afin de pouvoir injecter les champs supplémentaires dans les formulaires HTML de manière automatique.
Rien ne vaut l’exemple
Pour notre exemple,nous souhaitons rajouter des champs dynamique sur une page contenant les informations d’un utilisateur.
Modèle de données
Nous avons simplement une table t_user comprenant les informations de base de notre utilisateur ainsi que notre champ extraFields qui servira a stocker les champs supplémentaires en JSON.

Mapping Hibernate
Coté hibernate nous avons la classe suivante:
@Data
@Entity
@Table(name = "T_USER")
public class User implements Serializable {
@NotNull
@Size(min = 0, max = 50)
@Id
private String login;
@JsonIgnore
@Size(min = 0, max = 100)
private String password;
@Size(min = 0, max = 50)
@Column(name = "first_name")
private String firstName;
@Size(min = 0, max = 50)
@Column(name = "last_name")
private String lastName;
@Email
@Size(min = 0, max = 100)
private String email;
@Column(name = "extra_fields")
private String extraFields;
public String getExtraFields(){
return DynamicFieldsUtils.sync(extraFields,"t_user");
}
}
Le getter getExtraFields fait appel à notre classe utiltaire DynamicFieldsUtils définie ci-dessous.
Ficher XML
Notre fichier XML comprend le descriptif des champs à ajouter pour chaque table. Dans notre cas nous aurons donc uniquement le descriptif des nouveaux champs de la table t_user:
1
textfield
3 rue du chene
Adresse
true
2
textarea
69002
Code postal
true
3
password
Password
true
4
dropdown
Liste
true
1
Element 1
value1
2
Element 2
value2
3
Element 3
value3
Sur chaque occurrence de la balise entity du document XML on ajoute un attribut value qui nous permet de connaitre le nom de la table concernée. On ajoute également un attribut version qui nous permettra par la suite de détecter un changement dans le descriptif des champs des entités de notre fichier XML.Enfin, l’attribut id d’un champ permet de détecter son existence lors de la comparaison de deux version de notre entité. Ces trois informations doivent donc absolument figuer dans les fichiers XML.
Mapping JAXB
Voici la liste des classes qui sont mappés (à l’aide de la librairie JAXB) sur notre fichier XML d’exemple. Elles nous permettent de manipuler facilement les données:
@Data
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "entities")
public class ExtraFieldEntities implements Serializable{
@XmlElement(name = "entity")
private List<ExtraFieldEntity> entities;
}
@Data
@XmlAccessorType(XmlAccessType.FIELD)
public class ExtraFieldEntity implements Serializable {
@XmlAttribute(name = "value")
private String entityName;
@XmlAttribute(name = "version")
private Integer version;
@JsonProperty(value = "form_fields")
@XmlElementWrapper(name = "extrafields")
@XmlElement(name = "extrafield")
private List<ExtraField> extraFields;
}
@Data
@XmlAccessorType(XmlAccessType.FIELD)
public class ExtraField implements Serializable {
@JsonProperty(value = "field_id")
private Integer id;
@JsonProperty(value = "field_type")
private String type;
@JsonProperty(value = "field_value")
private String value;
@JsonProperty(value = "field_required")
private String required;
@JsonProperty(value = "field_title")
private String label;
@JsonProperty(value = "field_options")
@XmlElementWrapper(name = "options")
@XmlElement(name = "option")
private List options;
@Override
public boolean equals(Object o){
ExtraField entity = (ExtraField) o;
return id.equals(entity.getId())
&& type != null && type.equals(entity.getType())
&& label != null && label.equals(entity.getLabel())
&& required != null && required.equals(entity.getRequired());
}
A note la méthode equals qui va nous permettre de comparer deux instances de la classe ExtraField pour pouvoir détecter un changement sur les propriétés d’un champ (type, libellé,etc..).
Classe utilitaire
Nous avons besoin d’une classe utilitaire qui va gérer le chargement et la synchronisation des différents champs supplémentaires entre la base de données et les fichiers XML. Pour cela on crée une classe annotée @Componentcar une classe annotée @Component est éligible au scan automatique des composants dans Spring et sera donc instancié automatiquement au lancement de notre application Nous plaçons notre fichier XML dans le dossier src/main/resources/config/extrafields. L’idée est de charger tous les fichiers XML présent dans ce dossier au lancement de notre application:
@Component
public class DynamicFieldsUtils {
//Logger
private static final Logger log = LoggerFactory.getLogger(DynamicFieldsUtils.class);
//Chemin vers le dossier contenant les fichiers XML à charger (placés sous src/main/resources)
private static final String CONFIG_FILEPATH = "config/extrafields";
//Liste de tous les champs supplémentaire
private static List extraFieldEntities;
}
La variable CONFIG_FILEPATH contient le chemin du dossier dans lequel se trouve les fichiers XML à charger. La variable extraFieldEntities contient la liste de tous les entités et de leurs champs pour les différents fichiers XML chargés. Cette variable est statique elle pourra donc être accessible de n’importe ou dans notre code. Après l’initialisation de l’application, l’accès à cette variable se fera uniquement en lecture.
Chargement des fichiers XML
Ensuite, toujours dans notre classe DynamicFieldUtils, nous allons créer une méthode chargée de lire tous les fichiers XML présents dans notre dossier:
/**
* Read datas from the xml configuration file under src/main/resources folder
* @return
*/
private List readXmlFiles() throws IOException {
//List files from classpath
InputStream is = DynamicFieldsUtils.class.getClassLoader().getResourceAsStream(CONFIG_FILEPATH);
if(is != null) {
List files = IOUtils.readLines(is, Charsets.UTF_8);
if (files != null && !files.isEmpty()) {
return files;
} else {
log.warn("No files found in path {}: Dynamic fields loading is aborted", CONFIG_FILEPATH);
return null;
}
}
log.warn("Directory {} does not exists: Dynamic fields loading is aborted", CONFIG_FILEPATH);
return null;
}
On teste si le dossier existe et on retourne le nom de tous les fichiers xml présents dans le dossier.
XML vers Java
On crée ensuite une méthode d’initialisation (automatiquement appelé grâce à l’annotation @PostConstruct), qui execute la méthode readXmlFiles et qui transforme chaque fichier XML en Bean Java. Ensuite, chaque bean est ajouté à la liste statique extraFieldEntites.
@PostConstruct
public void init() throws JAXBException, IOException {
extraFieldEntities = new ArrayList();
List files = readXmlFiles();
log.debug("Load extra fields into memory");
for(String fileName : files){
if(fileName != null && !fileName.isEmpty() && fileName.indexOf(".xml") != -1){
log.info("File name: {}",fileName);
//Load xml file
InputStream is = DynamicFieldsUtils.class.getClassLoader().getResourceAsStream(CONFIG_FILEPATH+fileName);
if(is != null) {
String fileContent = DataEncodeUtils.getStringFromInputStream(is);
//Convert XML file to Java entity
extraFieldEntities.add(XmlUtils.fromXml(fileContent, ExtraFieldEntities.class));
}
else{
log.warn("Error reading xml configuration file : {}, Dynamic fields loading is aborted",fileName);
}
}
}
}
Parcours des entités
On crée ensuite deux méthodes utilitaires:
getEntityByName: Permet de retrouver une entité par le nom de la table associée (en base de données).getEntityById: Permet de retrouver un champ par son identifiant pour une entité donnée.
Ces deux méthodes parcourent la variable statique extraFieldEntities qui contient les descriptions des champs chargées depuis le ou les fichiers XML.
/**
* Retrieve entites by the entity name (from config files)
* @param tableName
* @return
*/
public static ExtraFieldEntity getEntityByName(String tableName){
for(ExtraFieldEntities fieldEntities : extraFieldEntities) {
if (fieldEntities != null && fieldEntities.getEntities() != null && !fieldEntities.getEntities().isEmpty()) {
for (ExtraFieldEntity entity : fieldEntities.getEntities()) {
if (entity != null && tableName.equals(entity.getEntityName())) {
return entity;
}
}
}
}
return null;
}
/**
* Retrieve an entity by it's id
* @param extraFields
* @param id
* @return
*/
public static ExtraField findEntityById(List extraFields,Integer id){
if(extraFields != null && !extraFields.isEmpty()){
for(ExtraField entity : extraFields){
if(entity != null && id.equals(entity.getId())){
return entity;
}
}
}
return null;
}
Synchronisation des entités
Enfin on crée une méthode sync qui va synchroniser les informations de notre champ extraFields (dans notre exemple de l’entite hibernate User) avec la liste des entités présentes dans la variable statique extraFieldsEntities de notre classe DynamicFieldsUtils. Le workflow de synchronisation est le suivant:
/**
* Synchronize extra fields from parameter (json format) with new fields loaded from the xml config file
* @param jsonExtraField Current extra fields in json format
* @param tableName Name of the entity where the extra fields added
* @return updated fields definition and values in JSON format
*/
public static String sync(String jsonExtraField,String tableName){
try {
//If json string is not empty
if(jsonExtraField != null && !jsonExtraField.isEmpty()) {
//Get old fields
ExtraFieldEntity oldExtraEntity = JSONUtils.jsonToObject(jsonExtraField, ExtraFieldEntity.class);
//Get new fields definition (from config.xml file)
ExtraFieldEntity newExtraEntity = getEntityByName(tableName);
//If both old and new fields are not null
if (oldExtraEntity != null && newExtraEntity != null) {
//If new version is detected in config file
if (newExtraEntity.getVersion() > oldExtraEntity.getVersion()) {
log.debug("New version is detected {} for entity: {}",newExtraEntity.getVersion(),newExtraEntity.getEntityName());
//Update the new version
oldExtraEntity.setVersion(newExtraEntity.getVersion());
//For each field
for (ExtraField newEntity : newExtraEntity.getExtraFields()) {
if (newEntity != null) {
//Tests if the field already exists
ExtraField oldEntity = findEntityById(oldExtraEntity.getExtraFields(), newEntity.getId());
//If new entity from config file
if (oldEntity == null) {
//Adding the new entity to the current list of fields
oldExtraEntity.getExtraFields().add(newEntity);
} else {
//If existing entity from config file
if (!oldEntity.equals(newEntity)) {
//Update the essential informations: label,type,etc..
oldEntity.setLabel(newEntity.getLabel());
oldEntity.setType(newEntity.getType());
oldEntity.setRequired(newEntity.getRequired());
if(!oldEntity.getType().equals(newEntity.getType())){
oldEntity.setValue(newEntity.getValue());
}
//Replace the existing field with the updated one
oldExtraEntity.getExtraFields().set(newEntity.getId() - 1, oldEntity);
}
}
}
}
}
return JSONUtils.toJSON(oldExtraEntity);
}
}
else{
return JSONUtils.toJSON(getEntityByName(tableName));
}
} catch (IOException e) {
log.warn("Synchronization failed: Error fields {} not in json format",jsonExtraField);
}
return null;
}
Exemple de cas d’utilisation:
- Au premier appel du webservice REST de récupération de l’utilisateur, le champ
extraFieldsde l’entité hibernateUserest nul. - On va donc rechercher dans la variable statique
extraFieldEntitessi des champs supplémentaires on été définis pour la tablet_user- Si c’est le cas alors ces champs sont retournés au format JSON
- Sinon on ne retourne rien pour le champ extraFields
- A la sauvegarde du formulaire, les champs supplémentaire et leur valeur sont enregistrés dans la colonne extra_fields de la table t_user (au format JSON)
- Au second appel du webservice REST de récupération de l’utilisateur, le champ extraFields de l’entité hibernate User n’est pas null.
- On parcours alors la variable
extraFieldEntitespour voir si des informations concernant les champs de la tablet_useront évolues (c’est à dire si on a modifié un des fichiers XML) - Si c’est le cas les informations du champ
extraFieldsde l’entitéUsersont mises à jour.
Front-office
Exemple de flux JSON retourné par les webserices au front office:
{"login":"admin","firstName":"Michael","lastName":"Administrator","email":"michael@redfroggy.fr","extraFields":"{"entityName":"t_user","version":3,"form_fields":[{"field_id":1,"field_type":"textfield","field_value":"5 rue du chene","field_required":"true","field_title":"Adresse","field_options":null},{"field_id":2,"field_type":"textarea","field_value":"69003","field_required":"true","field_title":"Code postal","field_options":null},{"field_id":3,"field_type":"password","field_value":"test","field_required":"true","field_title":"Password","field_options":null},{"field_id":4,"field_type":"dropdown","field_value":"2","field_required":"true","field_title":"Liste","field_options":[{"option_id":1,"option_value":"value1","option_title":"Element 1"},{"option_id":2,"option_value":"value2","option_title":"Element 2"},{"option_id":3,"option_value":"value3","option_title":"Element 3"}]}]}"}
Coté AngularJS, il nous suffit simplement d’une directive qui va, selon le type de champ, injecter un template html correspondant:
.directive('fieldDirective', function ($http, $compile) {
var getTemplateUrl = function(field) {
var type = field.field_type;
var templateUrl = '';
switch(type) {
case 'textfield':
templateUrl = './views/template/textfield.html';
break;
case 'email':
templateUrl = './views/template/email.html';
break;
case 'textarea':
templateUrl = './views/template/textarea.html';
break;
case 'checkbox':
templateUrl = './views/template/checkbox.html';
break;
case 'date':
templateUrl = './views/template/date.html';
break;
case 'dropdown':
templateUrl = './views/template/dropdown.html';
break;
case 'hidden':
templateUrl = './views/template/hidden.html';
break;
case 'password':
templateUrl = './views/template/password.html';
break;
case 'radio':
templateUrl = './views/template/radio.html';
break;
}
return templateUrl;
}
var linker = function(scope, element) {
// GET template content from path
var templateUrl = getTemplateUrl(scope.field);
$http.get(templateUrl).success(function(data) {
element.html(data);
$compile(element.contents())(scope);
});
}
return {
template: '
{{field}}
', restrict: 'E', scope: { field:'=' }, link: linker }; })
Il ne reste plus qu’a appelé notre directive dans les formulaires souhaités:
