Utiliser Publicodes dans un projet Elm

Pourquoi ?

Elm

Elm est un langage de programmation purement fonctionnel qui est compilé en JavaScript. Il est volontairement frugale en fonctionnalités dans le but de faciliter la prise en main et de garantir la maintenabilité des applications. Il y a généralement qu’une seule façon de faire les choses en Elm, et c’est souvent la meilleure.

Le problème

La principale difficulté de l’utilisation de Publicodes dans un projet Elm (et la raison d’être de ce guide) est que Publicodes est un langage interprété, et que le seul interpréteur disponible actuellement est en JS. Or, Elm étant un langage purement fonctionnel, il n’est pas possible d’appeler une fonction JS directement depuis Elm. En effet, le système de types d’Elm est sound, c’est-à-dire qu’il garantit que le type de toutes valeurs manipulées dans le code Elm est connu à la compilation et ne permet pas de représenter des valeurs de type any ou unknown comme en JS.

De plus, la librairie @publicodes/react-ui qui permet de générer une documentation interactive des règles d’un modèle Publicodes est une librairie React, et il n’est pas possible d’utiliser directement des composants React depuis une application Elm.

La solution

Pour cette raison nous allons devoir utiliser des ports pour communiquer entre l’application Elm et l’interpréteur Publicodes. Ces ports servent d’interface entre le code Elm et le code JS, et forcent à parser dans des structures de données Elm les valeurs manipulées en JS. Il faudra également utiliser des customElements afin de pouvoir utiliser les composants React de @publicodes/react-ui dans l’application Elm.

Pour la suite de ce guide, nous supposerons que vous êtes déjà familier avec Elm et possédez déjà une application Elm fonctionnelle à laquelle vous souhaitez y intégrer Publicodes. Si ce n’est pas le cas, je vous invite à consulter la documentation officielle.

Appel de Publicodes depuis Elm

Installation de Publicodes

Pour commencer, il vous faudra installer Publicodes dans votre projet Elm. Pour ce faire, vous pouvez utiliser npm ou yarn :

yarn add publicodes

Initialisation de l’interpréteur Publicodes et de l’application Elm

Pour commencer, nous allons devoir instancier un moteur Publicodes et préciser à l’application Elm les règles du modèle à utiliser, ainsi que la situation de départ.

Une situation Publicodes est un objet JS qui contient qui associe à chaque nom de règle la valeur de la réponse de l’utilisateurice.

Un modèle Publicodes est un objet JS qui contient l’ensemble des règles du modèle. Pour créer ce modèle, à partir d’un ensemble de fichiers .publicodes, vous pouvez utilisez la fonction getModelFromSources du package @publicodes/tools.

Astuce : si vous souhaitez publier votre modèle sur NPM, vous pouvez suivre le guide dédié.

Initialisation du code JS

Dans votre code JS, cela ressemblera à quelque chose comme ceci :

// main.ts
import { Elm } from './Main.elm';
import Engine, { Situation } from 'publicodes';
import rules from 'model.json';

// (optionnel) Récupération de la situation sauvegardée dans le localStorage
const situation = JSON.parse(localStorage.getItem('situation') ?? '{}');

const engine = new Engine(rules).setSituation(situation);

const app = Elm.Main.init({
    flags: { rules, situation },
    node: document.getElementById('app')
});

Vous avez à présent instancié le moteur Publicodes et l’application Elm. Cependant, les règles du modèle et la situation de départ passées à l’application via les flags ne sont pas encore utilisées.

Initialisation de l’application Elm

Pour utiliser les règles du modèle et la situation de départ dans l’application Elm, nous allons devoir les récupérer via les flags et les stocker dans le Model de l’application.

Pour cela, vous allez devoir sérialiser et désérialiser le règles ainsi que la situation. Le plus simple est de récupérer ce fichier Publicodes.elm qui contient toutes les fonctions et types nécessaires pour la manipulation de modèles Publicodes.

Voici un exemple de code Elm qui récupère les règles et la situation du modèle via les flags :

-- Main.elm
import Publicodes as P

type alias Flags =
    { rules : P.RawRules
    , situation : P.Situation
    }


flagsDecoder : Json.Decode.Decoder Flags
flagsDecoder =
    Json.Decode.succeed Flags
        |> Decode.required "rules" P.rawRulesDecoder
        |> Decode.required "situation" P.situationDecoder


type alias Model =
    { rules : P.RawRules
    , situation : P.Situation
    , evaluations : Dict P.RuleName P.NodeValue
    , result : Maybe P.NodeValue
    , errorMsg : Maybe String
    }


emptyModel : Model
emptyModel =
    { rules = Dict.empty
    , situation = Dict.empty
    , evaluations = Dict.empty
    , result = Nothing
    , errorMsg = Nothing
    }


init : Json.Encode.Value -> ( Model, Cmd Msg )
init flags =
    case Json.Decode.decodeValue flagsDecoder flags of
        Ok { rules, situation } ->
            ( { emptyModel | rules = rules, situation = situation }
            , Effect.evaluateAll (Dict.keys rules)
            )

        Err e ->
            ( { emptyModel | errorMsg = Just (Json.Decode.errorToString e) }
            , Cmd.none
            )

Cas n°1 : Mettre à jour la situation

Maintenant que nous avons d’un côté le moteur et de l’autre l’application Elm, nous allons devoir synchroniser les deux pour que la situation de l’application Elm soit toujours à jour avec celle du moteur.

Pour cela, nous allons créer un port Elm qui permettra de mettre à jour la situation du moteur à chaque fois que la situation de l’application Elm est modifiée. Ainsi, qu’une souscription qui permettra de notifier l’application Elm que la situation du moteur a été mise à jour.

-- Effect.elm

port module Effect exposing (..)

import Json.Encode
import Publicodes as P

-- COMMANDS

port updateSituation : ( P.RuleName, Json.Encode.Value ) -> Cmd msg

-- SUBSCRIPTIONS

port situationUpdated : (() -> msg) -> Sub msg

Ainsi, à chaque nouvelle réponse de l’utilisateurice, nous pouvons envoyer la commande correspondante :

-- Main.elm
import Publicodes as P

type Msg
    = NewAnswer ( P.RuleName, P.NodeValue )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NewAnswer ( rule, value ) ->
            ( { model | situation = Dict.insert rule value model.situation }
            , Effect.updateSituation ( rule, P.nodeValueEncoder value )
            )

Côté JS, nous allons devoir écouter les messages envoyés par le port updateSituation et mettre à jour la situation du moteur Publicodes :

app.ports.updateSituation.subscribe(([rule, value]: [RuleName, PublicodesExpression]) => {
    const newSituation = { ...engine.getSituation(), [rule]: value };
    engine.setSituation(newSituation);
    // (optionnel) localStorage.setItem('situation', JSON.stringify(newSituation));
    app.ports.situationUpdated.send(null);
});

Cas n°2 : Évaluer les règles

Maintenant que nous avons synchronisé la situation, nous devons évaluer les règles du modèles à chaque fois que la situation est mise à jour.

Pour cela, nous allons créer un nouveau port Elm qui permettra de demander l’évaluation d’une règle du modèle, ainsi qu’une souscription qui permettra de recevoir le résultat de l’évaluation.

-- Effect.elm
import Publicodes as P

-- COMMANDS

port evaluateAll : List (P.RuleName) -> Cmd msg

-- SUBSCRIPTIONS

port evaluatedRules : (List ( P.RuleName, Json.Encode.Value ) -> msg) -> Sub msg

Ainsi, à chaque fois que l’application Elm est notifiée que la situation a été mise à jour, nous pouvons demander l’évaluation de toutes les règles du modèle :

-- Main.elm
import Publicodes as P

type Msg
    = NewAnswer ( P.RuleName, P.NodeValue )
    | Evaluate
    | UpdateEvaluations (List ( P.RuleName, Json.Encode.Value ))
    | NoOp


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NewAnswer ( rule, value ) ->
            ( { model | situation = Dict.insert rule value model.situation }
            , Effect.updateSituation ( rule, P.nodeValueEncoder value )
            )

        Evaluate ->
            -- On demande l'évaluation de toutes les règles du modèle Publicodes
            ( model, Effect.evaluateAll (Dict.keys model.rules) )

        UpdateEvaluations evaluations ->
            -- On met à jour les évaluations des règles stockée dans le Model
            ( List.foldl updateEvaluation model evaluations
            , Cmd.none
            )

        NoOp ->
            ( model, Cmd.none )


updateEvaluation : ( P.RuleName, Json.Encode.Value ) -> Model -> Model
updateEvaluation ( name, encodedValue ) model =
    case Json.Decode.decodeValue P.nodeValueDecoder encodedValue of
        Ok eval ->
            { model | evaluations = Dict.insert name eval model.evaluations }

        Err e ->
            { model | errorMsg = Just (Json.Decode.errorToString e) }


subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.batch
        [ Effect.situationUpdated (_ -> Evaluate)
        , Effect.evaluatedRules UpdateEvaluations
        ]

Côté JS, nous allons devoir écouter les messages envoyés par le port evaluateAll et évaluer les règles du modèle :

app.ports.evaluateAll.subscribe((rules: RuleName[]) => {
    const results = rules.map((rule) => [rule, engine.evaluate(rule)?.nodeValue ?? null]);
    app.ports.evaluatedRules.send(results);
});

Avec cette méthode, vous pouvez accéder au résultat (nodeValue) de l’évaluation des règles dans l’application Elm. Cependant, vous aurez probablement besoin de conditionner l’affichage de certaines questions en fonction de si la règle est applicable ou non. En effet, les modèles Publicodes peuvent être représentés comme des arbres de décision, et certaines branches peuvent ne pas être applicables en fonction d’une situation donnée. Par exemple, si l’utilisateurice répond qu’elle n’a pas d’enfant, les règles liées à la garde d’enfants ne seront pas pertinentes à poser.

Pour cela, il vous suffit de rajouter dans le résultat de l’évaluation si la règle est applicable ou non :

app.ports.evaluateAll.subscribe((rules: RuleName[]) => {
    const results = rules.map((rule) => [
        rule,
        {
            nodeValue: engine.evaluate(rule)?.nodeValue ?? null,
            isApplicable: engine.evaluate({ 'est applicable': rule })?.nodeValue === true
        }
    ]);
    app.ports.evaluatedRules.send(results);
});

Astuce : pour plus d’informations sur la création de formulaires à partir de modèles Publicodes, vous pouvez consulter le guide dédié.

Utilisation de @publicodes/react-ui pour la documentation

Publicodes propose une librairie React @publicodes/react-ui qui permet d’afficher une documentation interactive des règles du modèle. Malheureusement, on ne peut pas utiliser directement des composants React depuis une application Elm.

Pour contourner ce problème, nous allons utiliser les customElements. Avec les customElements, nous pouvons créer des composants HTML personnalisés qui peuvent être utilisés dans n’importe quelle application JS, y compris une application Elm.

Création du composant RulePage

Pour commencer, nous devons créer la page de documentation à partir du composant RulePage de @publicodes/react-ui comme on le ferait dans une application React classique :

// RulePage.tsx
import React from "react"
import Engine from "publicodes"
import { RulePage } from "@publicodes/react-ui"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"

import "./rule-page.css"

export type Props = {
    engine: Engine
    rulePath: string
    documentationPath: string
}

export default function ({ engine, rulePath, documentationPath }: Props) {
    return (
        <RulePage
            engine={engine}
            rulePath={rulePath}
            documentationPath={documentationPath}
            searchBar={true}
            language="fr"
            npmPackage="publicodes-evenements"
            renderers={{
                Text: ({ children }) => (
                    <Markdown
                        className={"markdown"}
                        remarkPlugins={[remarkGfm]}
                    >
                        {children}
                    </Markdown>
                ),
                Link: ({ to, children }) => (
                    <button
                        className="link"
                        onClick={(e) => {
                            e.preventDefault()
                            engine.getElmApp().ports.reactLinkClicked.send(to)
                        }}
                    >
                        {children}
                    </button>
                ),
            }}
        />
    )
}

Création du customElement

Afin de pouvoir utiliser le composant RulePage dans une application Elm, nous allons devoir effectuer le rendu du composant dans un customElement.

Pour cela, nous devons définir ce customElement :

// RulePageCustomElement.tsx
import React, { Suspense } from "react"
import { Root, createRoot } from "react-dom/client"
import Engine from "publicodes"

// Chargement dynamique du composant RulePage
const RulePage = React.lazy(() => import("./RulePage.tsx"))

// id de la div racine de l'application React
const reactRootId = "react-root"

// Définition du customElement à partir d'un moteur Publicodes
export function defineCustomElementWith(engine: Engine) {
    customElements.define(
        "publicodes-rule-page",
        class extends HTMLElement {
            reactRoot: Root
            engine: Engine

            // Définition des attributs observés qui déclenchent un re-render du
            // composant
            static observedAttributes = [
                "rule",
                "documentationPath",
                "situation",
            ]

            constructor() {
                super()
                // Création de la racine de l'application React dans le DOM
                this.reactRoot = createRoot(
                    document.getElementById(reactRootId) as HTMLElement
                )
                this.engine = engine
                this.renderElement()
            }

            connectedCallback() {
                this.renderElement()
            }

            attributeChangedCallback() {
                this.renderElement()
            }

            renderElement() {
                // Récupère les attributs de l'élément HTML qui sont définis
                // dans l'appel de la balise customElement
                const rulePath = this.getAttribute("rule") ?? ""
                const documentationPath =
                    this.getAttribute("documentationPath") ?? ""

                if (!rulePath || !documentationPath) {
                    return null
                }

                // Rendu du composant RulePage dans le noeud React
                this.reactRoot.render(
                    <Suspense
                        fallback={
                            <div className="flex flex-col items-center justify-center mb-8 w-full">
                                <div className="loading loading-lg text-primary mt-4"></div>
                            </div>
                        }
                    >
                        <RulePage
                            engine={this.engine}
                            rulePath={rulePath}
                            documentationPath={documentationPath}
                        />
                    </Suspense>
                )
            }
        }
    )
}

Une fois le customElement défini, nous devons le créer avec le moteur Publicodes courant :

// main.ts
import { defineCustomElementWith } from './RulePageCustomElement';

const app = ...
const engine = ...

// Définition du customElement RulePage avec le moteur Publicodes courant
defineCustomElementWith(engine);

app.ports...

Utilisation du customElement depuis Elm

Pour utiliser le customElement depuis Elm, il suffit d’utiliser le nouveau tag HTML publicodes-rule-page dans la vue Elm, en passant les attributs nécessaires :

-- Main.elm

-- [rule] ici est le nom de la règle à afficher, elle est récupérée à partir de
-- l'URL par exemple.
viewRulePage : Model -> Html Msg
viewRulePage { session, rule } =
    let
        serializedSituation =
            Json.Encode.encode 0 (P.encodeSituation session.situation)
    in
    node "publicodes-rule-page"
        [ attribute "rule" rule
        , attribute "documentationPath" "/documentation"
        , attribute "situation" serializedSituation
        ]
        []

Pour que le rendu React se fasse correctement, il est important de s’assurer de rendre la div react-root dans laquelle le composant React sera rendu à la racine de la vue Elm :

-- Main.elm

view : Model -> Html Msg
view model =
    div []
        [ if model.showReactRoot then
            div [ id "react-root" ] []

          else
            text ""
        , viewRulePage model
        ]