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
extraFields
sont 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
extraFields
soit 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
@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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<!--?xml version="1.0" encoding="UTF-8"?--> 1 textfield 3 rue du chene <label>Adresse</label> true 2 textarea 69002 <label>Code postal</label> true 3 password <label>Password</label> true 4 dropdown <label>Liste</label> 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:
1 2 3 4 5 6 7 |
@Data @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "entities") public class <strong>ExtraFieldEntities</strong> implements Serializable{ @XmlElement(name = "entity") private List<<strong>ExtraFieldEntity</strong>> entities; } |
1 2 3 4 5 6 7 8 9 10 11 12 |
@Data @XmlAccessorType(XmlAccessType.FIELD) public class <strong>ExtraFieldEntity</strong> 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<<strong>ExtraField</strong>> extraFields; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
@Data @XmlAccessorType(XmlAccessType.FIELD) public class <strong>ExtraField</strong> 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 @Component
car 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:
1 2 3 4 5 6 7 8 9 |
@Component public class <strong>DynamicFieldsUtils</strong> { //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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
/** * 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
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
/** * 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
/** * 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
extraFields
de l’entité hibernateUser
est nul. - On va donc rechercher dans la variable statique
extraFieldEntites
si 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
extraFieldEntites
pour voir si des informations concernant les champs de la tablet_user
ont évolues (c’est à dire si on a modifié un des fichiers XML) - Si c’est le cas les informations du champ
extraFields
de l’entitéUser
sont mises à jour.
Front-office
Exemple de flux JSON retourné par les webserices au front office:
1 |
{"<strong>login</strong>":"admin","<strong>firstName</strong>":"Michael","<strong>lastName</strong>":"Administrator","<strong>email</strong>":"michael@redfroggy.fr","<strong>extraFields</strong>":"{"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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
.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: ' |
1 |
', restrict: 'E', scope: { field:'=' }, link: linker }; }) |
Il ne reste plus qu’a appelé notre directive dans les formulaires souhaités: