La solution qui est présentée se base sur thymeleaf et boostrap 3.
Interface utilisateur
Depuis le tout début de ma carrière, à chaque emploi j'ai eu à créer de nombreuses interfaces web pour des applications de gestion. Ce n'est pas nécessairement très intéressant, c'est redonnant et c'est de plus en plus complexe pour au final prendre des données, les afficher à l'utilisateur, lui permettre de les modifier et finalement les retourner au serveur.
Après avoir fait du jsp, jsf, asp, php, struts, gwt, gxt, gwt-ext, jquery, thymeleaf j'avais de grosse attente envers les nouveaux framework / librairie frontend. J'ai été malheureusement fortement déçu après avoir fait quelques nouvelles applications from scratch en angular / react.
Encore plus complexe qu'auparavant, mais plus découpé. Courbe d'apprentissage assez élevé et maintenance coûteuse. Le temps et argent pris pour constamment mettre à jour des libraires c'est du temps, argent en moins pour ajouter des nouvelles fonctionnalités à l'utilisateur.
Sur un nouveau projet qui allait demander la création d'une centaine d'écran avec la majeure partie du temps que deux développeurs, il fallait trouver une solution efficace et rapide. Une preuve de concept au tout début avec react / angular / vue afin de voir si c'était viable de les utiliser fût créé.
Personne à l'interne n'avait de l'expérience avec ces outils. De plus la centaine d'écrans à réaliser n'était qu'une petite partie insignifiante du système à créer. Il fût rapidement décidé qu'avec le temps et le budget alloué que ces outils ne pouvaient assurer une réussite au projet.
Idée
Une centaine d'écrans à réaliser en peu de temps, comment y parvenir?
J'ai alors commencé à penser à des critères que les interfaces avaient en commun. Est-ce qu'il y aurait un moyen d'automatiser les interfaces utilisateur d'une application de gestion?
Qu'est-ce qui peut changer? Les types de données. Est-ce que le champ est obligatoire? L'ordre d'affichage des composants. Est-ce qu'il doit être formaté? L'affichage du champs est nécessaire? Est-ce qu'il doit avoir un style particulier à appliquer au champ?
Une annoation à été créé pour ces différents critères. Certaine aurait pu être combiné et avoir des attributs.
Reflection
Afin d'éviter le plus possible d'écrire du code, dans un esprit de généricité avec les différents critères récurrents d'un écran à l'autre, j'ai regardé du côté de la réflection en java. J'ai mis en place des annotations pour différent critère qui serait utilisé dans un dto (représentant les données d'une interface utilisateur). Par la suite, via la réflection ces dto sont parcouru et un objet générique est utilisé pour construire l'interface utilisateur via thymeleaf.
public class UsersDto {
@Order(value = 1)
private Long id
@Mandatory
@Order(value = 2)
private String firstname;
@Order(value = 3)
private String lastname;
@Order(value = 4)
private String username;
@TextArea(value=true)
@OuterCssClass(value="col-md-12")
@Order(value = 17)
private String comment;
@MandatoryField(value=true)
@Formatter(value="'mask' : '999 999-9999'")
private String phone;
private LocalDate birthDate;
private List<Ville> villesId
}
Structure de données où les informations étaient mises
@Data
public class UIReflection {
private String field;
private Object value;
private String formatter;
private String sortable;
private boolean mandatory;
private String i18n;
private String dataType;
private String uiComponent;
}
Ensuite une opération analysait le dto et pour chacun des champs du dto, un objet de ce type était créer.
Si le type de donnée était de type Date, un DateTimePicker serait inséré dans l'attribut uiComponent Ceci est une version simplifié de ce qui a été fait.
Class<?> myUserDto = UsersDto.class;
Field[] allFields = myClass.getFields();
List<UIReflection> uiReflections = new ArrayList<>();
for (Field field : allFields) {
UIReflection uiReflection = new UIReflection();
uiReflection.setField(field.getName());
uiReflection.setI18n(field.getName());
uiReflection.setMandatory(mandatoryValue(field));
uiReflection.setFormatter(formatter(field));
if (field.getType().equals(LocalDate.class)) {
uiReflection.setDataType("localdate");
} else if (field.getType().equals(boolean.class)) {
uiReflection.setDataType("boolean");
} else if (ReflectionUtils.isNumeric(field.getType()) && !field.getType().getName().contains("Id")){
uiReflection.setDataType("number");
} else if (field.getType().equals(String.class)) {
if(textArea(field)){
uiReflection.setDataType("textArea");
}
}
uiReflections.add(uiReflection);
}
La création d'une nouvelle écran se résumait majoritairement à créer un dto et mettre les annotations sur chacun des champs.
Template / fragment Thymeleaf
Par la suite, un template parcourait les objets UIReflection affectaient les valeurs css, attribut aux composants html.
<div th:fragment="Generic">
<input id="genericFormId" name="id" type="hidden"/>
<div th:each="uiGenericForm, iterStat : ${uiReflectionsForm}" th:remove="tag">
<div class="col-sm-6">
<div th:class="${uiGenericForm.mandatory ? 'form-group required' : 'form-group' }" class="form-group">
<label th:attr="for='genericForm'+${uiGenericForm.field}" class="col-sm-3 control-label" th:text="#{${uiGenericForm.i18n}}"></label>
<div class="col-sm-9">
<div th:switch="${uiGenericForm.dataType}" th:remove="tag">
<div th:case="localDateTime" class="input-group date" th:id="'genericForm'+${uiGenericForm.field}+'DatePicker'">
<input type="text" th:id="'genericForm'+${uiGenericForm.field}" th:attr="name=${uiGenericForm.field}" class="form-control"/>
<span class="input-group-addon">
<span class="glyphicon glyphicon-calendar"></span>
</span>
</div>
<div th:case="selection" th:remove="tag">
<div th:if="${uiGenericForm.mandatory}" th:remove="tag">
<select class="form-control" th:id="'genericForm'+${uiGenericForm.field}" th:name="${uiGenericForm.field}" th:attr="data-msg=#{form.mandatory.field}" >
<option th:value="NULL" selected="selected" th:text="#{form.select.empty}"></option>
<option th:each="generic : ${__${uiGenericForm.field}__}" th:attr="data-id=${generic.id}" th:value="${generic.id}" th:text="${generic.name}"></option>
</select>
</div>
<div th:unless="${uiGenericForm.mandatory}" th:remove="tag">
<select class="form-control" th:id="'genericForm'+${uiGenericForm.field}" th:name="${uiGenericForm.field}" >
<option th:value="NULL" selected="selected" th:text="#{form.select.empty}"></option>
<option th:each="generic : ${__${uiGenericForm.field}__}" th:attr="data-id=${generic.id}" th:value="${generic.id}" th:text="${generic.name}"></option>
</select>
</div>
</div>
<input th:case="boolean" type="checkbox" th:attr="name=${uiGenericForm.field}"/>
<div th:case="map">
<div th:include="fragments/map-table :: map-table" th:remove="tag"></div>
</div>
<div th:case="number" th:remove="tag">
<div th:if="${uiGenericForm.mandatory}" th:remove="tag">
<input type="number" class="form-control" th:id="'genericForm'+${uiGenericForm.field}" th:attr="data-msg=#{form.mandatory.field}, name=${uiGenericForm.field}" required/>
</div>
<div th:unless="${uiGenericForm.mandatory}" th:remove="tag">
<input type="number" class="form-control" th:id="'genericForm'+${uiGenericForm.field}" th:attr="name=${uiGenericForm.field}"/>
</div>
</div>
<div th:case="*" th:remove="tag">
<div th:if="${uiGenericForm.mandatory}" th:remove="tag">
<input type="text" class="form-control" th:id="'genericForm'+${uiGenericForm.field}" th:attr="data-msg=#{form.mandatory.field}, name=${uiGenericForm.field}" required/>
</div>
<div th:unless="${uiGenericForm.mandatory}" th:remove="tag">
<input type="text" class="form-control" th:id="'genericForm'+${uiGenericForm.field}" th:attr="name=${uiGenericForm.field}"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
Une fois que le template est traité.
Possibilité
Préférez-vous les formulaires horizontaux au formulaires verticaux? Appréciez-vous davantage Tailwind css que bootstrap? Quelques modification dans les classes css du template et le tour est joué.
Il est tout à fait possible de changer ce script thymeleaf par un autre moteur de template orienté serveur tel que freemaker, groovy, pebble... Htmx pourrait être utilisé afin de ne pas rafraichir la page au complet.
Dans notre cas, htmx n'existait pas. On chargait les templates via fetch afin d'éviter de recharger la page au complet. Un api rest était utiliser pour afficher, modifier et sauvegarer une ressource. Donc au final du MVC et REST.
Il serait aussi envisageable de retourner la structure de UIReflection en JSON. VueJs, React, Angular... pourrait alors être utilisé pour générer le UI au lieu d'un template serveur.