Hook initUpdate & synchronisation automatique avec un service externe

Request description

Bonjour,

Nous avons un retour d’expérience et une question de bonnes pratiques concernant l’utilisation du hook initUpdate avec des appels à un service externe.

Contexte

  • À l’ouverture du formulaire d’un objet LegalText en statut DRAFT, nous déclenchons automatiquement une synchronisation avec SharePoint.
  • Cette synchronisation est faite côté back-end (appels API + persistance en base des fichiers PDF/DOCX et de champs métier si nécessaire).
  • Fonctionnellement, cette synchro automatique à l’ouverture est souhaitée.

Problème rencontré

  • Le service externe peut être temporairement indisponible (timeouts / 504).
  • Lorsque cela arrive pendant le initUpdate :
    • l’ouverture du formulaire peut être fortement ralentie (plus d’1 minute),
    • voire échouer, ce qui bloque l’utilisateur sans visibilité sur ce qu’il se passe.

Objectif

Nous souhaitons :

  • conserver une synchronisation automatique à l’ouverture,
  • sans bloquer ni dégrader l’UI,
  • et ajouter une visibilité utilisateur (ex : “synchronisation en cours”, “échec de synchronisation réssayer plus tard etc..”).

Questions

  1. Quelles sont les bonnes pratiques recommandées dans Simplicité pour gérer des appels API externes potentiellement longs ou instables dans un initUpdate ?
  2. Le tracker Simplicité est-il exploitable dans ce contexte (hors action explicite) pour informer l’utilisateur qu’une synchronisation est en cours ?
  3. Est-il préférable de :
  • déclencher ce type de traitement de manière asynchrone,du coup déplacer cette logique du hook initupdate ?
  • ou de le déplacer vers un autre mécanisme (action, job, statut de synchronisation), tout en conservant une UX fluide à l’ouverture du formulaire ?

Merci d’avance pour vos retours . :smiley:

Cordialement,

Hamza

1 Like

Bonjour Hamza,

Il n’y a pas vraiment de bonne pratique. Comme tu l’as constaté, un initUpdate long va mécaniquement ralentir le chargement du formulaire. La question de savoir comment éviter la latence, l’instabilité, tout en respectant les contraintes rendant nécéssaire la synchronisation, est purement applicative.

Le tracker ne s’affiche que pour les jobs lancés via la UI. Il est par contre tout à fait possible de lancer une action de synchronisation en parallène depuis le initUpdate, et afficher un message standard à l’utilisation (“une synchro a été lancée, vous serez notifié à la finalisation du traitement”). L’action en question peut très bien se conclure par une notification à l’utilisateur.

Si ce traitement n’est pas un pré-requis à l’affichage du formulaire, lancer une tâche asynchrone semble pertinent.

1 Like

[Message prédéfini]

Nous conseillons aux utilisateurs de marquer comme “solution” la réponse résolvant leur problématique pour permettre au support de mieux suivre les sujets non résolus, et à la communauté de trouver plus facilement la bonne réponse.

Vos messages indiquant une résolution du problème, nous avons réalisé cette opération pour vous.

Bonjour Simon,

Merci encore pour ton retour.

Nous avons poursuivi les tests sur la démo autour de ce sujet.

À ce stade, ce que nous avons mis en place est le suivant :

  • déclenchement d’une action asynchrone au chargement du formulaire côté front ;
  • exécution du traitement côté back ;
  • simulation d’un traitement externe dans la démo ;
  • blocage d’une zone d’attribut et déblocage à la fin du traitement
  • mise à jour d’un attribut à la fin du job (dans notre cas, la date du jour) ;
  • affichage du tracker pendant l’exécution ;
  • test d’un rechargement du formulaire à la fin pour refléter la nouvelle valeur ;
  • ajout d’un garde côté front pour éviter de relancer automatiquement le traitement après reload.

Le fonctionnement async en lui-même est OK.

Le blocage se situe surtout à la fin du tracker : nous voulons simplement mettre à jour la valeur affichée dans le formulaire, sans que cela ne relance automatiquement le tracker ou l’action async au rechargement du form.

Nous avons réussi à contourner cela avec un garde côté front, mais cela ressemble davantage à un workaround qu’à un pattern standard car le user ne peut le faire que une fois par session chargé…

Existe-t-il dans Simplicité une manière recommandée de rafraîchir proprement un attribut ou une zone du formulaire à la fin d’un traitement async, sans relancer le traitement updateduform en boucle ? Ou bien autre chose sur laquelle le lancement du tracker au chargement du formulaire peut se conditionner ?

Voici les classes et méthodes testé sur DemoOrder :

ACTION_ASYNCH_UPDATE_FORM.xml (2.6 KB)

DemoOrder Java
public class DemoOrder extends ObjectDB {
    private static final long serialVersionUID = 1L;

    @Override
    public boolean isActionEnable(String[] row, String action) {

        if ("ACTION_ASYNCH_UPDATE_FORM".equals(action)) {


            String status = getFieldValue("demoOrdStatus");

            boolean enabled = "P".equals(status);

            return enabled;
        }
        return true;
    }

    @BusinessObjectAction
    public String updateFormArea(Action action, AsyncTracker tracker) {
        
        setParameter("LOCK_FORM_RELOAD",false);

        final String lang = getGrant().getLang();
        final boolean isFrench = !lang.equals("ENU");

        final String demoId = getRowId();

        final String status = getFieldValue("demoOrdStatus");
        if (!"P".equals(status)) {
            return "SKIP: status != P";
        }

        if (tracker.isRunning()) return null;

        // 2) Init tracker
        tracker.setMinified(true);
        tracker.setCloseable(true);
        tracker.setMinifiable(true);
        tracker.setStoppable(true);
        tracker.setProgress(0);
        tracker.start();

        tracker.add("Début de synchro");
        tracker.message("Initialisation...");

        // 3) Choix scénario (simple, déterministe, sans paramètre)
        final int pick = Math.abs((demoId != null ? demoId.hashCode() : 0) % 3);
        final String scenario = pick == 0 ? "SUCCESS_3S" : (pick == 1 ? "SUCCESS_10S" : "ERROR");

        JobQueue.push(
                "updateFormAreaJob",
                () -> {
                    ObjectDB testDemo = getGrant().getTmpObject(this.getName());
                    synchronized (testDemo.getLock()) {
                        try {
                            String dateNow = Tool.getCurrentDatetime();
                            // testDemo.resetValues(true, ObjectField.DEFAULT_ROW_ID);
                            testDemo.select(demoId);
                            testDemo.setFieldValue("demoOrdComments", dateNow);
                            testDemo.getTool().validateAndSave();

                            AppLog.info(
                                    getClass(),
                                    "ZAZA",
                                    "DemoOrder mis à jour pour l'ID: " + demoId,
                                    getGrant());

                        } catch (ValidateException | SaveException e) {
                            AppLog.error(
                                    getClass(),
                                    "postSave",
                                    "Erreur lors de la sauvegarde de l'adaptation/version: "
                                            + e.getMessage(),
                                    null,
                                    getGrant());
                        }
                    }
                    try {
                        tracker.message("Synchronisation en cours...");
                        tracker.setProgress(10);

                        if ("SUCCESS_3S".equals(scenario)) {
                            // Succès en ~3s
                            Thread.sleep(1000);
                            tracker.add("Récupération des données...");
                            tracker.setProgress(50);

                            Thread.sleep(1000);
                            tracker.add("Mise à jour du formulaire...");
                            tracker.setProgress(80);

                            Thread.sleep(1000);
                            tracker.add("Synchronisation terminée (3s)");
                            tracker.setProgress(100);

                            // } else if ("SUCCESS_10S".equals(scenario)) {
                        } else {
                            // Succès en ~10s
                            Thread.sleep(2000);
                            tracker.add("Récupération des données...");
                            tracker.setProgress(30);

                            Thread.sleep(3000);
                            tracker.add("Téléchargement des documents...");
                            tracker.setProgress(60);

                            Thread.sleep(3000);
                            tracker.add("Mise à jour du formulaire...");
                            tracker.setProgress(90);

                            Thread.sleep(2000);
                            tracker.add("Synchronisation terminée (10s)");
                            tracker.setProgress(100);
                        }
                        // else {
                        //     // Échec
                        //     Thread.sleep(2000);
                        //     tracker.add("Erreur: service de synchro indisponible");
                        //     tracker.error("La synchronisation n'est pas disponible pour le
                        // moment. Veuillez réessayer plus tard.");
                        // }

                    } catch (Exception e) {
                        tracker.error("Erreur technique: " + e.getMessage());
                        AppLog.error(
                                getClass(),
                                "updateFormAreaJob",
                                "ERROR row_id=" + demoId,
                                e,
                                getGrant());
                    } finally {

                        tracker.stop();

                        AppLog.info(
                                getClass(),
                                "updateFormArea",
                                "END row_id=" + demoId + " scenario=" + scenario,
                                getGrant());
                    }
                });

        return null; // async => UI non bloquante
    }
// code existant suite....
    /** Quantity field name */
    private static final String QUANTITY_FIELDNAME = "demoOrdQuantity";

   
}

DemoOrder JS Hook
/**
 * Order business object
 * @class
 */
Simplicite.UI.BusinessObjects.DemoOrder = class extends Simplicite.UI.BusinessObject {
    /** @override */
    onLoadForm(ctn, obj, p) {
        super.onLoadForm(ctn, obj, p);

        // Note that this client-side logic will be overridden
        // anyway by server-side logic at save

        const pup = $ui.getUIField(ctn, obj, 'demoOrdPrdId.demoPrdUnitPrice').ui;
        const up = $ui.getUIField(ctn, obj, 'demoOrdUnitPrice').ui;
        const q = $ui.getUIField(ctn, obj, 'demoOrdQuantity').ui;
        const t = $ui.getUIField(ctn, obj, 'demoOrdTotal').ui;
        const v = $ui.getUIField(ctn, obj, 'demoOrdVAT').ui;

        const calcTotal = () => {
            t.val(q.val() * up.val());
            v.val(t.val() * parseFloat($grant.sysparams.DEMO_VAT) / 100);
        };

        // Change unit price if product is changed
        pup.on('change', () => {
            up.val(pup.val());
            calcTotal();
        });

        // recalculate total if quantity changes
        q.on('change', calcTotal);

        const setAreaReadOnly = (areaEl, ro) => {
            if (!areaEl) return;

            // Visuel : grisé + pas de clic
            areaEl.classList.toggle("lbc-sync-ro", ro);

            // Fonctionnel : disable tous les champs éditables
            const selectors = "input, textarea, select, button";
            areaEl.querySelectorAll(selectors).forEach((el) => {
                // on évite de casser les readonly natifs déjà présents
                if (ro) {
                    el.dataset.prevDisabled = el.disabled ? "1" : "0";
                    el.disabled = true;
                } else {
                    if (el.dataset.prevDisabled === "0") el.disabled = false;
                    delete el.dataset.prevDisabled;
                }
            });
        };

        const ensureSyncCss = () => {
            if (document.getElementById("lbc-sync-css")) return;
            const style = document.createElement("style");
            style.id = "lbc-sync-css";
            style.textContent = `
            .lbc-sync-ro {
              opacity: .65;
              filter: grayscale(.15);
              pointer-events: none; /* empêche clic/slider */
            }
          `;
            document.head.appendChild(style);
        };
        ensureSyncCss();

                // ===============================
        // ASYNC ACTION AUTO-START (clean)
        // ===============================
        const act = "ACTION_ASYNCH_UPDATE_FORM";
        const rowId = obj.getRowId();

        if (!rowId) {
            console.log("No rowId yet (creation mode) → async not started");
            return;
        }

        const bo = $ui.getAjax().getBusinessObject("DemoOrder");

        // FRONT GUARD
        const statusField = $ui.getUIField(ctn, obj, "demoOrdStatus");
        const status = statusField && statusField.ui ? statusField.ui.val()
            : obj.getFieldValue("demoOrdStatus");

        console.log("Status at load =", status);

        if (status !== "P") {
            console.log("Sync NOT started (status != P)");
            return;
        }

        const syncKey = `demo-order-sync-${rowId}`;
        const syncState = sessionStorage.getItem(syncKey);

        if (syncState === "running") {
            console.log("Sync already running for this record");
            return;
        }

        if (syncState === "done") {
            console.log("Sync already done for this record in this session");
            return;
        }

        console.log("Starting async action:", act, "rowId=", rowId);
        sessionStorage.setItem(syncKey, "running");

        (async () => {
            const area4 = ctn.find("#area4")[0] || document.getElementById("area4");
            const area5 = ctn.find("#area5")[0] || document.getElementById("area5");

            setAreaReadOnly(area4, true);
            setAreaReadOnly(area5, true);

            try {
                const startRes = await bo.action(act, {
                    values: {
                        row_id: rowId
                    }
                });

                console.log("Async action started:", startRes);

                if (!startRes || !startRes.tracker) {
                    console.warn("No tracker returned by async action");
                    sessionStorage.removeItem(syncKey);
                    setAreaReadOnly(area4, false);
                    setAreaReadOnly(area5, false);
                    return;
                }

                let finished = false;

                $ui.view.form.tracker(startRes.tracker, {
                    title: "Auto Synchronisation",
                    progress: function(cbk) {
                        bo.action(act, {
                            values: {
                                row_id: rowId
                            },
                            track: "status"
                        })
                        .then((st) => {
                            console.log("Tracker status =", st);

                            // Etat terminal  = T
                            if (!finished && st && st.state === "T") {
                                finished = true;

                                setAreaReadOnly(area4, false);
                                setAreaReadOnly(area5, false);

                                sessionStorage.setItem(syncKey, "done");

                                setTimeout(() => {
                                    $ui.reloadForm();
                                }, 300);
                            }

                            // En cas d'erreur terminale éventuelle
                            if (!finished && st && st.state === "E") {
                                finished = true;

                                setAreaReadOnly(area4, false);
                                setAreaReadOnly(area5, false);

                                sessionStorage.removeItem(syncKey);
                            }

                            cbk(st);
                        })
                        .catch((e) => {
                            console.error("Tracker progress error:", e);

                            if (!finished) {
                                finished = true;
                                sessionStorage.removeItem(syncKey);
                                setAreaReadOnly(area4, false);
                                setAreaReadOnly(area5, false);
                            }

                            cbk({ state: "E" });
                        });
                    }
                });

            } catch (e) {
                console.error("Start async failed:", e);
                sessionStorage.removeItem(syncKey);
                setAreaReadOnly(area4, false);
                setAreaReadOnly(area5, false);
            }
        })();

    }
};

Merci d’avance pour vos éclairages. :slight_smile:

Il n’y a pas de recommandation particulière sur le sujet hormis d’éviter le rechargement coûteux que vous envisagez :slight_smile:

Mon approche serait plutôt de lancer dans le onLoadForm un polling réguler d’un paramètre de session du type LEGALTEXT_FIELD_SETVALUE, setté pour l’utilisateur à une valeur standard (par exemple waiting-for-value, null, etc) au initUpdate, et à la valeur à remplacer à la fin du traitement async.

1 Like

Bonjour @Hamza , ma proposition est-elle assez claire? Avez-vous essayé de l’implémenter?