Aller au contenu
← Tous les articles

Créer un formulaire dynamique avec AngularJS et Spring

Michael Desigaud
AngularJSHtml5SpringSpringBootSpringMVC

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 ?

  1. Fichiers XML :
    1. Création d’un ou plusieurs fichiers XML décrivant les champs supplémentaires à ajouter (libellé,type,champs requis,etc..).
    2. Le fichier contiendra un numéro de version (par entité décrite) afin de détecter les changements.
  2. Des classes de mapping JAXB pour convertir nos fichiers XML en beans Java.
  3. Modification de la base de données:
    1. 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.
    2. Les données présentes dans le champ extraFields sont au format JSON.
  4. Modification du mapping relationnel:
    1. Modification des classes de mapping relationnelle (dans notre cas Hibernate) : il faut mapper le nouveau champ extraFields.
  5. 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.
  6. Modification du webservice REST:
    1. Il faut s’assurer que le nouveau champ extraFields soit bien rajouté dans les webservices REST concernés.
  7. 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.

tableUserPocFormDyn

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:

workflow synchro extraFields(corrige)

   /**
     * 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é hibernate User est nul.
  • On va donc rechercher dans la variable statique extraFieldEntites si des champs supplémentaires on été définis pour la table t_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 table t_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:

{"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:

Un projet en tête ?

Discutons de vos enjeux techniques — la première conversation est toujours gratuite.

Faisons connaissance