Skip to content

7. Ajaxification d'une application Spring MVC

7.1. La place d'AJAX dans une application web

Pour l'instant, les exemples d'apprentissage étudiés avaient l'architecture suivante :

Pour passer d'une vue [Vue1] à une vue [Vue2], le navigateur :

  • émet une requête vers l'application web ;
  • reçoit la vue [Vue2] et l'affiche à la place de la vue [Vue1].

C'est le schéma classique :

  • demande du navigateur ;
  • élaboration d'une vue en réponse au client par le serveur web ;
  • affichage de cette nouvelle vue par le navigateur.

Il existe depuis quelques années un autre mode d'interaction entre le navigateur et le serveur web : AJAX (Asynchronous Javascript And Xml). Il s'agit en fait d'interactions entre la vue affichée par le navigateur et le serveur web. Le navigateur continue à faire ce qu'il sait faire, afficher une vue HTML mais il est désormais manipulé par du Javascript embarqué dans la vue HTML affichée. Le schéma est le suivant :

  • en [1], un événement se produit dans la page affichée dans le navigateur (clic sur un bouton, changement d'un texte, ...). Cet événement est intercepté par du Javascript (jS) embarqué dans la page ;
  • en [2], le code Javascript fait une requête HTTP comme l'aurait fait le navigateur. La requête est asynchrone : l'utilisateur peut continuer à interagir avec la page sans être bloqué par l'attente de la réponse à la requête HTTP. La requête suit le processus classique de traitement. Rien (ou peu) ne la distingue d'une requête classique ;
  • en [3], une réponse est envoyée au client jS. Plutôt qu'une vue HTML complète, c'est plutôt une vue HTML partielle, un flux XML ou jSON (JavaScript Object Notation) qui est envoyé ;
  • en [4], le Javascript récupère cette réponse et l'utilise pour mettre à jour une région de la page HTML affichée.

Pour l'utilisateur, il y a changement de vue car ce qu'il voit a changé. Il n'y a cependant pas rechargement total d'une page mais simplement modification partielle de la page affichée. Cela contribue à donner de la fluidité et de l'interactivité à la page : parce qu'il n'y a pas de rechargement total de la page, on peut se permettre de gérer des événements qu'auparavant on ne gérait pas. Par exemple, proposer à l'utilisateur une liste d'options au fur et à mesure qu'il saisit des caractères dans une boîte de saisie. A chaque nouveau caractère tapé, une requête AJAX est faite vers le serveur qui renvoie alors d'autres propositions. Sans Ajax, ce genre d'aide à la saisie était auparavant impossible. On ne pouvait pas recharger une nouvelle page à chaque caractère tapé.

7.2. Mise à jour d'une page avec un flux HTML

7.2.1. Les vues

On se propose d'étudier l'application suivante :

  • en [1], l'heure de chargement de la page ;
  • en [2], on fait les quatre opérations arithmétiques sur deux nombres réels A et B ;
  • en [3], la réponse du serveur vient s'inscrire dans une région de la page ;
  • en [4], l'heure du calcul. Celle-ci est différente de l'heure de chargement de la page [5]. Cette dernière est égale à [1] montrant que la région [6] n'a pas été rechargée. Par ailleurs l'URL [7] de la page n'a pas changé.

7.2.2. L'action [/ajax-01]

  

Le contrôleur [Ajax.java] définit l'action [/ajax-01] suivante :


    @RequestMapping(value = "/ajax-01", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax01(Locale locale, Model modèle, HttpSession session, String tempo) {
        // tempo valide ?
        if (tempo != null) {
            boolean valide = false;
            int valueTempo = 0;
            try {
                valueTempo = Integer.parseInt(tempo);
                valide = valueTempo >= 0;
            } catch (NumberFormatException e) {

            }
            if (valide) {
                session.setAttribute("tempo", new Integer(valueTempo));
            }
        }
        // on prépare le modèle de la vue [vue-01]
        ...
}
  • ligne 2 : l'action [/ajax-01] n'accepte qu'un seul paramètre [tempo]. C'est la durée en millisecondes pendant laquelle le serveur devra attendre avant d'envoyer les résultats des opérations arithmétiques ;
  • ligne 4 : le paramètre [tempo] est facultatif ;
  • lignes 5-12 : on vérifie que la valeur du paramètre [tempo] est acceptable ;
  • lignes 13-15 : si c'est le cas, la valeur de la temporisation est mise en session. Cela veut dire qu'elle sera en vigueur tant qu'on ne la changera pas ;

Le code de l'action [/ajax-01] se poursuit ainsi :


    @RequestMapping(value = "/ajax-01", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax01(Locale locale, Model modèle, HttpSession session, String tempo) {
        // tempo valide ?
...
        // on prépare le modèle de la vue [vue-01]
        modèle.addAttribute("actionModel01", new ActionModel01());
...
        // vue
        return "vue-01";
}

La classe [ActionModel01] sert principalement à encapsuler les valeurs postées par l'action [/ajax-01]. Ici, il n'y a rien de posté. On crée une classe vide qu'on met dans le modèle car la vue [vue-01.xml] l'utilise. La classe [ActionModel01] est la suivante :


package istia.st.springmvc.models;

import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotNull;

public class ActionModel01 {

    // données postées
    @NotNull
    @DecimalMin(value = "0.0")
    private Double a;

    @NotNull
    @DecimalMin(value = "0.0")
    private Double b;

    // getters et setters
    ...
}
  • lignes 11 et 15 : deux réels [a,b] qui vont être postés par un formulaire ;

Revenons au code de l'action :


    @RequestMapping(value = "/ajax-01", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax01(Locale locale, Model modèle, HttpSession session, String tempo) {
...
        // on prépare le modèle de la vue [vue-01]
        modèle.addAttribute("actionModel01", new ActionModel01());
        Resultats résultats = new Resultats();
        modèle.addAttribute("resultats", résultats);
...
        // vue
        return "vue-01";
}
  • lignes 6-7 : on met une instance de type [Resultats] dans le modèle ;

Le type [Resultats] mis dans le modèle est le suivant :

  

package istia.st.springmvc.models;

public class Resultats {

    // données
    private String aplusb;
    private String amoinsb;
    private String amultiplieparb;
    private String adiviseparb;
    private String heureGet;
    private String heurePost;
    private String erreur;
    private String vue;
    private String culture;

    // getters et setters
    ...
}
  • lignes 6-9 : le résultat des quatre opération arithmétiques sur les nombres [a,b] ;
  • ligne 10 : l'heure du chargement initial de la page ;
  • ligne 11 : l'heure d'exécution des quatre opérations arithmétiques ;
  • ligne 12 : un éventuel message d'erreur ;
  • ligne 13 : l'éventuelle vue qui doit être affichée ;
  • ligne 14 : la culture de la vue, [fr-FR] ou [en-US] ;

Le code de l'action [/ajax-01] se poursuit ainsi :


    @RequestMapping(value = "/ajax-01", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax01(ActionModel01 formulaire, Locale locale, Model modèle, HttpSession session) {
        ...
        // locale
        setLocale(locale, modèle, résultats);
...
}
  • ligne 5 : la méthode [setLocale] sert à mettre dans le modèle de la vue la culture à utiliser, [fr-FR] ou [en-US]. Cette culture est à destination du Javascript embarqué dans la vue ;

La méthode [setLocale] est la suivante :


    private void setLocale(Locale locale, Model modèle, Resultats résultats) {
        // on ne gère que les locales fr-FR, en-US
        String language = locale.getLanguage();
        String country = null;
        switch (language) {
        case "fr":
            country = "FR";
            break;
        default:
            language = "en";
            country = "US";
            break;
        }
        // culture
        résultats.setCulture(String.format("%s-%s", language, country));
}

Dans le modèle on aura la chaîne [${resultats.culture}] égale à 'fr-FR' ou 'en-US'.

Revenons à l'action [/ajax-01] :


@RequestMapping(value = "/ajax-01", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax01(ActionModel01 formulaire, Locale locale, Model modèle, HttpSession session) {
...
        // locale
        setLocale(locale, modèle, résultats);
        // heure
        résultats.setHeureGet(new SimpleDateFormat("hh:mm:ss").format(new Date()));
        // vue
        return "vue-01";
    }
  • ligne 7 : on met l'heure du GET dans le modèle ;
  • lignes 9 : on affiche la vue [vue-01.xml] :

7.2.3. La vue [vue-01.xml]

La vue [vue-01.xml] est la suivante :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>Ajax-01</title>
        <link rel="stylesheet" href="/css/ajax01.css" />
        <script type="text/javascript" src="/js/jquery/jquery-2.1.1.min.js"></script>
        <script type="text/javascript" src="/js/jquery/jquery.validate.min.js"></script>
        <script type="text/javascript" src="/js/jquery/jquery.validate.unobtrusive.min.js"></script>
        <script type="text/javascript" src="/js/jquery/globalize/globalize.js"></script>
        <script type="text/javascript" src="/js/jquery/globalize/cultures/globalize.culture.fr-FR.js"></script>
        <script type="text/javascript" src="/js/jquery/globalize/cultures/globalize.culture.en-US.js"></script>
        <script type="text/javascript" src="/js/jquery/jquery.unobtrusive-ajax.js"></script>
        <script type="text/javascript" src="/js/json3.js"></script>
        <script type="text/javascript" src="/js/client-validation.js"></script>
        <script type="text/javascript" src="/js/local1.js"></script>
        <script th:inline="javascript">
            /*<![CDATA[*/
                    var culture = [[${resultats.culture}]];
                    Globalize.culture(culture);
                    /*]]>*/
        </script>
    </head>
    <body>
        <h2>Ajax - 01</h2>
        <p>
            <strong th:text="#{labelHeureGetCulture(${resultats.heureGet},${resultats.culture})}">
                Heure de chargement :
            </strong>
        </p>
        <h4>
            <p th:text="#{titre.part1}">
                Opérations arithmétiques sur deux nombres réels A et B positifs ou nuls
            </p>
        </h4>
        <form id="formulaire" name="formulaire" ... ">
...
        </form>
        <hr />
        <div id="resultats" />
    </body>
</html>
  • lignes 7-12 : les bibliothèques jQuery de validation et d'internationalisation (cultures) ;
  • ligne 15 : la bibliothèque [client-validation] construite au paragraphe 6.3 ;
  • ligne 14 : la bibliothèque jSON utilisée par la bibliothèque [client-validation]. Elle est facultative si les logs de validation ont été désactivés ;
  • ligne 13 : la bibliothèque [Unobtrusive Ajax] de Microsoft. Cette bibliothèque permet parfois de s'affranchir d'écrire du Javascript ;
  • ligne 16 : un fichier jS pour nos propres besoins ;
  • lignes 17-22 : pour gérer côté client les cultures [fr-FR] et [en-US]. Nous avons déjà rencontré ce code ;
  • ligne 27 : un message paramétré. Nous les avons étudiés au paragraphe 5.18 ;
  • lignes 36-38 : le formulaire sur lequel nous allons revenir ;
  • ligne 40 : la zone du document dans lequel le Javascript placera la réponse du serveur ;

7.2.4. Le formulaire

 

Dans la vue [vue-01.xml], le formulaire est le suivant :


<form id="formulaire" name="formulaire" th:action="@{/ajax-02.html}" method="post" th:object="${actionModel01}" th:attr="data-ajax='true',data-ajax-loading='#loading',data-ajax-loading-duration='0',data-ajax-method='post',data-ajax-mode='replace',data-ajax-update='#resultats', data-ajax-begin='beforeSend',data-ajax-complete='afterComplete' ">
    <table>
        <thead>
            <tr>
                <th>
                    <span th:text="#{valeur.a}"></span>
                </th>
                <th>
                    <span th:text="#{valeur.b}"></span>
                </th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>
                    <input type="text" th:field="*{a}" th:value="*{a}" data-val="true"
                        th:attr="data-val-required=#{NotNull},data-val-number=#{typeMismatch},data-val-min=#{actionModel01.a.min},data-val-min-value=#{actionModel01.a.min.value}" />
                </td>
                <td>
                    <input type="text" th:field="*{b}" th:value="*{b}" data-val="true"
                        th:attr="data-val-required=#{NotNull},data-val-number=#{typeMismatch},data-val-min=#{actionModel01.b.min},data-val-min-value=#{actionModel01.b.min.value}" />
                </td>
            </tr>
            <tr>
                <td>
                    <span class="field-validation-valid" data-valmsg-for="a" data-valmsg-replace="true"></span>
                    <span th:if="${#fields.hasErrors('a')}" th:errors="*{a}" class="error">Donnée
                        erronée
                    </span>
                </td>
                <td>
                    <span class="field-validation-valid" data-valmsg-for="b" data-valmsg-replace="true"></span>
                    <span th:if="${#fields.hasErrors('b')}" th:errors="*{b}" class="error">Donnée
                        erronée
                    </span>
                </td>
            </tr>
        </tbody>
    </table>
    <p>
        <input type="submit" th:value="#{action.calculer}" value="Calculer"></input>
        <img id="loading" style="display: none" src="/images/loading.gif" />
        <a href="javascript:postForm()" th:text="#{action.calculer}">Calculer</a>
    </p>
</form>

qui produit le HTML suivant :


<form id="formulaire" name="formulaire" method="post" data-ajax-update="#resultats" data-ajax-complete="afterComplete"     data-ajax-begin="beforeSend" data-ajax-loading-duration="0" data-ajax-mode="replace" data-ajax="true" data-ajax-method="post" data-ajax-loading="#loading" action="/ajax-02.html">
    <table>
        <thead>
            <tr>
                <th>
                    <span>valeur de A</span>
                </th>
                <th>
                    <span>valeur de B</span>
                </th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>
                    <input type="text" data-val="true" data-val-min="Le nombre doit être supérieur ou égal à 0" data-val-number="Format invalide" data-val-min-value="0" data-val-required="Le champ est obligatoire" value="" id="a" name="a" />
                </td>
                <td>
                    <input type="text" data-val="true" data-val-min="Le nombre doit être supérieur ou égal à 0" data-val-number="Format invalide" data-val-min-value="0" data-val-required="Le champ est obligatoire" value="" id="b" name="b" />
                </td>
            </tr>
            <tr>
                <td>
                    <span class="field-validation-valid" data-valmsg-for="a" data-valmsg-replace="true"></span>

                </td>
                <td>
                    <span class="field-validation-valid" data-valmsg-for="b" data-valmsg-replace="true"></span>

                </td>
            </tr>
        </tbody>
    </table>
    <p>
        <input type="submit" value="Calculer" />
        <img id="loading" style="display: none" src="/images/loading.gif" />
        <a href="javascript:postForm()">Calculer</a>
    </p>
</form>
  • ligne 16 : au champ [a] sont associés les validateurs [required], [number] et [min] ;
  • ligne 19 : idem pour le champ [b] ;

Les divers messages sont trouvés dans les fichiers [messages.properties] du projet :

  

[messages_fr.properties]


NotNull=Le champ est obligatoire
typeMismatch=Format invalide
actionModel01.a.min=Le nombre doit être supérieur ou égal à 0
DecimalMin.actionModel01.a=Le nombre doit être supérieur ou égal à 0
DecimalMax.actionModel01.b=Le nombre doit être supérieur ou égal à 0
actionModel01.b.min=Le nombre doit être supérieur ou égal à 0
valeur.a=valeur de A
valeur.b=valeur de B
actionModel01.a.min.value=0
actionModel01.b.min.value=0
labelHeureCalcul=Heure de calcul : 
LabelErreur=Une erreur s''est produite : [{0}]
labelAplusB=A+B=
labelAmoinsB=A-B=
labelAfoisB=A*B=
labelAdivB=A/B=
titre.part1=Opérations arithmétiques sur deux nombres réels A et B positifs ou nuls
labelHeureGetCulture=Heure de chargement : [{0}], culture : [{1}]
action.calculer=Calculer
erreur.aleatoire=erreur aléatoire
resultats=Résultats
resultats.erreur=Une erreur s''est produite : [{0}]
resultats.titre=Résultats
message.zone=Nombre d'accès : 

[messages_en.properties]


NotNull=Required field
typeMismatch=Invalid format
actionModel01.a.min=The number must be greater or equal to 0
DecimalMin.actionModel01.a=The number must be greater or equal to 0
DecimalMax.actionModel01.b=The number must be greater or equal to 0
actionModel01.b.min=The number must be greater or equal to 0
valeur.a=A value
valeur.b=B value
actionModel01.a.min.value=0
actionModel01.b.min.value=0
labelHeureCalcul=Computing hour: 
LabelErreur=There was an error: [{0}]
labelAplusB=A+B=
labelAmoinsB=A-B=
labelAfoisB=A*B=
labelAdivB=A/B=
titre.part1=Arithmetic operations on two positive or equal to zero real numbers
labelHeureGetCulture=Loading hour: [{0}], culture: [{1}]
action.calculer=Calculate
erreur.aleatoire=randomly generated error
resultats=Results
resultats.erreur=Some error occurred : [{0}]
resultats.titre=Results
message.zone=Number of hits:

Maintenant, étudions les attributs de la balise [form] :


<form id="formulaire" name="formulaire" method="post" data-ajax-update="#resultats" data-ajax-complete="afterComplete" data-ajax-begin="beforeSend" data-ajax-loading-duration="0" data-ajax-mode="replace" data-ajax="true" data-ajax-method="post" data-ajax-loading="#loading" action="/ajax-02.html">

On reconnaît les attributs classiques de la balise [form] :


<form id="formulaire" name="formulaire" method="post" action="/ajax-02.html">

On peut noter tout de suite que si sur le navigateur qui affiche la page, le Javascript est désactivé, alors le formulaire sera posté à l'URL [/ajax-02.html]. Maintenant, analysons les autres attributs :


<form ... data-ajax-update="#resultats" data-ajax-complete="afterComplete" data-ajax-begin="beforeSend" data-ajax-loading-duration="0" data-ajax-mode="replace" data-ajax="true" data-ajax-method="post" data-ajax-loading="#loading">

Les attributs [data-ajax-xxx] sont gérés par la bibliothèque jS [unobtrusive-ajax] qui a été importée par la vue [vue-01.xml] :


<script type="text/javascript" src="/js/jquery/jquery.unobtrusive-ajax.js"></script>

Lorsque les attributs [data-ajax-xxx] sont présents, le [submit] du formulaire va être exécuté par un appel Ajax de la bibliothèque [unobtrusive-ajax]. La signification des paramètres est la suivante :

  • [data-ajax="true"] : c'est la présence de cet attribut qui fait que le [submit] du formulaire va être ajaxifié ;
  • [data-ajax-method="post"] : la méthode du [submit]. L'URL du post sera celle de l'attibut [action="/ajax-02.html"] ;
  • [data-ajax-loading="#loading"] : l'id d'une zone à afficher en attendant la réponse du serveur. La zone identifiée par [loading] dans la vue [vue-01.xml] est la suivante :

<img id="loading" style="display: none" src="/images/loading.gif" />

C'est une image animée d'attente qui sera affichée tant que la réponse du serveur n'aura pas été reçue ;

  • [data-ajax-loading-duration="0"] : le temps d'attente en millisecondes avant que la zone [data-ajax-loading="#loading"] soit affichée. Ici, elle sera affichée dès que l'attente commencera ;
  • [data-ajax-begin="beforeSend"] : la fonction jS à exécuter avant de faire le [submit] ;
  • [data-ajax-complete="afterComplete"] : la fonction jS à exécuter lorsque la réponse a été reçue ;
  • [data-ajax-update="#resultats"] : l'identifiant de la zone où le résultat envoyé par le serveur sera placé. La vue [vue-01.xml] possède la zone suivante :

<div id="resultats" />
  • [data-ajax-mode="replace"] : le mode d'insertion du résultat dans la zone précédente. Le mode [replace] fera que le résultat 'écrasera' ce qu'il y avait avant dans la zone d'id [resultats] ;

Il faut noter que le [submit] Javascript n'aura lieu que si les validateurs ont déclaré valides les valeurs testées.

La bibliothèque jS [unobtrusive-ajax] a deux objectifs :

  • faire en sorte que le formulaire s'adapte correctement aux deux possibilités : activation ou non du Javascript sur le navigateur ;
  • éviter d'écrire du Javascript. Nous verrons qu'ici, cela n'a pas pu être évité.

7.2.5. L'action [/ajax-02]

Nous avons vu que les valeurs postées étaient envoyées à l'action [/ajax-02]. Celle-ci est la suivante :


@RequestMapping(value = "/ajax-02", method = RequestMethod.POST, produces = "text/html; charset=UTF-8")
    public String ajax02(ActionModel01 formulaire, Locale locale, Model modèle, HttpSession session) throws InterruptedException {
        // tempo ?
        Integer tempo = (Integer) session.getAttribute("tempo");
        if (tempo != null && tempo > 0) {
            Thread.sleep(tempo);
        }
        // on prépare le modèle de la prochaine vue
        Resultats résultats = new Resultats();
        modèle.addAttribute("resultats", résultats);
        // on fixe la locale
        setLocale(locale, modèle, résultats);
        // heure
        résultats.setHeurePost(new SimpleDateFormat("hh:mm:ss").format(new Date()));
        ...
}
  • nous allons simplifier dans un premier temps : nous supposons que le POST qui a lieu a bien été fait par le Javascript de la vue [vue-01.xml]. Nous reviendrons sur cette hypothèse un peu plus tard ;
  • ligne 2 : les valeurs [a,b] postées sont mises dans le modèle [ActionModel01] ;
  • lignes 4-7 : si l'utilisateur avait fixé une temporisation lors d'un précédent GET, celle-ci est récupérée dans la session et on fait la temporisation (ligne 6). Le but de celle-ci est de permettre à l'utilisateur de voir l'effet de l'attribut [data-ajax-loading="#loading"] dans le formulaire ;
  • lignes 9-10 : on met un attribut [resultats] dans le modèle ;
  • ligne 12 : on met la culture [fr-FR] ou [en-US] dans le modèle ;
  • ligne 14 : on met l'heure du POST dans le modèle ;

Rappelons le type [Resultats] mis dans le modèle :


public class Resultats {

    // données
    private String aplusb;
    private String amoinsb;
    private String amultiplieparb;
    private String adiviseparb;
    private String heureGet;
    private String heurePost;
    private String erreur;
    private String vue;
    private String culture;

    // getters et setters
...
}

Le code de l'action [/ajax-02] se poursuit ainsi :


@RequestMapping(value = "/ajax-02", method = RequestMethod.POST, produces = "text/html; charset=UTF-8")
    public String ajax02(ActionModel01 formulaire, BindingResult result, Locale locale, Model modèle,    HttpSession session) throws InterruptedException {
...
        résultats.setHeurePost(new SimpleDateFormat("hh:mm:ss").format(new Date()));
        // on génère une erreur une fois sur deux
        int val = new Random().nextInt(2);
        if (val == 0) {
            // on renvoie un message d'erreur
            résultats.setErreur("erreur.aleatoire");
            return "vue-03";
        }
...
    }
  • lignes 6-11 : pour l'exemple on montre comment renvoyer une page d'erreur au client jS. Une fois sur deux, on renvoie la vue [vue-03.xml] suivante :

On notera ligne 9, que ce n'est pas un message qu'on met dans le modèle, mais une clé de message :

[messages_fr.properties]


erreur.aleatoire=erreur aléatoire

[messages_fr.properties]


erreur.aleatoire=randomly generated error

Le code de la vue [vue-03.xml] est le suivant :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <body>
        <h4>Résultats</h4>
        <p>
            <strong>
                <span th:text="#{labelHeureCalcul}">Heure de calcul :</span>
                <span id="heureCalcul" th:text="${resultats.heurePost}"></span>
            </strong>
        </p>
        <p style="color: red;">
            <span th:text="#{LabelErreur(#{${resultats.erreur}})}">Une erreur s'est produite :</span>
            <!-- <span id="erreur" th:text="${resultats.erreur}"></span> -->
        </p>
    </body>
</html>

  • ligne 12, on notera un message paramétré par une clé de message qui est elle-même calculée. Nous avons introduit cette notion au paragraphe 5.18, page 170.

Le code de l'action [/ajax-02] se poursuit ainsi :


@RequestMapping(value = "/ajax-02", method = RequestMethod.POST, produces = "text/html; charset=UTF-8")
    public String ajax02(ActionModel01 formulaire, BindingResult result, Locale locale, Model modèle,    HttpSession session) throws InterruptedException {
...
        // on récupère les valeurs postées
        double a = formulaire.getA();
        double b = formulaire.getB();
        // on construit le modèle
        résultats.setAplusb(String.valueOf(a + b));
        résultats.setAmoinsb(String.valueOf(a - b));
        résultats.setAmultiplieparb(String.valueOf(a * b));
        try {
            résultats.setAdiviseparb(String.valueOf(a / b));
        } catch (RuntimeException e) {
            résultats.setAdiviseparb("NaN");
        }
        // on affiche la vue
        return "vue-02";
    }
  • lignes 5-15 : les quatre opération arithmétiques sont faites sur les nombres [a,b] et encapsulées dans l'instance [Resultats] du modèle ;
  • ligne 17 : on renvoie la vue [vue-02.xml] suivante :

La vue [vue-02.xml] est la suivante :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <body>
        <h4>Résultats</h4>
        <p>
            <strong>
                <span th:text="#{labelHeureCalcul}">Heure de calcul :</span>
                <span id="heureCalcul" th:text="${resultats.heurePost}"></span>
            </strong>
        </p>
        <p>
            <span th:text="#{labelAplusB}">A+B=</span>
            <span id="aplusb" th:text="${resultats.aplusb}"></span>
        </p>
        <p>
            <span th:text="#{labelAmoinsB}">A-B=</span>
            <span id="amoinsb" th:text="${resultats.amoinsb}"></span>
        </p>
        <p>
            <span th:text="#{labelAfoisB}">A*B=</span>
            <span id="amultiplieparb" th:text="${resultats.amultiplieparb}"></span>
        </p>
        <p>
            <span th:text="#{labelAdivB}">A/B=</span>
            <span id="adiviseparb" th:text="${resultats.adiviseparb}"></span>
        </p>
    </body>
</html>

Que le résultat soit la vue [vue-02.xml] ou la vue [vue-03.xml], ce résultat HTML est placé dans la zone identifiée par [resultats] dans la vue [vue-01.xml], ceci à cause de l'attribut [data-ajax-update="#resultats"] du formulaire.

7.2.6. Le POST des valeurs saisies

On a une difficulté ici avec les valeurs postées. On travaille avec deux cultures [fr-FR] et [en-US] qui écrivent différemment les nombres réels. Nous nous étions attaqués à cette difficulté lorsqu'il avait fallu dans le pararaphe 6.3, page 190, poster des réels dans deux cultures différentes. Nous allons reprendre ici des outils utilisés alors. Mais on a une difficulté supplémentaire : on n'a pas accès à la méthode qui opère le POST des valeurs saisies. C'est la raison pour laquelle, nous avons ajouté les attributs suivants à la balise du formulaire :

  • [data-ajax-begin="beforeSend"] : la fonction jS à exécuter avant de faire le [submit] ;
  • [data-ajax-complete="afterComplete"] : la fonction jS à exécuter lorsque la réponse a été reçue ;

Nous n'avons pas accès à la fonction jS qui va poster les valeurs saisies, mais nous pouvons écrire deux fonctions jS :

  • [beforeSend] : une fonction jS exécutée avant le POST ;
  • [afterComplete] : une fonction jS exécutée à réception de la réponse au POST ;

Ces deux fonctions sont placées dans un fichier [local1.js] :

  

Le fichier [local1.js] initialise l'environnement jS de la vue [vue-01.xml] de la façon suivante :


// données globales
var loading;
var formulaire;
var résultats;
var a, b;

// au chargement du document
$(document).ready(function() {
    // on récupère les références des différents composants de la page
    loading = $("#loading");
    formulaire = $("#formulaire");
    resultats = $('#resultats');
    a = $("#a");
    b = $("#b");
    // on cache certains éléments
    loading.hide();
    // on parse les validateurs du formulaire
    $.validator.unobtrusive.parse(formulaire);
    // on gère deux locales [fr_FR, en_US]
    // les réels [a,b] sont envoyés par le serveur au format anglo-saxon
    // on les met au format français si nécessaire
    checkCulture(2);
});
  • ligne 22 : la fonction [checkCulture] est présentée un peu plus loin ;

La fonction jS [beforeSend] sera la suivante :


function beforeSend(jqXHR, settings) {
    // avant le POST
    // les nombres doivent être postés au format anglo-saxon
    var culture = Globalize.culture().name;
    if (culture === 'fr-FR') {
        checkCulture(1);
        settings.data = formulaire.serialize();
    }
}

function afterComplete(jqXHR, settings) {
    ...
}

function checkCulture(mode) {
    if (mode == 1) {
        // on met les nombres [a,b] au format anglo-saxon
        var value1 = a.val().replace(",", ".");
        a.val(value1);
        var value2 = b.val().replace(",", ".");
        b.val(value2);
    }
    if (mode == 2) {
...
    }
}
  • lignes 4-6 : on vérifie si la culture de la vue est [fr-FR]. Dans ce cas, il faut changer les valeurs postées. En effet, si l'utilisateur a saisi [1,6], il faut poster la valeur [1.6] sinon la valeur [1,6] sera refusée côté serveur. Il suffit pour cela de changer la virgule des valeurs postées en point décimal (lignes 18-21) ;
  • on ne peut s'en tenir là. En effet, lorsque la fonction [beforeSend] est appelée, la chaîne des valeurs postées [a=val1&b=valB] a déjà été construite. Il nous faut donc la modifier. Cela se fait à l'aide du second paramètre [settings] de la fonction ;
  • ligne 7 : [settings.data] (settings est un paramètre de la fonction) représente la chaîne postée. On recrée cette chaîne avec l'expression [formulaire.serialize()]. Cette expression parcourt le formulaire à la recherche des valeurs à poster et construit la chaîne du POST. Elle va alors prendre les nouvelles valeurs de [a,b] avec des points décimaux ;

Si on ne fait rien de plus, le serveur va envoyer sa réponse qui va être correctement affichée. Seulement maintenant les valeurs de [a,b] sont avec le point décimal alors qu'on est toujours dans la culture [fr-FR]. Si donc l'utilisateur ne s'en aperçoit pas et reclique sur [Calculer], les validateurs lui répondent que les valeurs [a,b] sont invalides. Ce qui est juste. C'est là qu'intervient la fonction [afterComplete] exécutée à la réception du résultat :


function beforeSend(jqXHR, settings) {
    // avant le POST
...
}

function afterComplete(jqXHR, settings) {
    // après le POST
    // les nombres doivent être remis au format français si nécessaire
    var culture = Globalize.culture().name;
    if (culture === 'fr-FR') {
        checkCulture(2);
    }
}

function checkCulture(mode) {
    if (mode == 1) {
...
    }
    if (mode == 2) {
        // on met les nombres au format français
        var value1 = a.val().replace(".", ",");
        a.val(value1);
        var value2 = b.val().replace(".", ",");
        b.val(value2);
    }
}
  • lignes 9-12 : si la culture de la vue est [fr-FR], on remet les nombres [a,b] au format français.

7.2.7. Tests

Voici quelques copies d'écran de tests :

  • en [1], la réponse du serveur ;
  • en [2], la réponse du serveur avec un message d'erreur ;
  • en [3], on fixe une temporisation de 5 secondes. Cela veut dire que le serveur attendra 5 secondes avant d'envoyer sa réponse. Dans la balise [form], nous avons utilisé l'attribut [data-ajax-loading='#loading']. Le paramètre [loading] est l'identifiant d'une zone qui est :
    • affichée pendant toute la durée de l'attente ;
    • cachée après réception de la réponse du serveur ;

Ici [loading] est l'identifiant d'une image animée qu'on voit en [4].

7.2.8. Désactivation du Javascript avec la culture [en-US]

Que se passe-t-il si on désactive le Javascript du navigateur ?

Le POST des valeurs saisies va se faire selon la balise [form] dont les attributs [data-ajax-attr] ne vont pas être utilisés. Tout se passe comme si on avait la balise [form] suivante :


<form id="formulaire" name="formulaire" method="post" action="/ajax-02.html">

Les valeurs saisies vont donc être postées à l'action [/ajax-02]. Elles n'auront pas été vérifiées côté client. Ce sont donc les validateurs côté serveur qui vont intervenir. Ils intervenaient déjà auparavant mais sur des valeurs déjà validées côté client, donc correctes. Ce n'est plus le cas.

Nous modifions l'action [/ajax-02] de la façon suivante :


@RequestMapping(value = "/ajax-02", method = RequestMethod.POST, produces = "text/html; charset=UTF-8")
    public String ajax02(@Valid ActionModel01 formulaire, BindingResult result, Locale locale, Model modèle,    HttpSession session, HttpServletRequest request) throws InterruptedException {
        // requête Ajax ?
        boolean isAjax = "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
        ...
    }
  • ligne 4 : l'action [/ajax-02] peut donc désormais être appelé via un POST Ajax ou via un POST classique. Il nous faut savoir différencier ces deux cas. On le fait avec les entêtes HTTP envoyés par le navigateur client ;

Lorsqu'on regarde les échanges réseau dans la console de développement de Chrome (Ctrl-Maj-I) alors que le Javascript est activé, on voit que le client envoie les entêtes suivants au moment du POST :

On voit ci-dessus que :

  • un entête [X-Requested-With] a été envoyé [1] ;
  • un paramètre [X-Requested-With] a été ajouté aux valeurs postées [2] ;

Ceci n'est pas fait dans le cas d'un POST classique. On a donc deux possibilités pour récupérer l'information : la récupérer dans les entêtes HTTP ou dans les valeurs postées. La ligne 4 de l'action [/ajax-02] a choisi la première solution.

Continuons avec le code de cette action :


@RequestMapping(value = "/ajax-02", method = RequestMethod.POST, produces = "text/html; charset=UTF-8")
    public String ajax02(@Valid ActionModel01 formulaire, BindingResult result, Locale locale, Model modèle, HttpSession session, HttpServletRequest request) throws InterruptedException {
        // requête Ajax ?
        boolean isAjax = "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
        // tempo ?
        Integer tempo = (Integer) session.getAttribute("tempo");
        if (tempo != null && tempo > 0) {
            Thread.sleep(tempo);
        }
        // on prépare le modèle de la prochaine vue
        Resultats résultats = new Resultats();
        modèle.addAttribute("resultats", résultats);
        // on fixe la locale
        setLocale(locale, modèle, résultats);
        // heure
        String heure = new SimpleDateFormat("hh:mm:ss").format(new Date());
        résultats.setHeurePost(heure);
        résultats.setHeureGet(heure);
        // requête valide ?
        if (!isAjax && result.hasErrors()) {
            return "vue-01";
        }
...
  • ligne 2 : le paramètre [@Valid ActionModel01 formulaire] actionne les validateurs côté serveur ;
  • lignes 20-22 : si l'appel n'est pas un appel Ajax et que la validation a échoué, alors on renvoie la vue [vue-01.xml] avec les messages d'erreur.

Voici un exemple :

Continuons l'étude de l'action [/ajax-02] :


@RequestMapping(value = "/ajax-02", method = RequestMethod.POST, produces = "text/html; charset=UTF-8")
    public String ajax02(@Valid ActionModel01 formulaire, BindingResult result, Locale locale, Model modèle,    HttpSession session, HttpServletRequest request) throws InterruptedException {
        // requête Ajax ?
        boolean isAjax = "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
...
        // requête valide ?
        if (!isAjax && result.hasErrors()) {
            return "vue-01";
        }
        // on génère une erreur une fois sur deux
        int val = new Random().nextInt(2);
        if (val == 0) {
            // on renvoie un message d'erreur
            résultats.setErreur("erreur.aleatoire");
            if (isAjax) {
                return "vue-03";
            } else {
                résultats.setVue("vue-03");
                return "vue-01";
            }
        }
...
  • ligne 14 : on génère une erreur aléatoire ;
  • ligne 16 : dans le cas d'un appel Ajax, on retourne la vue [vue-03.xml] qui sera placée dans la zone identifiée par [resultats] ;
  • ligne 18 : dans le cas d'un appel non Ajax, on met la vue à afficher dans le modèle de type [Resultats] ;
  • ligne 19 : on rend de nouveau la vue [vue-01.xml] ;

La vue [vue-01.xml] est modifiée de la façon suivante :


<div id="resultats" />
<div th:if="${resultats.vue}=='vue-02'" th:include="vue-02" />
<div th:if="${resultats.vue}=='vue-03'" th:include="vue-03" />
  • ligne 3 : la vue [vue-03.xml] va être insérée sous la zone [resultats] ;

Voici un exemple :

On notera que désormais les heures [1] et [2] sont identiques.

Continuons l'étude de l'action [/ajax-02] :


    @RequestMapping(value = "/ajax-02", method = RequestMethod.POST, produces = "text/html; charset=UTF-8")
    public String ajax02(@Valid ActionModel01 formulaire, BindingResult result, Locale locale, Model modèle, HttpSession session, HttpServletRequest request) throws InterruptedException {
        // requête Ajax ?
        boolean isAjax = "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
...
        // on récupère les valeurs postées
        double a = formulaire.getA();
        double b = formulaire.getB();
        // on construit le modèle
        résultats.setAplusb(String.valueOf(a + b));
        résultats.setAmoinsb(String.valueOf(a - b));
        résultats.setAmultiplieparb(String.valueOf(a * b));
        try {
            résultats.setAdiviseparb(String.valueOf(a / b));
        } catch (RuntimeException e) {
            résultats.setAdiviseparb("NaN");
        }
        // on affiche la vue
        if (isAjax) {
            return "vue-02";
        } else {
            résultats.setVue("vue-02");
            return "vue-01";
        }
}
  • lignes 7-17 : les résultats des quatre opération arithmétiques sont mises dans le modèle ;
  • lignes 22-23 : on rend la vue [vue-01.xml] (ligne 22) en lui insérant la vue [vue-02.xml] (ligne 22) ;

Cette insertion de fait de la façon suivante dans [vue-01.xml] :


<div id="resultats" />
<div th:if="${resultats.vue}=='vue-02'" th:include="vue-02" />
<div th:if="${resultats.vue}=='vue-03'" th:include="vue-03" />
  • ligne 2 : la vue [vue-02.xml] va être insérée sous la zone [resultats] ;

Voici un exemple d'exécution :

 

7.2.9. Désactivation du Javascript avec la culture [fr-FR]

Avec la culture [fr-FR] on a le problème suivant :

Les valeurs saisies au format français ont été déclarées invalides. En effet, le serveur attend des réels au format anglo-saxon. La solution est assez complexe. Nous allons créer un filtre qui va :

  • intercepter la requête ;
  • changer les virgules dans les valeurs postées [a] et [b] en point décimal ;
  • puis passer la nouvelle requête à l'action qui doit la traiter ;

Tout d'abord, nous introduisons un champ caché dans la vue [vue-01.xml] :


<form ...>
...
</p>
    <!-- champs cachés -->
    <input type="hidden" id="culture" name="culture" th:value="${resultats.culture}"></input>
</form>
  • ligne 5 : la culture [fr-FR] ou [en-US] est mise dans le champ d'attribut [name=culture]. Comme la balise [input] est dans le formulaire, sa valeur va être postée avec les valeurs de [a] et [b]. On aura alors une chaîne postée de la forme :
culture=fr-FR&a=12,7&b=20,78

Il est important de comprendre ce point.

Ensuite nous incluons un filtre dans la configuration de l'application :

  

Le fichier [Config] est modifié de la façon suivante :


@Configuration
@ComponentScan({ "istia.st.springmvc.controllers", "istia.st.springmvc.models" })
@EnableAutoConfiguration
public class Config extends WebMvcConfigurerAdapter {
...
    @Bean
    public Filter cultureFilter() {
        return new CultureFilter();
    }
}
  • ligne 7 : le fait que le bean [cultureFilter] rende un type [Filter] fait de lui un filtre. Le bean, lui, peut porter un nom quelconque ;

L'étape suivante est de créer le filtre lui-même :

  

package istia.st.springmvc.config;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.web.filter.OncePerRequestFilter;

public class CultureFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // handler suivant
        filterChain.doFilter(new CultureRequestWrapper(request), response);
    }
}
  • ligne 12 : nous étendons la classe [OncePerRequestFilter] qui est une classe Spring et ce que nous devons faire est de rédéfinir la méthode [doFilterInternal] de cette classe ;
  • ligne 15 : la méthode [doFilterInternal] reçoit trois informations :
    • [HttpServletRequest request] : la requête à filtrer. Celle-ci ne peut être modifiée,
    • [HttpServletResponse response] : la réponse qui sera faite au serveur. Le filtre peut décider de la faire lui-même,
    • [FilterChain filterChain] : la chaîne des filtres. Une fois que la méthode [doFilterInternal] a fini son travail, elle doit passer la requête au filtre suivant de la chaîne des filtres ;
  • ligne 18 : on crée une nouvelle requête à partir de celle qu'on a reçue [new CultureRequestWrapper(request)] et on la passe au filtre suivant. Parce qu'on ne peut modifier la requête initiale [HttpServletRequest request], on en crée une nouvelle ;

La classe [CultureRequestWrapper] est la suivante :

  

package istia.st.springmvc.config;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;

public class CultureRequestWrapper extends HttpServletRequestWrapper {

    public CultureRequestWrapper(HttpServletRequest request) {
        super(request);
    }

    @Override
    public String[] getParameterValues(String name) {
        // valeurs postées a et b
        if (name != null && (name.equals("a") || name.equals("b"))) {
            String[] values = super.getParameterValues(name);
            String[] newValues = values.clone();
            newValues[0] = newValues[0].replace(",", ".");
            return newValues;
        }
        // autres cas
        return super.getParameterValues(name);
    }

}
  • ligne 6 : la classe [CultureRequestWrapper] étend la classe [HttpServletRequestWrapper] et va redéfinir certaines de ces méthodes ;
  • lignes 8-10 : le constructeur qui reçoit la requête à filtrer et la passe à la classe parent ;
  • il faut comprendre ici que la requête filtrée va au final aboutir comme paramètre d'entrée d'une classe appelée une servlet. Avec Spring MVC, cette servlet est de type [DispatcherServlet]. Cette classe dispose de diverses méthodes pour récupérer les paramètres de la requête : [getParameter, getParameterMap, getParameterNames, getParameterValues, ...]. Il faut redéfinir la méthode utilisée par la servlet. Il faudrait lire pour cela le code de la classe [DispatcherServlet]. Je ne l'ai pas fait et j'ai redéfini diverses méthodes. C'est finalement la méthode [getParameterValues] qui a été redéfinie ;
  • ligne 13 : la méthode [getParameterValues] reçoit en paramètre, le nom d'un des paramètres rendus par la méthode [getParameterNames] et doit rendre le tableau de ses valeurs. En effet, on sait qu'un paramètre peut être présent en plusieurs exemplaires dans une requête ;
  • ligne 18 : on remplace la virgule par un point décimal ;

Voici un exemple d'exécution :

  • en [1], les valeurs [a,b] sont saisies au format français ;
  • en [2], les résultats ;
  • en [3], le serveur a renvoyé une page avec des nombres au format anglo-saxon.

Ce dernier problème peut-être résolu avec Thymeleaf de la façon suivante dans la vue [vue-01.xml]


<tr>
    <td>
        <input type="text" id="a" name="a"    th:value="${resultats.culture}=='fr-FR' and ${actionModel01.a}!=null? ${#strings.replace(actionModel01.a,'.',',')} : ${actionModel01.a}" data-val="true" th:attr="data-val-required=#{NotNull},data-val-number=#{typeMismatch},data-val-min=#{actionModel01.a.min},data-val-min-value=#{actionModel01.a.min.value}" />
    </td>
    <td>
        <input type="text" id="b" name="b" th:value="${resultats.culture}=='fr-FR' and ${actionModel01.b}!=null? ${#strings.replace(actionModel01.b,'.',',')} : ${actionModel01.b}" data-val="true" th:attr="data-val-required=#{NotNull},data-val-number=#{typeMismatch},data-val-min=#{actionModel01.b.min},data-val-min-value=#{actionModel01.b.min.value}" />
    </td>
</tr>

Il y a plusieurs modification à faire lignes 3 et 6. Nous allons raisonner sur la ligne 3 :

  • on avait écrit [th:field="*{a}"]. Le paramètre [th:field] fixe les attributs [id, name, value] de la balise HTML [input] générée. Ici, on veut gérer l'attribut [value] nous-mêmes. On fixe donc aussi les attributs [id, name] nous-mêmes ;
  • l'attribut [th:value] évalue une expression utilisant l'opérateur ternaire ?. On teste l'expression [${resultats.culture}=='fr-FR' and ${actionModel01.b}!=null]. Si elle est vraie on donne à l'attribut [value] la valeur de a [actionModel01.a] où le point décimal est remplacé par la virgule. Si elle est fausse, on donne à l'attribut [value] la valeur de a [actionModel01.a] sans modifications ;
  • ligne 6 : on refait la même chose pour le champ [b] ;

Voici un exemple d'exécution :

  • en [1], les nombres [a,b] ont gardé la notation française. Ce n'est pas le cas en [2] ;

Ce nouveau problème se gère de la même façon que le précédent. On modifie la vue [vue-03.xml] de la façon suivante :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <body>
        <h4 th:text="#{resultats}">Résultats</h4>
        <p>
            <strong>
                <span th:text="#{labelHeureCalcul}">Heure de calcul :</span>
                <span id="heureCalcul" th:text="${resultats.heurePost}"></span>
            </strong>
        </p>
        <p>
            <span th:text="#{labelAplusB}">A+B=</span>
            <span id="aplusb" th:text="${resultats.culture}=='fr-FR' and ${resultats.aplusb}!=null? ${#strings.replace(resultats.aplusb,'.',',')} : ${resultats.aplusb}"></span>
        </p>
        <p>
            <span th:text="#{labelAmoinsB}">A-B=</span>
            <span id="amoinsb"     th:text="${resultats.culture}=='fr-FR' and ${resultats.amoinsb}!=null? ${#strings.replace(resultats.amoinsb,'.',',')} : ${resultats.amoinsb}"></span>
        </p>
        <p>
            <span th:text="#{labelAfoisB}">A*B=</span>
            <span id="amultiplieparb" th:text="${resultats.culture}=='fr-FR' and ${resultats.amultiplieparb}!=null? ${#strings.replace(resultats.amultiplieparb,'.',',')} : ${resultats.amultiplieparb}"></span>
        </p>
        <p>
            <span th:text="#{labelAdivB}">A/B=</span>
            <span id="adiviseparb" th:text="${resultats.culture}=='fr-FR' and ${resultats.adiviseparb}!=null? ${#strings.replace(resultats.adiviseparb,'.',',')} : ${resultats.adiviseparb}"></span>
        </p>
    </body>
</html>

Voici un exemple :

On a désormais une application qui gère correctement deux cultures dans un environnement utilisant ou non du Javascript. Il a fallu pour cela complexifier de façon importante le code côté serveur. Par la suite, nous supposerons toujours que le Javascript du navigateur est activé. Cela permet des choses impossibles en mode serveur uniquement.

7.2.10. Gestion du lien [Calculer]

Examinons le lien [Calculer] de la page principale [vue-01.xml] :

Le code du lien [Calculer] dans la vue [vue-01.xml] est le suivant :


<a href="javascript:postForm()" th:text="#{action.calculer}">Calculer</a>

La fonction jS [postForm] est définie dans le fichier [local1.js] de la façon suivante :


// données globales
var loading;
var formulaire;
var résultats;
var a, b;

function postForm() {
    // formulaire valide ?
    if (!formulaire.validate().form()) {
        // formulaire invalide - terminé
        return;
    }
    // on gère deux locales [fr_FR, en_US]
    // les réels [a,b] doivent être postés au format anglo-saxon dans tous les cas
    // ils le seront par le filtre [CultureFilter]

    // on fait un appel Ajax à la main
    $.ajax({
        url : '/ajax-02',
        headers : {
            'X-Requested-With' : 'XMLHttpRequest'
        },
        type : 'POST',
        data : formulaire.serialize(),
        dataType : 'html',
        beforeSend : function() {
            loading.show();
        },
        success : function(data) {
            resultats.html(data);
        },
        complete : function() {
            loading.hide();
        },
        error : function(jqXHR) {
            résultats.html(jqXHR.responseText);
        }
    })
}
  • lignes 2-5 : rappelons que ces éléments ont été initialisés par la fonction [$(document).ready] ;
  • lignes 9-12 : on exécute les validateurs jS du formulaire. Si l'une des valeurs est invalide, l'expression [formulaire.validate().form()] rend la valeur false. Dans ce cas, le [submit] du formulaire est annulé ;
  • lignes 18-38 : on fait un appel Ajax à la main ;
  • ligne 19 : l'URL cible de l'appel Ajax ;
  • lignes 20-22 : un tableau d'entêtes HTTP à ajouter à ceux présents par défaut dans la requête HTTP. Ici, on ajoute l'entête HTTP qui va indiquer au serveur qu'on fait un appel Ajax ;
  • ligne 23 : la méthode HTTP utilisée ;
  • ligne 24 : les données postées. [formulaire.serialize] crée la chaîne à poster [culture=fr-FR&a=12,7&b=20,89] du formulaire d'id [formulaire]. On va retrouver ici le problème étudié précédemment : il faut que les valeurs [a,b] soient postées au format anglo-saxon. On sait que ce problème a été désormais réglé avec la création du filtre [cultureFilter] ;
  • ligne 25 : le type de données attendu en retour. On sait que le serveur va renvoyer un flux HTML ;
  • ligne 26 : la méthode à exécuter lorsque la requête démarre. Ici, on indique qu'il faut afficher le composant d'id [loading]. C'est l'image animée d'attente ;
  • ligne 29 : la méthode à exécuter en cas de succès de la requête Ajax. Le paramètre [data] est la réponse complète du serveur. On sait que c'est un flux HTML ;
  • ligne 30: on met à jour le composant d'id [résultats] avec le HTML du paramètre [data].
  • ligne 33 : on cache le signal d'attente ;
  • ligne 35 : fonction exécutée lorsque la réponse du serveur a été reçue, quelle que soit celle-ci, succès ou erreur ;
  • lignes 35-37 : en cas d'erreur (le serveur a renvoyé une réponse HTTP avec un statut indiquant qu'il y a eu erreur côté serveur), on affiche la réponse HTML du serveur dans la zone [resultats] ;

Voici un exemple d'exécution :

7.3. Mise à jour d'une page HTML avec un flux jSON

Dans l'exemple précédent, le serveur web répondait à la requête HTTP Ajax par un flux HTML. Dans ce flux, il y avait des données accompagnées par du formatage HTML. On se propose de reprendre l'exemple précédent avec cette fois-ci des réponses jSON (JavaScript Object Notation) ne contenant que les données. L'intérêt est qu'on transmet ainsi moins d'octets. On suppose que le Javascript est activé sur le navigateur.

7.3.1. L'action [/ajax-04]

L'action [/ajax-04] est identique à l'action [/ajax-01], si ce n'est qu'on affiche la vue [vue-04.xml] au lieu de la vue [vue-01.xml] :


@RequestMapping(value = "/ajax-04", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax04(Locale locale, Model modèle, HttpSession session, String tempo) {
        ...
        // vue
        return "vue-04";
    }

7.3.2. La vue [vue-04.xml]

 

La vue [vue-04.xml] reprend le corps de la vue [vue-01.xml] avec les différences suivantes :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        ...
        <script type="text/javascript" src="/js/local4.js"></script>
        <script th:inline="javascript">
            /*<![CDATA[*/
                    var culture = [[${resultats.culture}]];
                    Globalize.culture(culture);
                    /*]]>*/
        </script>
    </head>
    <body>
        <h2>Ajax - 04</h2>
    ...
        <form id="formulaire" name="formulaire" th:object="${actionModel01}">
...
            <p>
                <img id="loading" style="display: none" src="/images/loading.gif" />
                <a href="javascript:postForm()" th:text="#{action.calculer}">Calculer</a>
            </p>
            <!-- champs cachés -->
            <input type="hidden" id="culture" name="culture" th:value="${resultats.culture}"></input>
</form>
        <hr />
        <div id="entete">
            <h4 id="titre">Résultats</h4>
            <p>
                <strong>
                    <span id="labelHeureCalcul">Heure de calcul :</span>
                    <span id="heureCalcul">12:10:87</span>
                </strong>
            </p>
        </div>
        <div id="résultats">
            <p>
                A+B=
                <span id="aplusb">16,7</span>
            </p>
            <p>
                A-B=
                <span id="amoinsb">16,7</span>
            </p>
            <p>
                A*B=
                <span id="afoisb">16,7</span>
            </p>
            <p>
                A/B=
                <span id="adivb">16,7</span>
            </p>
        </div>
        <div id="erreur">
            <p style="color: red;">
                <span id="msgErreur">xx</span>
            </p>
        </div>
    </body>
</html>
  • ligne 5 : le Javascript de la vue est désormais dans le fichier [local4.js] ;
  • ligne 16 : la balise [form] n'a plus les paramètres [data-ajax-attr] de la bibliothèque [Unobtrusive Ajax]. Nous n'allons pas l'utiliser ici. La balise [form] n'a pas non plus les attributs [method] et [action] qui indiquent comment et où poster les valeurs saisies dans le formulaire. Ceci parce que celui-ci va être posté par une fonction jS (ligne 20) ;
  • lignes 26-57 : la zone d'id [resultats] qui auparavant était une zone vide contient désormais du code HTML pour afficher les résultats ;
  • lignes 26-34 : l'entête des résultat où l'heure de calcul est affichée ;
  • lignes 35-52 : les résultats des quatre opérations arithmétiques ;
  • lignes 53-57 : un éventuel message d'erreur envoyé par le serveur ;

Le code jS exécuté au chargement de la vue [vue-04.xm] est dans le fichier [local4.js]. C'est le suivant :


// données globales
    var loading;
    var formulaire;
    var résultats;
    var titre;
    var labelHeureCalcul;
    var heureCalcul;
    var aplusb;
    var amoinsb;
    var afoisb;
    var adivb;
    var msgErreur;

// au chargement du document
$(document).ready(function() {
    // on récupère les références des différents composants de la page
    loading = $("#loading");
    formulaire = $("#formulaire");
    résultats = $('#résultats');
    titre=$("#titre");
    labelHeureCalcul=$("#labelHeureCalcul");
    heureCalcul=$("#heureCalcul");
    aplusb=$("#aplusb");
    amoinsb=$("#amoinsb");
    afoisb=$("#afoisb");
    adivb=$("#adivb");
    msgErreur=$("#msgErreur");
    // on cache certains éléments
    résultats.hide();
    erreur.hide();
    loading.hide();
});
  • lignes 17-27 : on récupère les références jQuery de tous les éléments de la page ;
  • ligne 29 : la zone des résultats est cachée ;
  • ligne 30 : ainsi que la zone de l'erreur ;
  • ligne 31 : ainsi que l'image animée d'attente ;
  • lignes 2-12 : les références récupérées sont faites globales afin que les autres fonctions puissent en disposer ;

7.3.3. La fonction jS [postForm]

Le lien [Calculer] est le suivant :


<p>
    <img id="loading" style="display: none" src="/images/loading.gif" />
    <a href="javascript:postForm()" th:text="#{action.calculer}">Calculer</a>
</p>

La fonction jS [postForm] est définie dans le fichier [local.js] de la façon suivante :


function postForm() {
    // formulaire valide ?
    if (!formulaire.validate().form()) {
        // formulaire invalide - terminé
        return;
    }
    // on fait un appel Ajax à la main
    $.ajax({
        url : '/ajax-05',
        headers : {
            'Accept' : 'application/json'
        },
        type : 'POST',
        data : formulaire.serialize(),
        dataType : 'json',
        beforeSend : onBegin,
        success : onSuccess,
        error : onError,
        complete : onComplete
    })
}

// avant l'appel Ajax
function onBegin() {
...
}

// à réception de la réponse du serveur
// en cas de succès
function onSuccess(data) {
...
}

// à réception de la réponse du serveur
// en cas d'échec
function onError(jqXHR) {
...
}

// après [onSuccess, onError]
function onComplete() {
...
}
  • lignes 3-6 : avant de poster les valeurs saisies, on les vérifie. Si elles sont incorrectes, on ne fait pas le POST du formulaire ;
  • ligne 9 : les valeurs saisies sont envoyées à l'action [/ajax-05] que nous détaillons un peu plus loin ;
  • lignes 10-12 : un entête HTTP pour dire au serveur qu'on attend une réponse au format jSON ;
  • ligne 13 : les valeurs saisies vont être postées ;
  • ligne 14 : sérialisation des valeurs saisies en une chaîne prête à être postée [a=1,6&b=2,4&culture=fr-FR] ;
  • ligne 15 : le type de la réponse envoyée par le serveur. Ce sera du jSON ;
  • ligne 16 : la fonction à exécuter avant le POST ;
  • ligne 17 : la fonction à exécuter à réception de la réponse du serveur si celle-ci est un succès. Le 'succès' d'une requête HTTP est mesuré à l'aune du statut de la réponse HTTP du serveur. Une réponse [HTTP/1.1 200 OK ] est une réponse de succès. Une réponse [HTTP/1.1 500 Internal Server Error] est une réponse d'échec. Ce qu'on appelle le statut d'une réponse HTTP est le code [200] ou [500]. Un certain nombre de ces codes sont reliés au 'succès' alors que d'autres codes sont reliés à 'l'échec' ;
  • ligne 18 : la fonction à exécuter à réception de la réponse du serveur lorsque le statut HTTP de cette de cette réponse est un statut d'échec ;
  • ligne 18 : la fonction à exécuter en dernier lieu, après les fonctions [onSuccess, onError] précédentes ;

La fonction [onBegin] est la suivante :


// avant l'appel Ajax
function onBegin() {
    console.log("onBegin");
    // on montre l'image animée
    loading.show();
    // on cache certains éléments de la vue
    entete.hide();
    résultats.hide();
    erreur.hide();
}

Avant d'étudier les autres fonctions jS de l'appel Ajax, nous avons besoin de connaître la réponse envoyée par l'action [/ajax-05].

7.3.4. L'action [/ajax-05]

L'action [/ajax-05] est la suivante :


    @RequestMapping(value = "/ajax-05", method = RequestMethod.POST)
    @ResponseBody()
    // traite le POST de la vue [vue-04]
    public JsonResults ajax05(@Valid ActionModel01 formulaire, BindingResult result, Locale locale,    HttpServletRequest request, HttpSession session) throws InterruptedException {
        if(result.hasErrors()){
            // cas anormal - on ne rend rien
            return null;
        }
        ...
}
  • ligne 2 : l'attribut [ResponseBody] indique que l'action [/ajax-05] rend elle-même la réponse au client. Parce qu'une bibliothèque jSON est dans les dépendances du projet, Spring Boot autoconfigure ce type d'actions pour qu'elles rendent du jSON. C'est donc la chaîne jSON d'un type [JsonResults] (ligne 4) qui va être envoyée au client ;
  • ligne 2 : les valeurs postées [a, b, culture] vont être encapsulées dans un type [ActionModel01] dont on demande la validation [@Valid ActionModel01]. C'est pour la forme. On est parti sur l'hypothèse que le Javascrit était activé sur le navigateur client et donc lorsqu'elles arrivent, les valeurs postées ont déjà été vérifiées côté client. Néanmoins, on peut prévoir le cas d'un POST sauvage qui n'utiliserait pas notre client jS. Dans ce cas, la validation peut échouer ;
  • lignes 5-7 : en cas d'erreur, on rend un flux jSON vide ;

Continuons l'étude de l'action [/ajax-05] :


    @RequestMapping(value = "/ajax-05", method = RequestMethod.POST)
    @ResponseBody()
    // traite le POST de la vue [vue-04]
    public JsonResults ajax05(@Valid ActionModel01 formulaire, BindingResult result, Locale locale,
            HttpServletRequest request, HttpSession session) throws InterruptedException {
...
        // le contexte de l'application Spring
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(request.getServletContext());
        // tempo ?
        Integer tempo = (Integer) session.getAttribute("tempo");
        if (tempo != null && tempo > 0) {
            Thread.sleep(tempo);
        }
    ...
        // on rend le résultat
        return résultats;
}
  • ligne 8 : on récupère le contexte [ctx] de l'application Spring. On en a besoin pour récupérer les messages des fichiers [messages.properties] à partir d'une clé de message et d'une locale. Cela se fait avec la syntaxe suivante :

ctx.getMessage(clé_message, tableau_de_paramètres, locale)
- [**clé\_message**] : la clé du message recherché ;
- [**locale**] : la locale utilisée. Ainsi si cette locale est [en\_US], c&#x27;est le fichier [messages\_en.properties] qui sera exploité ;
- [**tableau\_de\_paramètres**] : le message obtenu peut être paramétré tel que dans [clé=message {0} {1}]. Il y a dans ce message deux paramètres [{0} {1}]. Il faudra fournir comme second paramètre de [<span style="color: #6a3e3e">ctx</span><span style="color: #000000">.getMessage</span>] un tableau de deux valeurs ;
  • lignes 10-13 : s'il y a une temporisation dans la session, on arrête le thread courant le temps de celle-ci ;

L'action [/ajax-05] se poursuit de la façon suivante :


        // on prépare le modèle de la prochaine vue
        JsonResults résultats = new JsonResults();
        ...
}
  • ligne 2 : création du modèle de la chaîne jSON envoyée au client ;

Le modèle [JsonResults] est le suivant :

 

package istia.st.springmvc.models;

public class JsonResults {

    // data
    private String titre;
    private String labelHeureCalcul;
    private String heureCalcul;
    private String aplusb;
    private String amoinsb;
    private String afoisb;
    private String adivb;
    private String msgErreur;

    // getters et setters
...

}
  • lignes 6-13 : chacun des champs de la classe [JsonResult] correspond à un champ de même [id] dans la vue [vue-04.xml] :

L'action [/ajax-05] se poursuit de la façon suivante :


        // on prépare le modèle de la prochaine vue
        JsonResults résultats = new JsonResults();
        // entête
        résultats.setTitre(ctx.getMessage("resultats.titre", null, locale));
        résultats.setLabelHeureCalcul(ctx.getMessage("labelHeureCalcul", null, locale));
        résultats.setHeureCalcul(new SimpleDateFormat("hh:mm:ss").format(new Date()));
        // on génère une erreur une fois sur deux
        int val = new Random().nextInt(2);
        if (val == 0) {
            // on renvoie un message d'erreur
            résultats.setMsgErreur(ctx.getMessage("resultats.erreur",
                    new Object[] { ctx.getMessage("erreur.aleatoire", null, locale) }, locale));
            return résultats;
}
  • ligne 2 : création du modèle de la chaîne jSON envoyée au client ;
  • lignes 4-6 : on crée les messages de l'entête des résultats ;
  • lignes 8-14 : une fois sur deux en moyenne, on génère un message d'erreur. Dans ce cas, on ne va pas plus loin et on rend la chaîne jSON au client (ligne 13) ;
  • ligne 11 : on a ici un exemple de message paramétré :

erreur.aleatoire=erreur aléatoire
resultats.erreur=Une erreur s''est produite : [{0}]

L'action [/ajax-05] se poursuit de la façon suivante :


        // on récupère les valeurs postées
        double a = formulaire.getA();
        double b = formulaire.getB();
        // on construit le modèle
        résultats.setAplusb(String.valueOf(a + b));
        résultats.setAmoinsb(String.valueOf(a - b));
        résultats.setAfoisb(String.valueOf(a * b));
        try {
            résultats.setAdivb(String.valueOf(a / b));
        } catch (RuntimeException e) {
            résultats.setAdivb("NaN");
        }
        // on rend le résultat
return résultats;
  • lignes 2-3 : on récupère les valeurs de [a] et [b] ;
  • lignes 5-12 : on construit les quatre résultats ;
  • ligne 14 : la chaîne jSON [JsonResults] est envoyée au client ;

Voyons ce que ça donne avec le client [Advanced Rest Client] :

  • en [1-2], on fait une requête POST à l'action [/ajax-05] ;
  • en [3], on poste des valeurs incorrectes ;
  • en [4], le serveur a renvoyé un flux vide ;
  • en [1], on poste des valeurs correctes ;
  • en [2], l'objet jSON renvoyé par le serveur, avec ici un message d'erreur ;
  • en [1], on poste des valeurs correctes ;
  • en [2], l'objet jSON renvoyé par le serveur, avec ici les quatre résultats ;
  • en [1], on poste des valeurs correctes ;
  • en [2], on s'est arranagé pour provoquer une exception côté serveur. On voit que le serveur envoie encore un objet jSON. Dans ce message, on voit que le statut HTTP de la réponse est [500], indiquant qu'il y a eu une erreur côté serveur ;

7.3.5. La fonction jS [postForm] - 2

Maintenant que nous connaissons l'objet jSON renvoyé par le serveur, on peut l'exploiter dans le javascript. La méthode [onSuccess] exécutée lorsque le serveur envoie une réponse avec le statut HTTP [200] est la suivante :


// à réception de la réponse du serveur
// en cas de succès
function onSuccess(data) {
    console.log("onSuccess");
    // on remplit la zone des résultats
    titre.text(data.titre);
    labelHeureCalcul.text(data.labelHeureCalcul);
    heureCalcul.text(data.heureCalcul);
    entete.show();
    // résultats sans erreur
    if (!data.msgErreur) {
        aplusb.text(data.aplusb);
        amoinsb.text(data.amoinsb);
        afoisb.text(data.afoisb);
        adivb.text(data.adivb);
        résultats.show();
        return;
    }
    // résultats avec erreur
    msgErreur.text(data.msgErreur);
    erreur.show();
}
  • ligne 3 : le paramètre [data] est l'objet jSON renvoyé par le serveur :
 

La méthode [onError] exécutée lorsque le statut de la réponse HTTP est [500] est la suivante :


// à réception de la réponse du serveur
// en cas d'échec
function onError(jqXHR) {
    console.log("onError");
    // erreur système
    msgErreur.text(jqXHR.responseText);
    erreur.show();
}
  • ligne 3 : l'objet JQuery [jqXHR] a parmi ses propriétés les suivantes :
    • responseText : le texte de la réponse du serveur,
    • status : le code d'erreur retourné par le serveur,
    • statusText : le texte associé à ce code d'erreur ;
  • ligne 6 : l'objet [jqXHR.responseText] est l'objet jSON suivant :
 

7.3.6. Tests

Voyons quelques copies d'écran d'exécution de l'application web :

 
 
 

7.4. Application web à page unique

7.4.1. Introduction

La technologie Ajax permet de construire des applications à page unique :

  • la première page est issue d'une requête classique d'un navigateur ;
  • les pages suivantes sont obtenues avec des appels Ajax. Aussi, au final le navigateur ne change jamais d'URL et ne charge jamais de nouvelle page. On appelle ce type d'application, Application à Page Unique (APU) ou en anglais Single Page Application (SPA).

Voici un exemple basique d'une telle application. La nouvelle application aura deux vues :

  • en [1], l'action [/ajax-06] nous permet d'avoir la première page, la page 1 ;
  • en [2], un lien nous permet de passer à la page 2 grâce à un appel Ajax ;
  • en [3], l'URL n'a pas changé. La page présentée est la page 2 ;
  • en [4], un lien nous permet de revenir à la page 1 grâce à un appel Ajax ;
  • en [5], l'URL n'a pas changé. La page présentée est la page 1.

7.4.2. L'action [/ajax-06]

Le code de l'action [/ajax-06] est le suivant :


    @RequestMapping(value = "/ajax-06", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax06() {
        return "vue-06";
}
  • lignes 1-4 : l'action [/ajax-06] se contente de rendre la vue [vue-06.xml] ;

7.4.3. La vue [vue-06.xml]

La vue [vue-06.xml] est la suivante :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>Ajax-06</title>
        <link rel="stylesheet" href="/css/ajax01.css" />
        <script type="text/javascript" src="/js/jquery/jquery-2.1.1.min.js"></script>
        <script type="text/javascript" src="/js/local6.js"></script>
    </head>
    <body>
        <h3>Ajax - 06 - Navigation dans une Application à Page Unique</h3>
        <div id="content" th:include="vue-07" />
    </body>
</html>
  • ligne 8 : la vue utilise un script [local6.js] ;
  • ligne 12 : on inclut la vue [vue-07.xml] dans la zone d'id [content] de la vue [vue-06.xml] ;

7.4.4. La vue [vue-07.xml]

La vue [vue-07.xml] est la suivante :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <body>
        <h4>Page 1</h4>
        <p>
            <a href="javascript:gotoPage(2)">Page 2</a>
        </p>
    </body>
</html>

7.4.5. La fonction jS [gotoPage]

Le lien [Page 2] de la vue [vue-07.xml] utilise la fonction jS [gotoPage] définie dans le fichier [local6.js] suivant :


// données globales
var content;

function gotoPage(num) {
    // on fait un appel Ajax à la main
    $.ajax({
        url : '/ajax-07',
        type : 'POST',
        data : 'num=' + num,
        dataType : 'html',
        beforeSend : function() {
        },
        success : function(data) {
            content.html(data)
        },
        complete : function() {
        },
        error : function(jqXHR) {
            // erreur système
            content.html(jqXHR.responseText);
        }
    })
}

// au chargement du document
$(document).ready(function() {
    // on récupère les références des différents composants de la page
    content = $("#content");
});
  • ligne 28 : au chargement de la page, on mémorise la zone d'id [content] et on en fait une variable globale (ligne 2) ;
  • ligne 4 : la fonction [gotoPage] reçoit comme paramètre le n° de la page (1 ou 2) à afficher dans la vue actuelle ;
  • ligne 7 : l'URL cible du POST ;
  • ligne 8 : l'URL de la ligne 7 est demandée via un POST ;
  • ligne 9 : la chaîne postée. C'est un paramètre nommé [num] qui est posté. Sa valeur est le n° de page (ligne 4) à afficher dans la vue actuelle ;
  • ligne 10 : le serveur va renvoyer du HTML, celui de la page à afficher ;
  • lignes 13-15 : en cas de succès (statut HTTP égal à 200), le HTML envoyé par le serveur est mis dans la zone d'id [content] ;
  • lignes 18-20 : en cas d'échec (statut HTTP égal à 500), le HTML envoyé par le serveur est mis dans la zone d'id [content] ;

7.4.6. L'action [/ajax-07]

Le code de l'action [/ajax-07] est le suivant :


@RequestMapping(value = "/ajax-07", method = RequestMethod.POST, produces = "text/html; charset=UTF-8")
    public String ajax07(int num) {
        // num : numéro de page
        switch (num) {
        case 1:
            return "vue-07";
        case 2:
            return "vue-08";
        default:
            return "vue-07";
        }
    }
  • ligne 2 : on récupère le paramètre posté qui s'appelle [num]. On rappelle que le paramètre ligne 2 doit porter le nom du paramètre posté, ici [num]. [num] est un n° de page ou de vue ;
  • lignes 5-6 : dans le cas où [num==1], on renvoie la vue [vue-07.xml] ;
  • lignes 7-8 : dans le cas où [num==2], on renvoie la vue [vue-08.xml] ;
  • lignes 9-10 : dans les autres cas, (impossible normalement), on renvoie la vue [vue-07.xml] ;

7.4.7. La vue [vue-08.xml]

La vue [vue-08.xml] forme la page n° 2 de l'application :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <body>
        <h4>Page 2</h4>
        <p>
            <a href="javascript:gotoPage(1)">Page 1</a>
        </p>
    </body>
</html>

7.5. Embarquer plusieurs flux HTML dans une réponse jSON

7.5.1. Introduction

Nous considérons l'application suivante :

La page [1] a quatre zones :

  • [Zone 1, Zone 3] sont des zones qui apparaissent / disparaissent sur un clic sur le bouton [Rafraîchir]. On compte le nombre d'apparitions de chacune de ces deux zones [2]. La zone [Zone 1] utilise la langue française alors que la zone [Zone 3] utilise la langue anglaise ;
  • la zone [Zone 2] est présente en permanence ;
  • la zone [Saisies] est présente en permanence ;

Le lien [Valider] affiche la page suivante [3] :

  • le lien [Retour à la page 1] ramène la page n° 1 dans l'état où elle était [4] ;

L'application est à page unique. La première page est demandée au serveur par le navigateur. Les suivantes sont obtenues auprès du serveur par des appels Ajax.

7.5.2. L'action [/ajax-09]

  

L'action [/ajax-09] est la suivante :


    @RequestMapping(value = "/ajax-09", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax09() {
        return "vue-09";
}

Elle se contente d'afficher la vue [vue-09.xml].

7.5.3. Les vues XML

  

La vue [vue-09.xml] est la page maître de l'application :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>Ajax-09</title>
        <link rel="stylesheet" href="/css/ajax01.css" />
        <script type="text/javascript" src="/js/jquery/jquery-2.1.1.min.js"></script>
        <script type="text/javascript" src="/js/json3.js"></script>
        <script type="text/javascript" src="/js/local9.js"></script>
    </head>
    <body>
        <h3>Ajax - 09 - Navigation dans une Application à Page Unique</h3>
        <h3>avec des flux HTML embarqués dans des chaînes jSON</h3>
        <hr />
        <div id="content" th:include="vue-09-page1" />
        <img id="loading" src="/images/loading.gif" />
        <div id="erreur" style="background-color:lightgrey"></div>
    </body>
</html>
  • ligne 9 : le fichier JS utilisé dans l'application ;
  • ligne 15 : le contenu de la page maître ;
  • ligne 16 : une image animée d'attente :
  • ligne 17 : zone pour afficher une éventuelle erreur ;

La vue [vue-09-page1.xml] est la page 1 de l'application :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <body>
        <h2>Page 1</h2>
        <!-- zone 1 -->
        <fieldset id="zone1" style="background-color:pink">
            <legend>Zone 1</legend>
            <span id="zone1-content" th:text="xx">xx</span>
        </fieldset>
        <!-- zone 2 -->
        <fieldset id="zone2" style="background-color:lightgreen">
            <legend>Zone 2</legend>
            <span>Ce texte reste toujours présent</span>
        </fieldset>
        <!-- zone 3 -->
        <fieldset id="zone3" style="background-color:yellow">
            <legend>Zone 3</legend>
            <span id="zone3-content" th:text="zz">zz</span>
        </fieldset>
        <br />
        <p>
            <button onclick="javascript:postForm()">Rafraîchir</button>
        </p>
        <hr />
        <div id="saisies" th:include="vue-09-saisies">
        </div>
    </body>
</html>
  • lignes 6-9 : la zone [Zone 1]. Son contenu est placé dans le composant [id="zone1-content"] ;
  • lignes 11-14 : la zone [Zone 2] qui ne change pas ;
  • lignes 16-19 : la zone [Zone 3]. Son contenu est placé dans le composant [id="zone3-content"] ;
  • ligne 22 : la fonction JS qui poste le formulaire ;
  • ligne 25 : inclusion de la zone de saisies ;

On notera que la page 1 n'a pas de balise [form]. Tout va être traité en javascript.

La vue [vue-09-saisies.xml] est la suivante :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <div id="saisies">
        <h4>Saisies :</h4>
        <p>
            Chaîne de caractères :
            <input type="text" id="text1" size="30" th:value="${value1}" />
        </p>
        <p>
            Nombre entier :
            <input type="text" id="text2" size="10" th:value="${value2}" />
        </p>
        <p>
            <a href="javascript:valider()">Valider</a>
        </p>
    </div>
</html>
  • lignes 5-8 : saisie d'une chaîne de caractères ;
  • lignes 13-16 : saisie d'un nombre entier ;
  • ligne 14 : la fonction JS qui poste les valeurs saisies ;

De nouveau, on notera que la zone de saisies n'a pas de balise [form].

Au total, la page n° 1 présente deux fonctionnalités :

  • [Rafraîchir] : qui rafraîchit les zones 1 et 3. Cette action est traitée par le serveur qui renvoie aléatoirement  :
    • la zone 1 avec son compteur d'accès et rien pour la zone 3,
    • la zone 3 avec son compteur d'accès et rien pour la zone 1,
    • les deux zones avec leurs compteurs d'accès ;
  • [Valider] : qui affiche la page 2 avec les valeurs saisies ou bien un message d'erreur si les données saisies sont invalides ;

Nous allons nous intéresser d'abord au bouton [Rafraîchir].

7.5.4. Le code JS de gestion du bouton [Rafraîchir]

  

Le code du fichier [local9.js] est le suivant :


// variables globales
var content;
var loading;
var erreur;

// au chargement du document
$(document).ready(function() {
    // on récupère les références des différents composants de la page
    loading = $("#loading");
    loading.hide();
    erreur = $("#erreur");
    erreur.hide();
    content = $("#content");
});
  • lignes 9-13 : lorsque la page maître est chargée, on mémorise les références sur les trois composants identifiés par [loading, erreur, content] ;
  • lignes 2-4 : les références de ces trois composants sont mémorisées dans des variables globales. Elles restent fixes parce que les trois zones concernées sont toujours présentes dans la page affichée, ceci quelque soit le moment. Parce qu'elles restent fixes elles peuvent être calculées dans [$(document).ready] et partagées avec les autres fonctions du fichier JS ;

La fonction [postForm] gère le clic sur le bouton [Rafraîchir] :


function postForm() {
    console.log("postForm");
    // on fait un appel Ajax à la main
    $.ajax({
        url : '/ajax-10',
        headers : {
            'Accept' : 'application/json'
        },
        type : 'POST',
        dataType : 'json',
        beforeSend : onBegin,
        success : onSuccess,
        error : onError,
        complete : onComplete
    })
}
  • lignes 4-15 : l'appel Ajax au serveur ;
  • ligne 5 : c'est l'action [ajax-10] qui va traiter le POST ;
  • lignes 6-8 : la réponse va être du jSON. Le client JS indique qu'il accepte les documents jSON ;
  • ligne 9 : l'action [ajax-10] est appelée avec une opération POST ;
  • igne 10 : on va recevoir du jSON ;
  • ligne 11 : la fonction exécutée avant l'appel Ajax ;
  • ligne 12 : la fonction exécutée à réception de la réponse du serveur, lorsque celle-ci est un succès [200 OK] ;
  • ligne 13 : la fonction exécutée à réception de la réponse du serveur, lorsque celle-ci est un échec [500 Internal server error, ...] ;
  • ligne 14 : la fonction exécutée après réception de la réponse ;

La fonction [onBegin] est la suivante :


// avant l'appel Ajax
function onBegin() {
    console.log("onBegin");
    // image d'attente
    loading.show();
}

Elle se contente de mettre en route l'image animée de l'attente du résultat du serveur.

7.5.5. L'action [/ajax-10]

  

L'action [/ajax-10] est la suivante :


// la session
    @Autowired
    private SessionModel1 session;
    // le moteur Thymeleaf / Spring
    @Autowired
    private SpringTemplateEngine engine;

    @RequestMapping(value = "/ajax-10", method = RequestMethod.POST)
    @ResponseBody()
    public JsonResult10 ajax10(HttpServletRequest request, HttpServletResponse response) {
    ...
    }
  • ligne 3 : on injecte la session. Celle-ci a le type [SessionModel1] suivant :
  

package istia.st.springmvc.models;

import java.io.Serializable;

import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Component;

@Component
@Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class SessionModel1 implements Serializable {

    private static final long serialVersionUID = 1L;
    // deux compteurs
    private int cpt1 = 0;
    private int cpt3 = 0;
    // les trois zones
    private String zone1 = "xx";
    private String zone3 = "zz";
    private String saisies;
    private boolean zone1Active = true;
    private boolean zone3Active = true;

    // getters et setters
    ...
}

La session [SessionModel1] mémorise les éléments suivants :

  • ligne 15 : le nombre de fois [cpt1] où la zone [Zone 1] est affichée ;
  • ligne 16 : le nombre de fois [cpt3] où la zone [Zone 3] est affichée ;
  • lignes 18-20 : les flux HTML des zones [Zone 1], [Zone 3] et [Saisies]. Ceci est nécessaire dans la séquence [Page 1] --> [Page 2] --> [Page 1]. Lorsqu'on passe de [Page 2] à [Page 1], il faut restaurer [Page 1] et donc ses trois zones ;
  • lignes 21-22 : deux booléens qui indiquent si les zones [Zone 1] et [Zone 3] sont affichées (visibles) ;

L'autre élément injecté dans le contrôleur [AjaxController] est le suivant :


    // le moteur Thymeleaf / Spring
    @Autowired
private SpringTemplateEngine engine;

Le bean de type [SpringTemplateEngine] est défini dans le fichier de configuration [Config] :

  

Il est défini de la façon suivante :


    @Bean
    public SpringResourceTemplateResolver templateResolver() {
        SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
        templateResolver.setPrefix("classpath:/templates/");
        templateResolver.setSuffix(".xml");
        templateResolver.setTemplateMode("HTML5");
        templateResolver.setCacheable(true);
        templateResolver.setCharacterEncoding("UTF-8");
        return templateResolver;
    }

    @Bean
    SpringTemplateEngine templateEngine(SpringResourceTemplateResolver templateResolver) {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(templateResolver);
        return templateEngine;
}
  • lignes 2-10 : nous connaissons le bean de type [SpringResourceTemplateResolver] qui nous permet de définir certaines caractéristiques des vues ;
  • lignes 13-17 : le bean de type [SpringTemplateEngine] nous permet de définir le " moteur " de vues, la classe chargée de générer les réponses [Thymeleaf] aux clients. [Thymeleaf] a un " moteur " par défaut et un autre lorsqu'il est utilisé dans un environnement [Spring]. C'est ce dernier que nous utilisons ici ;

La signature de l'action [/ajax-10] est la suivante :


@RequestMapping(value = "/ajax-10", method = RequestMethod.POST)
    @ResponseBody()
    public JsonResult10 ajax10(HttpServletRequest request, HttpServletResponse response) {
    ...
}
  • ligne 1 : l'action [/ajax-10] n'accepte qu'un POST ;
  • ligne 2 : l'action [/ajax-10] rend elle-même la réponse au client. Celle-ci sera tranformée automatiquement en jSON ;
  • ligne 3 : la réponse est de type [JsonResult10] suivant :
  

package istia.st.springmvc.models;

public class JsonResult10 {

    // data
    private String content;
    private String zone1;
    private String zone3;
    private String erreur;
    private String saisies;
    private boolean zone1Active;
    private boolean zone3Active;

    public JsonResult10() {
    }

    // getters et setters
...
}
  • ligne 6 : le contenu HTML de la zone identifiée par [content] ;
  • ligne 7 : le contenu HTML de la zone [Zone 1] ;
  • ligne 8 : le contenu HTML de la zone [Zone 3] ;
  • ligne 9 : le contenu HTML de la zone [Erreur] ;
  • ligne 10 : le contenu HTML de la zone [Saisies] ;
  • ligne 11 : booléen indiquant si la zone [Zone 1] doit être affichée ;
  • ligne 12 : booléen indiquant si la zone [Zone 3] doit être affichée ;

Le code de l'action [/ajax-10] est le suivant :


@RequestMapping(value = "/ajax-10", method = RequestMethod.POST)
    @ResponseBody()
    public JsonResult10 ajax10(HttpServletRequest request, HttpServletResponse response) {
        // contexte Thymeleaf
        WebContext thymeleafContext = new WebContext(request, response, request.getServletContext());
        // réponse
        JsonResult10 result = new JsonResult10();
        // session
        session.setZone1(null);
        session.setZone3(null);
        session.setZone1Active(false);
        session.setZone3Active(false);
        // on rend une réponse aléatoire
        int cas = new Random().nextInt(3);
        switch (cas) {
        case 0:
            // zone 1 active
            setZone1(thymeleafContext, result);
            return result;
        case 1:
            // zone 3 active
            setZone3(thymeleafContext, result);
            return result;
        case 2:
            // zones 1 et 3 actives
            setZone1(thymeleafContext, result);
            setZone3(thymeleafContext, result);
            return result;
        }
        return null;
    }
  • ligne 5 : nous récupérons le contexte [Thymeleaf]. Nous verrons ultérieurement à quoi il va nous servir ;
  • ligne 7 : nous créons une réponse vide pour l'instant ;
  • lignes 9-12 : nous mettons à [null] les deux zones contenues dans la session et nous indiquons qu'elles ne doivent pas être affichées. Ces deux zones vont être bientôt générées mais il est possible que seule l'une d'entre-elles le soit ;
  • lignes 14-29 : les deux zones sont générées ;
  • lignes 17-19 : seule la zone [Zone 1] est générée ;
  • lignes 21-23 : seule la zone [Zone 3] est générée ;
  • lignes 25-28 : les deux zones [Zone 1] et [Zone 3] sont générées ;

Le flux HTML de la zone [Zone 1] est généré par la méthode suivante :


    private void setZone1(WebContext thymeleafContext, JsonResult10 result) {
        // zone 1 active
        // flux HTML
        int cpt1 = session.getCpt1() + 1;
        thymeleafContext.setVariable("cpt1", cpt1);
        thymeleafContext.setLocale(new Locale("fr", "FR"));
        String zone1 = engine.process("vue-09-zone1", thymeleafContext);
        result.setZone1(zone1);
        result.setZone1Active(true);
        // session
        session.setCpt1(cpt1);
        session.setZone1(zone1);
        session.setZone1Active(true);
}
  • ligne 1 : les paramètres sont :
    • le contexte [Thymeleaf] de type [WebContext],
    • la réponse au client en cours de construction de type [JsonResult10] ;
  • ligne 3 : on incrémente le compteur [cpt1] de la session qui compte le nombre de fois où la zone [Zone 1] est affichée ;
  • ligne 4 : le contexte [Thymeleaf] de type [WebContext] se comporte un peu comme le modèle [Model] de Spring MVC. Pour ajouter un élément au modèle, on utilise [WebContext.setVariable]. Ici, on met donc le compteur [cpt1] dans le modèle [Thymeleaf]. Cela va permettre d'évaluer l'expression Thymeleaf [${cpt1}]
  • ligne 5 : le contexte [Thymeleaf] a une locale. Cela lui permet d'évaluer les expression du type [#{clé_msg}]. Ici, on associe le contexte Thymeleaf a une locale française ;
  • ligne 6 : c'est l'instruction la plus intéressante. Le moteur Thymeleaf va traiter la vue [vue-09-zone1.xml] avec le modèle et la locale que l'on vient de calculer et au lieu d'envoyer le flux HTML résultant au client, il le rend en tant que chaîne de caractères ;
  • lignes 7-9 : le flux HTML de la zone [Zone 1] qui vient d'être calculé est mémorisé dans la session et dans le résultat qui va être envoyé au client. Par ailleurs, on indique que la zone [Zone 1] doit être affichée ;
  • lignes 11-13 : on mémorise dans la session, les informations concernant la zone [Zone 1] afin d'être capables de la régénérer ;

La ligne 7 traite la vue [vue-09-zone1.xml] suivante :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <span th:text="#{message.zone}"></span>
    <span th:text="${cpt1}"></span>
</html>
  • ligne 3 : l'expression [#{message.zone}] va être évaluée grâce à la locale ;
  • ligne 4 : l'expression [${cpt1}] va être évaluée grâce au modèle Thymeleaf ;

Le message de clé [message.zone] est défini dans les fichiers de messages [messages_fr.properties] et [messages_en.properties] :

  

[messages_fr.properties]


message.zone=Nombre d'accès : 

[messages_en.properties]


message.zone=Number of hits: 

Le flux HTML de la zone [Zone 3] est généré par une méthode analogue :


    private void setZone3(WebContext thymeleafContext, JsonResult10 result) {
        // zone 3 active
        // flux HTML
        int cpt3 = session.getCpt3() + 1;
        thymeleafContext.setVariable("cpt3", cpt3);
        thymeleafContext.setLocale(new Locale("en", "US"));
        String zone3 = engine.process("vue-09-zone3", thymeleafContext);
        result.setZone3(zone3);
        result.setZone3Active(true);
        // session
        session.setCpt3(cpt3);
        session.setZone3(zone3);
        session.setZone3Active(true);
}
  • ligne 6 : la locale de la zone [Zone 3] est la locale anglaise ;

7.5.6. Traitement de la réponse de l'action [/ajax-10]

Revenons au code JS de [local9.js] qui va traiter la réponse du serveur :


// à réception de la réponse du serveur
// en cas de succès
function onSuccess(data) {
    console.log("onSuccess");
    // contenu
    if (data.content) {
        content.html(data.content);
    }
    // zone 1
    if (data.zone1Active) {
        $("#zone1").show();
        if (data.zone1) {
            $("#zone1-content").html(data.zone1);
        }
    } else {
        $("#zone1").hide();
    }
    // zone 3 active ?
    if (data.zone3Active) {
        $("#zone3").show();
        if (data.zone3) {
            $("#zone3-content").html(data.zone3);
        }
    } else {
        $("#zone3").hide();
    }
    // saisies ?
    if (data.saisies) {
        $("#saisies").html(data.saisies);
    }
    // erreur ?
    if (data.erreur) {
        erreur.text(data.erreur);
        erreur.show();
    } else {
        erreur.hide();
    }
}

Rappelons la structure Java de la réponse reçue ligne 3 dans la variable [data] :


public class JsonResult10 {

    // data
    private String content;
    private String zone1;
    private String zone3;
    private String erreur;
    private String saisies;
    private boolean zone1Active;
    private boolean zone3Active;

}
  • lignes 6-8 : si [data.content!=null], alors on initialise la zone [id=content] avec. Cette zonne représente [Page 1] ou [Page 2] dans sa totalité. Dans la démonstration présente, on a [data.content==null] et donc la zone [id=content] ne sera pas modifiée et continuera à afficher [Page 1] ;
  • lignes 10-17 : affichage [Zone 1] si [data.zone1Active==true]. Si de plus [data.zone1!=null] alors le contenu de [Zone 1] est modifié sinon il reste ce qu'il était ;
  • lignes 19-26 : même chose pour [Zone 3] ;
  • lignes 28-30 : si on a [data.saisies!=null] alors la zone [Saisies] est régénérée. Dans la démonstration présente, on a [data.saisies==null] et donc la zone [Saisies] reste ce qu'elle était ;
  • lignes 32-37 : raisonnement analogue pour la zone [Erreur] avec les nuances suivantes :
    • ligne 33 : [data.erreur] sera un message d'erreur au format texte ;
    • ligne 36 : si [data.erreur==null] alors la zone [Erreur] est cachée. En effet, elle a pu être affichée lors de la précédente requête ;

En cas d'erreur côté serveur (HTTP status du genre 500 Internal server error), la fonction suivante est exécutée :


// à réception de la réponse du serveur
// en cas d'échec
function onError(jqXHR) {
    console.log("onError");
    // erreur système
    erreur.text(jqXHR.responseText);
    erreur.show();
}

Pour voir une telle erreur, modifions la fonction [postForm] de la façon suivante :


function postForm() {
    console.log("postForm");
    // on récupère des références sur la page courante
    ...
    // on fait un appel Ajax à la main
    $.ajax({
        url : '/ajax-10x',
        ...
    })
}
  • ligne 7 : on met une URL qui n'existe pas ;

Voici les résultats lorsqu'on clique sur le bouton [Rafraîchir] :

Il est intéressant de voir que l'erreur a été envoyée elle également sous la forme d'une chaîne jSON.

La méthode exécutée après réception de la réponse du serveur est la suivante :


// après [onSuccess, onError]
function onComplete() {
    console.log("onComplete");
    // image d'attente
    loading.hide();
}

On se contente de cacher l'image animée de l'attente.

7.5.7. Affichage de la page [Page 2]

Le code HTML du lien [Valider] est le suivant :


<a href="javascript:valider()">Valider</a>

La fonction JS [valider] est la suivante :


// validation des valeurs saisies
function valider() {
    // valeur postée
    var post = JSON3.stringify({
        "value1" : $("#text1").val().trim(),
        "value2" : $("#text2").val().trim()
    });
    // on fait un appel Ajax à la main
    $.ajax({
        url : '/ajax-11A',
        headers : {
            'Accept' : 'application/json',
            'Content-Type' : 'application/json'
        },
        type : 'POST',
        data : post,
        dataType : 'json',
        beforeSend : onBegin,
        success : onSuccess,
        error : onError,
        complete : onComplete
    })
}
  • lignes 4-7 : nous avons deux valeurs v1 et v2 à poster : celles des composants de saisie identifiés par [#text1] et [#text2]. Nous allons faire quelque chose de nouveau. Nous allons poster ces deux valeurs sous la forme d'une chaîne jSON {"value1":v1,"value2":v2} ;
  • ligne 10 : les valeurs postées seront envoyées à l'action [ajax-11A] ;
  • ligne 12 : parce qu'on sait qu'on va recevoir une réponse jSON, on indique qu'on peut en recevoir du jSON ;
  • ligne 13 : on indique au serveur qu'on va lui envoyer la valeur postée sous la forme d'une chaîne jSON ;
  • lignes 15-16 : on fait un POST de la valeur à poster ;
  • ligne 17 : on va recevoir du jSON ;

7.5.8. L'action [ajax-11A]

L'action [ajax-11A] qui traite la chaîne jSON postée est la suivante :


@RequestMapping(value = "/ajax-11A", method = RequestMethod.POST, consumes = "application/json")
    @ResponseBody
    public JsonResult10 ajax11A(@RequestBody @Valid PostAjax11A post, BindingResult bindingResult, Locale locale,     HttpServletRequest request, HttpServletResponse response) {
        ...
    }
  • ligne 1 : on indique avec ["application/json"] que l'action attend un document sous forme jSON. Ce document est la valeur postée par le client ;
  • ligne 3 : la valeur postée va être récupérée dans l'objet [PostAjax11A post] suivant :
  

package istia.st.springmvc.models;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import org.hibernate.validator.constraints.Range;

public class PostAjax11A {

    // data
    @Size(min = 4, max = 6)
    @NotNull
    private String value1;
    @Range(min = 10, max = 14)
    @NotNull
    private Integer value2;

    // getters et setters
    ...
}
  • la structure de l'objet [PostAjax11A] doit reprendre la structure de l'objet posté {"value1":v1,"value2":v2}. Il faut donc un champ [value1] (ligne 13) et [value2] (ligne 16) ;
  • on a mis des contraintes d'intégrité sur les deux champs ;

Revenons au code de l'action [ajax-11A] :


@RequestMapping(value = "/ajax-11A", method = RequestMethod.POST, consumes = "application/json")
    @ResponseBody
    public JsonResult10 ajax11A(@RequestBody @Valid PostAjax11A post, BindingResult bindingResult, Locale locale,    HttpServletRequest request, HttpServletResponse response) {
        // contexte Thymeleaf
        WebContext thymeleafContext = new WebContext(request, response, request.getServletContext());
        // réponse
        JsonResult10 result = new JsonResult10();
        // post valide ?
        if (bindingResult.hasErrors()) {
            // on renvoie la page 1 avec une erreur
            result.setZone1Active(session.isZone1Active());
            result.setZone3Active(session.isZone3Active());
            result.setErreur(getErreursForModel(bindingResult));
            return result;
        }
        ...
}
  • ligne 3 : l'annotation [@RequestBody] désigne le document envoyé par le client. Il s'agit de la valeur postée en jSON par celui-ci. Celle-ci va donc être utilisée pour construire l'objet [PostAjax11A] ;
  • ligne 3 : l'annotation [@Valid] force la validation de la valeur postée ;
  • ligne 9 : si la validation échoue :
    • ligne 13 : on renvoie un message d'erreur,
    • lignes 11-12 : les zones 1 et 3 sont remises dans l'état où elles étaient (affichées ou non) ;

Le calcul du message d'erreur est fait de la façon suivante :


    private String getErreursForModel(BindingResult result) {
        StringBuffer buffer = new StringBuffer();
        for (FieldError error : result.getFieldErrors()) {
            StringBuffer bufferCodes = new StringBuffer("(");
            for (String code : error.getCodes()) {
                bufferCodes.append(String.format("%s ", code));
            }
            bufferCodes.append(")");
            buffer.append(String.format("[%s:%s:%s:%s]", error.getField(), error.getRejectedValue(), bufferCodes,
                    error.getDefaultMessage()));
        }
        return buffer.toString();
}

C'est une fonction qu'on a déjà rencontrée.

L'action [ajax-11A] se poursuit de la façon suivante :


@RequestMapping(value = "/ajax-11A", method = RequestMethod.POST, consumes = "application/json")
    @ResponseBody
    public JsonResult10 ajax11A(@RequestBody @Valid PostAjax11A post, BindingResult bindingResult, Locale locale,    HttpServletRequest request, HttpServletResponse response) {
        // contexte Thymeleaf
        WebContext thymeleafContext = new WebContext(request, response, request.getServletContext());
        // réponse
        JsonResult10 result = new JsonResult10();
        // post valide ?
        if (bindingResult.hasErrors()) {
    ...
        }
        // on mémorise la zone de saisie
        thymeleafContext.setVariable("value1", post.getValue1());
        thymeleafContext.setVariable("value2", post.getValue2());
        session.setSaisies(engine.process("vue-09-saisies", thymeleafContext));
        // on envoie la page 2
        result.setContent(engine.process("vue-09-page2", thymeleafContext));
        return result;
}
  • lignes 13-14 : les valeurs postées sont mises dans le contexte Thymeleaf ;
  • ligne 15 : avec ce contexte, on calcule la vue [vue-09-saisies] et on la met dans la session afin de pouvoir la régénérer ultérieurement ;
  • ligne 17 : la page 2 est mise dans le résultat qui va être envoyé au client ;

La vue [vue-09-page2.xml] est la suivante :

  

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <body>
        <h2>Page 2</h2>
        <p>
            <h4>Valeurs saisies :</h4>
            <p>
                Chaîne de caractères :
                <span th:text="${value1}"></span>
            </p>
            <p>
                Nombre entier :
                <span th:text="${value2}"></span>
            </p>
            <a href="javascript:retourPage1()">Retour à la page 1</a>
        </p>
    </body>
</html>
  • lignes 9 et 13, on affiche les valeurs [value1, value2] que l'action [/ajax-11A] a placées dans le contexte Thymeleaf ;

7.5.9. Traitement de la réponse de l'action [/ajax-11A]

Côté client, la réponse de l'action [/ajax-10] est traitée par la fonction [onSuccess] :


function onSuccess(data) {
    console.log("onSuccess");
    // contenu
    if (data.content) {
        content.html(data.content);
    }
    // zone 1
    if (data.zone1Active) {
        $("#zone1").show();
        if (data.zone1) {
            $("#zone1-content").html(data.zone1);
        }
    } else {
        $("#zone1").hide();
    }
    // zone 3 active ?
    if (data.zone3Active) {
        $("#zone3").show();
        if (data.zone3) {
            $("#zone3-content").html(data.zone3);
        }
    } else {
        $("#zone3").hide();
    }
    // saisies ?
    if (data.saisies) {
        $("#saisies").html(data.saisies);
    }
    // erreur ?
    if (data.erreur) {
        erreur.text(data.erreur);
        erreur.show();
    } else {
        erreur.hide();
    }
}

Nous avons déjà commenté ce code. Considérons les deux cas, réponse avec ou sans erreur :

Avec erreur

Dans ce cas, l'action [/ajax-11A] a envoyé une réponse jSON de la forme {"zone1":null, "zone3":null,"saisies":null,"erreur":erreur,"zone1Active":zone1Active,"zone3Active":zone3Active,"content":null}. Si on suit le code ci-dessus, on voit que :

  • la zone [content] ne change pas. Elle contenait la page n° 1 ;
  • la zone [Erreur] est affichée ;
  • les zones [Zone 1], [Zone 3], [Saisies] sont laissées dans l'état où elles étaient ;

Sans erreur

Dans ce cas, l'action [/ajax-11A] a envoyé une réponse jSON de la forme {"zone1":null, "zone3":null,"saisies":null,"erreur":null,"zone1Active":false,"zone3Active":false,"content":content}. Si on suit le code ci-dessus, on voit que :

  • la zone [content] est affichée. Elle contient la page n° 2 ;

Voici trois exemples d'exécution :

Un cas avec erreur de validation :

Un cas avec erreur de POST :

Ce type d'erreur est différent. Parce que Spring n'a pas pu convertir la chaîne jSON en type [PostAjax11A], il a renvoyé une réponse HTTP avec [status=400]. L'action [ajax-11A] n'a pas été exécutée ;

Un cas sans erreur :

7.5.10. Retour vers la page n° 1

Le lien [Retour vers la page 1] dans la page N° 2 est le suivant :


<a href="javascript:retourPage1()">Retour à la page 1</a>

La méthode JS [retourPage1] est la suivante :


// retour page 1
function retourPage1() {
    // on fait un appel Ajax à la main
    $.ajax({
        url : '/ajax-11B',
        headers : {
            'Accept' : 'application/json',
        },
        type : 'POST',
        dataType : 'json',
        beforeSend : onBegin,
        success : onSuccess,
        error : onError,
        complete : onComplete
    })
}

Elle fait un POST, sans valeur postée, vers l'action [/ajax-11B].

7.5.11. L'action [/ajax-11B]

L'action [/ajax-11B] est la suivante :


    @RequestMapping(value = "/ajax-11B", method = RequestMethod.POST)
    @ResponseBody
    public JsonResult10 ajax11B(HttpServletRequest request, HttpServletResponse response) {
        // contexte Thymeleaf
        WebContext thymeleafContext = new WebContext(request, response, request.getServletContext());
        // réponse
        JsonResult10 result = new JsonResult10();
        // on la rend la page 1 dans son état originel
        result.setContent(engine.process("vue-09-page1", thymeleafContext));
        result.setSaisies(session.getSaisies());
        result.setZone1(session.getZone1());
        result.setZone3(session.getZone3());
        result.setZone1Active(session.isZone1Active());
        result.setZone3Active(session.isZone3Active());
        return result;
}

L'action doit régénérer la page n°1 avec ses trois zones [Zone1, Zone3, Erreur] :

  • ligne 9 : la page n° 1 est mise dans le résultat ;
  • ligne 10 : la zone des saisies est mise dans le résultat ;
  • ligne 11 : la zone [Zone 1] est mise dans le résultat ;
  • ligne 12 : la zone [Zone 3] est mise dans le résultat ;
  • lignes 13-14 : on met l'état des zones [Zone 1] et [Zone 3] dans le résultat ;

7.5.12. Traitement de la réponse de l'action [/ajax-11B]

La réponse de l'action [/ajax-11B] est traitée par la fonction [onSuccess] :


function onSuccess(data) {
    console.log("onSuccess");
    // contenu
    if (data.content) {
        content.html(data.content);
    }
    // zone 1
    if (data.zone1Active) {
        $("#zone1").show();
        if (data.zone1) {
            $("#zone1-content").html(data.zone1);
        }
    } else {
        $("#zone1").hide();
    }
    // zone 3 active ?
    if (data.zone3Active) {
        $("#zone3").show();
        if (data.zone3) {
            $("#zone3-content").html(data.zone3);
        }
    } else {
        $("#zone3").hide();
    }
    // saisies ?
    if (data.saisies) {
        $("#saisies").html(data.saisies);
    }
    // erreur ?
    if (data.erreur) {
        erreur.text(data.erreur);
        erreur.show();
    } else {
        erreur.hide();
    }
}

L'action [/ajax-11B] a envoyé une réponse jSON de la forme {"zone1":zone1, "zone3":zone3,"saisies":saisies,"erreur":null,"zone1Active":zone1Active,"zone3Active":zone3Active,"content":content}. Si on suit le code ci-dessus, on voit que :

  • la zone [content] est modifiée. Elle contenait la page n° 2. Elle va désormais contenir la page n° 1 ;
  • la zone [Erreur] est cachée ;
  • les zones [Zone 1], [Zone 3], [Saisies] sont affichées dans l'état où elles étaient ;

7.6. Gérer la session côté client

7.6.1. Introduction

Dans le paragraphe précédent, nous avons géré une session dont la structure était la suivante :


public class SessionModel1 implements Serializable {

    // deux compteurs
    private int cpt1 = 0;
    private int cpt3 = 0;
    // les trois zones
    private String zone1 = "xx";
    private String zone3 = "zz";
    private String saisies;
    private boolean zone1Active = true;
    private boolean zone3Active = true;
...
}

Lorsqu'il y a de très nombreux utilisateurs, la mémoire occupée par les sessions de tous ces utilisateurs peut poser problème. La règle est donc de minimiser la taille de celle-ci. Le modèle APU (Application à Page Unique) permet de gérer la session côté client et d'avoir un serveur web sans session. En effet, la page unique est chargée initialement par le navigateur. Avec elle, arrive le fichier Javascript qui l'accompagne. Comme il n'y a pas de rechargement de page, ce fichier JS va rester en permanence au sein du navigateur tel qu'il a été chargé initialement. On peut alors utiliser ses variables globales pour y stocker de l'information sur les différentes actions de l'utilisateur. C'est ce que nous allons voir maintenant. Nous allons non seulement gérer la session côté client mais repenser l'application JS afin de solliciter le moins possible le serveur.

7.6.2. L'action [/ajax-12]

  

L'action [/ajax-12] est la suivante :


    @RequestMapping(value = "/ajax-12", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax12() {
        return "vue-12";
}

La vue [vue-12.xml] est la suivante :

  

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>Ajax-12</title>
        <link rel="stylesheet" href="/css/ajax01.css" />
        <script type="text/javascript" src="/js/jquery/jquery-2.1.1.min.js"></script>
        <script type="text/javascript" src="/js/json3.js"></script>        
        <script type="text/javascript" src="/js/local12.js"></script>
    </head>
    <body>
        <h3>Ajax - 12 - Navigation dans une Application à Page Unique</h3>
        <h3>avec des flux HTML embarqués dans une chaîne jSON</h3>
        <h3>et une session gérée par le client JS</h3>
        <hr />
        <div id="content" th:include="vue-09-page1" />
        <img id="loading" src="/images/loading.gif" />
        <div id="erreur" style="background-color:lightgrey"></div>
    </body>
</html>
  • cette vue est identique à la vue [vue-09] à la différence près du script JS utilisé en ligne 9 ;

La vue affichée est la suivante :

 

7.6.3. Le code JS de gestion du bouton [Rafraîchir]

  

Le code du fichier [local12.js] est le suivant :


// variables globales
var content;
var loading;
var erreur;
var page1;
var page2;
var value1;
var value2;
var session = {
        "cpt1" : 0,
        "cpt3" : 0
    };

// au chargement du document
$(document).ready(function() {
    // on récupère les références des différents composants de la page
    loading = $("#loading");
    loading.hide();
    erreur = $("#erreur");
    erreur.hide();
    content = $("#content");
});
  • lignes 17-21 : lorsque la page maître est chargée, on mémorise les références des trois composants identifiés par [loading, erreur, content] dans les variables globales des lignes 2-4 ;
  • lignes 5-6 : pour mémoriser les deux pages ;
  • lignes 7-8 : pour mémoriser les deux valeurs postées par le lien [Valider] ;
  • ligne 9 : la session. Elle mémorise côté client les valeurs des compteurs [cpt1, cpt3] ;

La fonction [postForm] gère le clic sur le bouton [Rafraîchir] :


function postForm() {
    console.log("postForm");
    // on poste la session
    var post = JSON3.stringify(session);
    // on fait un appel Ajax à la main
    $.ajax({
        url : '/ajax-13',
        headers : {
            'Accept' : 'application/json',
            'Content-Type' : 'application/json'
        },
        type : 'POST',
        data : post,
        dataType : 'json',
        beforeSend : onBegin,
        success : function(data) {
            ...
        },
        error : onError,
        complete : onComplete
    })
}

Les diffrences avec la version précédente sont les suivantes :

  • l'URL de la ligne 7 est différente ;
  • ligne 4 : on poste une valeur alors qu'auparavant on n'en postait pas. Cette valeur est la chaîne jSON de la session. Le principe est le suivant :
    • le client envoie la session au serveur,
    • celui-ci la modifie et lui renvoie,
    • le client mémorise la nouvelle session ;
  • ligne 10 : on envoie un document au format jSON (valeur postée) ;
  • ligne 13 : on a quelque chose à poster ;
  • lignes 15-20 : les fonctions [beforeSend, error, complete] sont celles de la version précédente. Seule la fonction [success] change (lignes 16-18) ;

7.6.4. L'action [/ajax-13]

  

L'action [/ajax-13] est la suivante :


    @RequestMapping(value = "/ajax-13", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
    @ResponseBody()
    public JsonResult13 ajax13(@RequestBody SessionModel2 session2, HttpServletRequest request,    HttpServletResponse response) {
    ...
}
  • ligne 3 : le paramètre [@RequestBody SessionModel2 session2] récupère la session postée par le client. Celle-ci a le type [SessionModel2] suivant :
  

package istia.st.springmvc.models;

import java.io.Serializable;

public class SessionModel2 implements Serializable {

    private static final long serialVersionUID = 1L;
    // deux compteurs
    private int cpt1 = 0;
    private int cpt3 = 0;

    // getters et setters
    ...
}

La session [SessionModel2] mémorise les éléments suivants :

  • ligne 9 : le nombre de fois [cpt1] où la zone [Zone 1] est affichée ;
  • ligne 10 : le nombre de fois [cpt3] où la zone [Zone 3] est affichée ;

Poursuivons l'étude du code de l'action [/ajax-13] :


    @RequestMapping(value = "/ajax-13", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
    @ResponseBody()
    public JsonResult13 ajax13(@RequestBody SessionModel2 session2, HttpServletRequest request,    HttpServletResponse response) {
    ...
}
  • ligne 3, le type [JsonResult13] de la réponse est le suivant :
  

package istia.st.springmvc.models;

public class JsonResult13 {

    // data
    private String page2;
    private String zone1;
    private String zone3;
    private String erreur;
    private String value1;
    private Integer value2;

    // session
    private SessionModel2 session;

    // getters et setters
    ...
}
  • ligne 14 : la session. Le serveur la renvoie au client pour mémorisation ;
  • ligne 6 : le contenu HTML de la page n° 2 ;
  • ligne 7 : le contenu HTML de la zone [Zone 1] ;
  • ligne 8 : le contenu HTML de la zone [Zone 3] ;
  • ligne 9 : le message d'erreur éventuel ;
  • lignes 10-11 : deux informations calculées par le serveur et affichées par la page n° 2 ;

Poursuivons l'étude du code de l'action [/ajax-13] :


@RequestMapping(value = "/ajax-13", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
    @ResponseBody()
    public JsonResult13 ajax13(@RequestBody SessionModel2 session2, HttpServletRequest request,
            HttpServletResponse response) {
        // contexte Thymeleaf
        WebContext thymeleafContext = new WebContext(request, response, request.getServletContext());
        // réponse
        JsonResult13 result = new JsonResult13();
        result.setSession(session2);
        // on rend une réponse aléatoire
        int cas = new Random().nextInt(3);
        switch (cas) {
        case 0:
            // zone 1 active
            setZone1B(thymeleafContext, result);
            return result;
        case 1:
            // zone 3 active
            setZone3B(thymeleafContext, result);
            return result;
        case 2:
            // zones 1 et 3 actives
            setZone1B(thymeleafContext, result);
            setZone3B(thymeleafContext, result);
            return result;
        }
        return null;
    }
  • ligne 9 : la session est mise dans le résultat de l'action ;

La méthode [setZone1B] qui active la zone [Zone 1] est la suivante :


    private void setZone1B(WebContext thymeleafContext, JsonResult13 result) {
        // on récupère la session
        SessionModel2 session = result.getSession();
        // zone 1 active
        // flux HTML
        int cpt1 = session.getCpt1() + 1;
        thymeleafContext.setVariable("cpt1", cpt1);
        thymeleafContext.setLocale(new Locale("fr", "FR"));
        String zone1 = engine.process("vue-09-zone1", thymeleafContext);
        result.setZone1(zone1);
        // session
        session.setCpt1(cpt1);
}
  • ligne 3 : on récupère la session. Elle va être modifiée ligne 12 avec le nouveau compteur [cpt1]. On rappelle que cette session va être renvoyée au client ;
  • ligne 10 : la nouvelle zone [Zone 1] ;

La méthode [setZone3B] qui active la zone [Zone 3] est analogue :


private void setZone3B(WebContext thymeleafContext, JsonResult13 result) {
        // on récupère la session
        SessionModel2 session = result.getSession();
        // zone 3 active
        // flux HTML
        int cpt3 = session.getCpt3() + 1;
        thymeleafContext.setVariable("cpt3", cpt3);
        thymeleafContext.setLocale(new Locale("en", "US"));
        String zone3 = engine.process("vue-09-zone3", thymeleafContext);
        result.setZone3(zone3);
        // session
        session.setCpt3(cpt3);
    }

7.6.5. Traitement de la réponse de l'action [/ajax-13]

Côté client, la réponse jSON de l'action [/ajax-13] est traitée par la fonction [onSuccess] suivante :


function postForm() {
    console.log("postForm");
    // on poste la session
    var post = JSON3.stringify(session);
    // on fait un appel Ajax à la main
    $.ajax({
    ...
        success : function(data) {
            // on mémorise la session
            session = data.session;
            // on met à jour les deux zones
            if (data.zone1) {
                $("#zone1-content").html(data.zone1);
                $("#zone1").show();
            } else {
                $("#zone1").hide();
            }
            if (data.zone3) {
                $("#zone3").show();
                $("#zone3-content").html(data.zone3);
            } else {
                $("#zone3").hide();
            }
        },
...
    })
}
  • lignes 12-17 : si le serveur a mis quelque chose dans le champ [zone1] de la réponse, alors il faut régénérer la zone [Zone 1] et l'afficher, sinon elle doit être cachée ;
  • lignes 18-23 : même raisonnement pour la zone [Zone 3] ;

7.6.6. Affichage de la page [Page 2]

Le code HTML du lien [Valider] est le suivant :


<a href="javascript:valider()">Valider</a>

La fonction JS [valider] est la suivante :


// validation des valeurs saisies
function valider() {
    // on mémorise la page 1
    page1 = content.html();
    // on mémorise les valeurs saisies
    value1 = $("#text1").val().trim();
    value2 = $("#text2").val().trim();
    // valeur postée
    var post = JSON3.stringify({
        "value1" : value1,
        "value2" : value2,
        "pageRequired" : page2 ? false : true
    });
    // on fait un appel Ajax à la main
    $.ajax({
        url : '/ajax-14',
        headers : {
            'Accept' : 'application/json',
            'Content-Type' : 'application/json'
        },
        type : 'POST',
        data : post,
        dataType : 'json',
        beforeSend : onBegin,
        success : function(data) {
        ...
        },
        error : onError,
        complete : onComplete
    })
}
  • on va faire un POST qui normalement va nous faire passer à la page n° 2 ;
  • ligne 4 : on mémorise la page n° 1 afin de pouvoir y revenir ultérieurement ;
  • lignes 6-7 : l'opération précédente ne mémorise pas les valeurs saisies, juste le code HTML de la page. Aussi mémorise-t-on maintenant les deux valeurs saisies dans le formulaire ;
  • lignes 9-13 : les deux valeurs saisies sont mises dans une chaîne jSON. C'est elle qui sera postée ;
  • ligne 12 : un paramètre pour indiquer au serveur si on a besoin de la page n° 2. Nous allons procéder ainsi. Nous allons demander la page n° 2 une première fois, puis la mémoriser dans la variable JS [page2]. Ensuite, nous ne la redemanderons plus. Nous utiliserons la page en cache. Ligne 2, [pageRequired] vaut [true] si la variable [page2] ne contient rien, [false] sinon ;
  • on notera que la session n'est pas postée. En effet, celle-ci mémorise des compteurs que l'action [/ajax-14] de la ligne 20 ne modifie pas ;

7.6.7. L'action [/ajax-14]

L'action [/ajax-14] est la suivante :


@RequestMapping(value = "/ajax-14", method = RequestMethod.POST)
    @ResponseBody
    public JsonResult13 ajax14(@RequestBody @Valid PostAjax14 post, BindingResult bindingResult, Locale locale,    HttpServletRequest request, HttpServletResponse response) {
        ...
    }
  • ligne 3 : la réponse est toujours de type [JsonResult13] ;
  • ligne 3 : la valeur postée est encapsulée dans le type [PostAjax14] suivant :

package istia.st.springmvc.models;

public class PostAjax14 extends PostAjax11A {

    // page 2
    private boolean pageRequired;

    // getters et setters
    ...
}
  • ligne 3 : la classe [PostAjax14] étend la classe [PostAjax11A] de la version précédente. Elle a donc une structure [value1, value2, pageRequired] ;

L'action [/ajax-14] se poursuit de la façon suivante :


    @RequestMapping(value = "/ajax-14", method = RequestMethod.POST)
    @ResponseBody
public JsonResult13 ajax14(@RequestBody @Valid PostAjax14 post, BindingResult bindingResult, Locale locale,    HttpServletRequest request, HttpServletResponse response) {
        // contexte Thymeleaf
        WebContext thymeleafContext = new WebContext(request, response, request.getServletContext());
        // réponse
        JsonResult13 result = new JsonResult13();
        // post valide ?
        if (bindingResult.hasErrors()) {
            // on renvoie une erreur
            result.setErreur(getErreursForModel(bindingResult));
            return result;
        }
        // on envoie la page 2
        result.setValue1(post.getValue1());
        result.setValue2(post.getValue2());
        // page requise ?
        if (post.isPageRequired()) {
            result.setPage2(engine.process("vue-12-page2", thymeleafContext));
        }
        return result;
}
  • lignes 9-13 : si les valeurs postées [value1, value2] sont invalides, on renvoie un message d'erreur ;
  • lignes 15-16 : normalement, le serveur devrait faire un calcul avec les valeurs postées. Ici, il se contente de les renvoyer pour montrer qu'il les a bien reçues ;
  • lignes 18-20 : la page n° 2 n'est renvoyée que si elle a été demandée par le client. Ligne 19, la vue [vue-12-page2] est nouvelle :
 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <body>
        <h2>Page 2</h2>
        <p>
            <h4>Valeurs saisies :</h4>
            <p>
                Chaîne de caractères :
                <span id="value1"></span>
            </p>
            <p>
                Nombre entier :
                <span id="value2"></span>
            </p>
            <a href="javascript:retourPage1()">Retour à la page 1</a>
        </p>
    </body>
</html>
  • le code XML ne contient plus de valeurs évaluées par Thymeleaf comme c'était le cas auparavant ;
  • on a identifié les zones où placer les valeurs renvoyées [value1, value2] par le serveur. Ligne 9, [id='value1'] désigne l'endroit où placer [value1]. Ligne 13, même chose pour [value2] ;

7.6.8. Traitement de la réponse de l'action [/ajax-14]

La réponse de l'action [/ajax-14] est traitée par la fonction [success] suivante :


// validation des valeurs saisies
function valider() {
    ...
    // on fait un appel Ajax à la main
    $.ajax({
        ...
        success : function(data) {
            // erreur ?
            if (data.erreur) {
                // affichage erreur
                erreur.html(data.erreur);
                erreur.show();
            } else {
                // pas d'erreur
                erreur.hide();
                // page 2
                if (page2) {
                    // on utilise la page en cache
                    content.html(page2);
                } else {
                    // on mémorise la page 2
                    page2 = data.page2;
                    // on l'affiche
                    content.html(data.page2);
                }
                // on la met à jour avec les infos du serveur
                $("#value1").text(data.value1);
                $("#value2").text(data.value2);
            }
        },
...
    })
}
  • lignes 9-13 : si le serveur a renvoyé une erreur, on l'affiche ;
  • lignes 14-29 : le cas où il n'y a pas eu d'erreur. On doit alors afficher la page n° 2 ;
  • ligne 17 : on regarde si la page n° 2 est déjà enregistrée dans la variable [page2] ;
  • ligne 19 : dans ce cas, on utilise la variable [page2] pour afficher la page n° 2 ;
  • ligne 24 : sinon, on utilise le champ [data.page2] fourni par le serveur ;
  • ligne 22 : on prend soin de mémoriser la page n° 2 pour ne plus la redemander par la suite ;
  • lignes 27-28 : dans la page n° 2, on affiche les deux informations [value1, value2] envoyées par le serveur ;

7.6.9. Retour à la page n° 1

Le lien [Retour vers la page 1] dans la page N° 2 est le suivant :


<a href="javascript:retourPage1()">Retour à la page 1</a>

La méthode JS [retourPage1] est la suivante :


// retour page 1
function retourPage1() {
    // on régénère la page 1
    content.html(page1);
    // on régénère les saisies
    $("#text1").val(value1);
    $("#text2").val(value2);
}
  • c'est une action JS sans interaction avec le serveur car la page n° 1 a été mémorisée localement dans la variable [page1] ;
  • ligne 4 : on régénère la page n° 1 ;
  • ligne 6-7 : seule la partie HTML de la page n° 1 avait été mémorisée. Pas les saisies. On doit donc régénérer celles-ci ;

7.6.10. Conclusion

En exploitant les possibilités du modèle APU, nous avons réussi à simplifier le serveur web qui est maintenant sans état (absence de session) et est moins sollicité :

  • nous avons supprimé l'interaction avec le serveur dans la fonction JS [retourPage1]) ;
  • le serveur ne génère la page n° 2 qu'une fois ;

7.7. Structuration du code Javascript en couches

7.7.1. Introduction

Le code Javascript de l'application précédente commence à devenir complexe. Il est temps qu'on le structure en couches. L'application va rester la même que précédemment. Nous n'allons pas toucher au serveur sauf pour ce qui est de définir une nouvelle page de démarrage. Nous allons refaçonner le code JS.

La nouvelle architecture sera la suivante :

7.7.2. La page de démarrage

L'action qui lance l'application est l'action [/ajax-16] suivante :


    @RequestMapping(value = "/ajax-16", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
    public String ajax16() {
        return "vue-16";
}

Elle affiche la vue [vue-16.xml] suivante :


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta name="viewport" content="width=device-width" />
        <title>Ajax-12</title>
        <link rel="stylesheet" href="/css/ajax01.css" />
        <script type="text/javascript" src="/js/jquery/jquery-2.1.1.min.js"></script>
        <script type="text/javascript" src="/js/json3.js"></script>
        <script type="text/javascript" src="/js/local16-dao.js"></script>
        <script type="text/javascript" src="/js/local16-ui.js"></script>
    </head>
    <body>
        <h3>Ajax - 16 - Navigation dans une Application à Page Unique</h3>
        <h3>Structuration du code JS</h3>
        <hr />
        <div id="content" th:include="vue-09-page1" />
        <img id="loading" src="/images/loading.gif" />
        <div id="erreur" style="background-color:lightgrey"></div>
    </body>
</html>
  • lignes 9-10 : le code JS a été placé dans deux fichiers différents :
    • [local-ui] implémente la couche [présentation],
    • [local-dao] implémente la couche [DAO] ;
  

7.7.3. Implémentation de la couche [DAO]

7.7.4. Interface

La couche [DAO] dans [local-dao.js] va présenter l'interface suivante à la couche [présentation] :


function updatePage1(deferred, sendMeBack)

pour mettre à jour la page 1 avec le bouton [Rafraîchir]


function getPage2(deferred, sendMeBack, value1, value2, pageRequired)

pour afficher la page 2 avec le bouton [Valider]

Le Javascript n'a pas la notion d'interface. J'ai utilisé ce terme simplement pour indiquer que la couche [présentation] s'engageait à dialoguer avec la couche [DAO] uniquement via les deux fonctions précédentes.

7.7.5. Implémentation de l'interface

Le squelette de l'implémentation est le suivant :


var session = {
    "cpt1" : 0,
    "cpt3" : 0
};

// update Page 1
function updatePage1(deferred, sendMeBack) {
...
}

// page 2
function getPage2(deferred, sendMeBack, value1, value2, pageRequired) {
...
}

Le but de la couche [DAO] est de cacher à la couche [présentation] les détails des requêtes HTTP faites au serveur web. La session fait partie de ces détails. Elle est donc désormais gérée par la couche [DAO].

7.7.5.1. La fonction [updatePage1]

La fonction [updatePage1] est la fonction appelée par la couche [présentation] pour rafraîchir la page 1. Son code est le suivant :


// update Page 1
function updatePage1(deferred, sendMeBack) {
    // requête HTTP
    executePost(deferred, sendMeBack, '/ajax-13', session);
}
  • ligne 1 : la fonction [updatePage1] reçoit deux paramètres :
    1. un objet de type [jQuery.Deferred]. Ce type d'objet mémorise un état qui peut avoir trois valeurs ['pending', 'resolved', 'rejected']. Lorsqu'il arrive dans la fonction [updatePage1], il est dans l'état [pending] ;
    2. un objet JS à renvoyer dans la couche [présentation] ;

Toutes les requêtes HTTP sont effectuées par la fonction [executePost] suivante :


// requête HTTP
function executePost(deferred, sendMeBack, url, post) {
    // on fait un appel Ajax à la main
    $.ajax({
        headers : {
            'Accept' : 'application/json',
            'Content-Type' : 'application/json'
        },
        url : url,
        type : 'POST',
        data : JSON3.stringify(post),
        dataType : 'json',
        success : function(data) {
            // on mémorise la session
            if (data.session) {
                session = data.session;
            }
            // on rend le résultat
            deferred.resolve({
                "status" : 1,
                "data" : data,
                "sendMeBack" : sendMeBack
            });
        },
        error : function(jqXHR) {
            // on rend l'erreur
            deferred.resolve({
                "status" : 2,
                "data" : jqXHR.responseText,
                "sendMeBack" : sendMeBack
            });
        }
    });
}
  • ligne 1 : la fonction [executePost] exécute un appel Ajax de type POST. Elle attend quatre paramètres :
    1. un objet de type [jQuery.Deferred] dans l'état [pending] ;
    2. un objet JS à renvoyer dans la couche [présentation] ;
    3. l'URL du POST ;
    4. la valeur à poster en tant qu'objet JS ;
  • lignes 5-8 : la fonction poste du jSON (ligne 7) et reçoit du jSON (ligne 6) ;
  • ligne 11 : la valeur à poster est transformée en jSON ;
  • lignes 13-24 : la fonction exécutée en cas de succès de l'appel Ajax ;
  • lignes 19-23 : si le serveur a renvoyé une session, on la mémorise ;
  • lignes 13-18 : passent l'objet [deferred] dans l'état [resolved] en passant de plus un résultat avec les champs suivants :
    • [status] : à 1 pour un succès, à 2 pour un échec,
    • [data] : la réponse jSON du serveur,
    • [sendMeBack] : le 2ième paramètre de la fonction qui est un objet que l'appelant veut récupérer ;
  • lignes 17-31 : la fonction exécutée en cas d'échec de l'appel Ajax. On fait la même chose que précédemment avec deux différences :
    • [status] passe à 2 pour signaler une erreur ;
    • [data] est là encore la réponse jSON du serveur mais obtenue d'une façon différente ;

7.7.5.2. La fonction [getPage2]

La fonction [getPage2] est la suivante :


// page 2
function getPage2(deferred, sendMeBack, value1, value2, pageRequired) {
    // requête HTTP
    executePost(deferred, sendMeBack, '/ajax-14', {
        "value1" : value1,
        "value2" : value2,
        "pageRequired" : pageRequired,
    });
}
  • la fonction reçoit les paramètres suivants :
    1. [deferred] : un objet de type [jQuery.Deferred] dans l'état [pending],
    2. [sendMeBack] : un objet JS à renvoyer dans la couche [présentation],
    3. [value1] : la première saisie dans page 1,
    4. [value2] : la seconde saisie dans page 2,
    5. [pageRequired] : un booléen indiquant au serveur s'il doit ou non envoyer le flux HTML de la page n° 2 ;
  • la fonction [executePost] est appelée pour exécuter la requête HTTP nécessaire ;

7.7.6. La couche [présentation]

La couche [présentation] est implémentée par le fichier [local-ui.js]. Ce dernier reprend le code du fichier [local12.js] refaçonné pour utiliser la couche [DAO] précédente. Seules deux fonctions changent : [postForm] et [valider].

7.7.6.1. La fonction [postForm]

La fonction [postForm] est la suivante :


// update Page 1
function postForm() {
    // on met à jour la page 1
    var deferred = $.Deferred();
    loading.show();
    updatePage1(deferred, {
        'sender' : "postForm",
        'info' : 10
    });
    // affichage résultats
    deferred.done(postFormDone);
}
  • ligne 4 : on crée un objet [jQuery.Deferred]. Par défaut, il est dans l'état [pending] ;
  • ligne 5 : l'image d'attente est affichée
  • lignes 6-9 : la fonction [updatePage1] est exécutée. On passe un objet [sendMeBack] fictif, juste pour montrer à quoi ça peut servir ;
  • ligne 11 : le paramètre de la fonction [deferred.done] est elle-même une fonction. C'est la fonction à exécuter lorsque l'état de l'objet [deferred] passe dans l'état [resolved]. On vient de voir que la fonction DAO [executePost] passait l'état de cet objet à [resolved] à réception de la réponse du serveur. Cela signifie que lorsque la fonction [postFormDone] s'exécute, la réponse du serveur a été reçue ;

La fonction [postFormDone] est la suivante :


function postFormDone(result) {
    // fin attente
    loading.hide();
    // on récupère les données
    var data = result.data
    // pour démo
    console.log(JSON3.stringify(result.sendMeBack));
    // on analyse le status
    switch (result.status) {
    case 1:
        // on met à jour les deux zones
        if (data.zone1) {
            $("#zone1-content").html(data.zone1);
            $("#zone1").show();
        } else {
            $("#zone1").hide();
        }
        if (data.zone3) {
            $("#zone3").show();
            $("#zone3-content").html(data.zone3);
        } else {
            $("#zone3").hide();
        }
        break;
    case 2:
        // affichage erreur
        erreur.html(data);
        break;
    }
}
  • ligne 1 : le paramètre [result] reçu est le paramètre passé à la méthode [deferred.resolve] dans la fonction [executePost], par exemple :

            // on rend le résultat
            deferred.resolve({
                "status" : 1,
                "data" : data,
                "sendMeBack" : sendMeBack
});
  • ligne 5 : on récupère la réponse du serveur ;
  • lignes 10-24 : on a le code qui dans la version précédente était dans la fonction [onSuccess] de la fonction [postForm] ;
  • lignes 25-28 : on a le code qui dans la version précédente était dans la fonction [onError] de la fonction [postForm] ;

7.7.6.2. Le rôle du paramètre [sendMeBack]

A quoi sert le paramètre [sendMeBack] ? Regardons le code d'appel de la fonction [updatePage1] :


// update Page 1
function postForm() {
    // on met à jour la page 1
    var deferred = $.Deferred();
    loading.show();
    updatePage1(deferred, {
        'sender' : "postForm",
        'info' : 10
    });
    // affichage résultats
    deferred.done(postFormDone);
}

et la signature de la fonction [validerDone] :


function postFormDone(result) {
}

Comment peut faire la fonction [postForm] pour passer des informations à la fonction [postFormDone] ? Celle-ci, n'a qu'un paramètre [result]. Celui-ci est créé par la fonction [executePost] de la couche [DAO]. Pour transmettre des informations à la fonction [postFormDone], la fonction [postForm] doit d'abord les transmettre à la fonction [updatePage1]. C'est le rôle du paramètre [sendMeBack]. Il s'utilise de la façon suivante :


function postFormDone(result) {
    // fin attente
    loading.hide();
    // on récupère les données
    var data = result.data
    // pour démo
    console.log(JSON3.stringify(result.sendMeBack));
    // on analyse le status
    switch (result.status) {
...
  • ligne 7, la fonction [postFormDone] a retrouvé le paramètre [sendMeBack] initialement transmis à la fonction DAO [updatePage1] par la fonction [postForm] ;

7.7.7. La fonction [valider]

La fonction [valider] est la suivante :


// validation valeurs saisies
function valider() {
    // on mémorise la page 1
    page1 = content.html();
    // on mémorise les valeurs saisies
    value1 = $("#text1").val().trim();
    value2 = $("#text2").val().trim();
    // pas d'erreur
    erreur.hide();
    // on demande la page 2
    var deferred = $.Deferred();
    loading.show();
    getPage2(deferred, {
        'sender' : 'valider',
        'info' : 20
    }, value1, value2, page2 ? false : true);
    // affichage résultats
    deferred.done(validerDone);
}

et la fonction [validerDone] (ligne 18) la suivante :


function validerDone(result) {
    // fin attente
    loading.hide();
    // on récupère les données
    var data = result.data
    // pour démo
    console.log(JSON3.stringify(result.sendMeBack));
    // on analyse le status
    switch (result.status) {
    case 1:
        // erreur ?
        if (data.erreur) {
            // affichage erreur
            erreur.html(data.erreur);
            erreur.show();
        } else {
            // pas d'erreur
            erreur.hide();
            // page 2
            if (page2) {
                // on utilise la page en cache
                content.html(page2);
            } else {
                // on mémorise la page 2
                page2 = data.page2;
                // on l'affiche
                content.html(data.page2);
            }
            // on la met à jour avec les infos du serveur
            $("#value1").text(data.value1);
            $("#value2").text(data.value2);
        }
        break;
    case 2:
        // affichage erreur
        erreur.html(data);
        erreur.show();
        break;
    }
}
  • ligne 5 : on récupère la réponse du serveur ;
  • lignes 10-32 : on a le code qui dans la version précédente était dans la fonction [onSuccess] de la fonction [valider] ;
  • lignes 34-38 : on a le code qui dans la version précédente était dans la fonction [onError] de la fonction [valider] ;

7.7.8. Tests

L'application continue à fonctionner comme auparavant et dans la console de Chrome, on peut voir les paramètres [sendMeBack] des fonctions [postForm] et [valider] :

 

7.8. Conclusion

Revenons au schéma général d'une application Spring MVC :

Grâce au Javascript embarqué dans les pages HTML et exécuté dans le navigateur et grâce au modèle APU, on peut déporter du code sur le navigateur et aboutir à l'architecture suivante :

  • on a une architecture client [2] / serveur [1] où le client et le serveur communiquent en jSON ;
  • en [1], la couche web Spring MVC délivre des vues, des fragments de vue, des données dans du jSON ;
  • en [2] : le code Javascript embarqué dans la vue chargée au démarrage de l'application peut être structuré en couches :
    • la couche [présentation] s'occupe des interactions avec l'utilisateur,
    • la couche [DAO] s'occupe de l'accès aux données via le serveur web [1] ,
    • la couche [métier] peut ne pas exister ou reprendre certaines des fonctionnalités non confidentielles de la couche [métier] du serveur afin de soulager celui-ci ;
  • le client [2] peut mettre certaines vues en cache afin là encore de soulager le serveur. Il gère la session ;