Logo Spiria

Tutoriel : construire un formulaire dans ProcessWire

21 mars 2019.

La programmation de formulaires dans ProcessWire n’est pas compliquée, mais peu documentée. Guy Verville illustre l’utilisation de l’API formulaires par l’exemple :

Les programmeurs sont gâtés avec ProcessWire, car ils ont accès à l’excellent outil de génération de formulaires qu’est FormBuilder. Ce module est payant, mais tout de même abordable, et il est particulièrement indiqué si on veut laisser la liberté aux administrateurs d’un site de développer leurs propres formulaires. Cependant, connaître les rouages de l’API des formulaires est tout aussi essentiel dans un contexte de programmation plus poussée. Étonnamment, la documentation relative à ce sujet est plutôt clairsemée et cet article se veut une tentative d’explication.

Références

  1. W3Schools: How TO — Login Form.” Il n’est jamais trop tard pour revisiter ses classiques !
  2. ProcessWire API Reference.” Notamment FormBuilder Class.
  3. Le code de ProcessWire : la partie qui nous intéresse est wire/modules/Inputfield. Chaque champ est une classe qu’il est possible d’invoquer.

Note concernant le code produit dans cet article

Nous avons activé l’appel par fonction des variables générales de PW. Le meilleur endroit pour placer la commande est dans le fichier config.php. À ce sujet, voir : “Various ways of accessing the ProcessWire API” : $config->useFunctionsAPI = true;.

Nous utilisons Twig comme moteur de rendu (notre « modèle », ou template, devient donc un contrôleur à part entière et le rendu HTML est déplacé vers le fichier Twig, d’où l’appel $this->view. Cette variable a été définie comme globale par le contrôleur principal MainController.php.

Nous aurons donc plusieurs fichiers, le contrôleur, la vue et les styles CSS. Nous ferons aussi appel à des fichiers provenant du cœur de ProcessWire.

Pour l’exercice, nous construirons un formulaire à deux colonnes :

decorative

Les étapes d’un formulaire

Soumettre un formulaire se fait en trois étapes :

  1. Présentation du formulaire ;
  2. Validation. Une fois soumis, on valide d’abord si les données requises sont présentes. S’il y a des erreurs, on présente de nouveau le formulaire à l’usager avec les données préremplies ;
  3. Traitement de données.

La lecture se poursuit maintenant à travers les commentaires du code :

public $values = [];

[...]

public function render(): void {

 // Le formulaire est aussitôt créé par l'appel de la fonction render().
 // Soit il sera vide, soit rempli avec des valeurs données. 
 // La fonction createForm est décrite plus loin.
 
$form = $this->createForm();

 // Nous vérifions si nous avons un formulaire soumis. 
 // Le bouton Envoyer est un excellent choix pour vérifier, 
 // mais il aurait pu s’agir d’un autre champ ou d'un bouton Annuler.

 if(input()->post("pf_submit_btn")) {

  // Nous devons d’abord valider.
  // La fonction processInput peut être interceptée pour une validation ultérieure.
  // Si une erreur est trouvée, $form->getErrors() nous l'indiquera.
  
   $form->processInput(input()->post);

  // Avons-nous des erreurs ?

  if(count($form->getErrors()) > 0) {

   // Nous avons trouvé des erreurs, nous affichons 
   // à nouveau le formulaire pour corriger la situation.
   // Le formulaire est rempli avec les messages d’erreur au-dessus des champs appropriés.
   
   $this->view->set("form", $form->render());

  } else {

   // Aucune erreur préliminaire trouvée. Nous pouvons traiter.

   $this->values["company"] = sanitizer()->input()->post("company");
   [...]

   // Les valeurs nous satisfont-elles ?
   // Si ce n’est pas le cas, renvoyez le formulaire à l’utilisateur !
   // Ici, le reste du code de traitement :
   [...]
  }

 } else {

  // Nous n’avons pas de données, nous devons les demander.
  // Le formulaire est par défaut vide (sauf pour la date dans cet exemple).
  // Nous utilisons ici le moteur de rendu Twig.

  $this->view->set("form", $form->render());
 }
}

Ces étapes peuvent être améliorées par injection de JavaScript afin de rendre plus agréable l’expérience usager. Nous nous en tiendrons le plus possible au code brut. L’essentiel des étapes pour produire les champs de formulaire se résume à :

  • Appeler le module du champ/formulaire concerné. Exemple : $form = modules()->get('InputfieldForm");.
  • Octroyer à la variable ainsi créée des attributs et des méthodes.
  • Quand il s’agit d’un champ : l’ajouter au formulaire ($form->add()) ou à un champ conteneur.

Continuons notre lecture du code en examinant createForm().


protected function createForm() {

 // Comme le formulaire est vide, toutes les valeurs le sont.
 // Mais ce formulaire pourrait être rempli avec des valeurs précédentes si
 // l’utilisateur a fait des erreurs. $this->values est donc examiné.

  if(empty($this->values)) {
   $this->values = [
    "company" => "",
    "city" => "",
    "email" => "",
    "prlanguages" => "",
    "language" => "",
    "date" => "",
   ];
  }
  // Initialisation du formulaire.
  // Si rien n’est inscrit à “action”, le formulaire recharge
  // la même page.
  $form = modules()->get("InputfieldForm"); // Le module de formulaire.
  $form->action = ""; // Recharge la même page.
  $form->method = "post"; // Méthode de soumission des valeurs.
  $form->attr("name+id", "pro_form"); // Ce sera autant le nom que l’id.
  $form->attr("class", "uk-form-stacked no-form-ul"); // Toute classe CSS.

  // CSRF
  // Champ caché qui nous protège des attaques CSRF.

  $f = modules()->get("InputfieldHidden");
  $f->attr("id+name", session()->CSRF->getTokenName());
  $f->attr("maxlength", 30);
  $f->attr("class", "uk-input");
  $f->attr("value", session()->CSRF->getTokenValue());

  // Champ pour intégrer tout marquage HTML. 
  // Attention, ProcessWire corrigera toute balise non fermée.

  $markup = modules()->get('InputfieldMarkup');
  $markup->value = "<div class='warning'>" . __("All fields are required") . "</div>";
  $form->add($markup);

Une fois le formulaire créé, on peut lui associer des champs.


  // Notre formulaire sera en deux colonnes,
  // Créons les deux fieldset avec le module InputfieldFieldset.

  $firstCol = modules()->get("InputfieldFieldset");
  $firstCol->attr("class", "firstCol");
  $form->add($firstCol);

  $secondCol = modules()->get("InputfieldFieldset");
  $secondCol->attr("class", "secondCol");
  $form->add($secondCol);

  // Champ de type “text”.
  // Ne pas oublier de mettre dans la balise de traduction
  // les diverses étiquettes de champ.
  // Les premiers champs vont dans la colonne de gauche.

  $f = modules()->get("InputfieldText");
  $f->set("label", __("Designer’s / architect’s office"));
  $f->attr("name+id", "company");
  $f->attr("maxlength", 50);
  $f->attr("class", "uk-form-width-medium uk-input");
  $f->attr("value", $this->values["company"]);
  $f->required(true);
  $f->attr("placeholder", __('Name of the company'));
  $firstCol->add($f);  // aurait pu être $form->add($f)

  // Champ de type “select”.
  // Les options peuvent provenir de ProcessWire.

  $options = [
   'montreal' => __("Montreal"),
   'quebec' => __("Quebec City")
  ];

  $f = modules()->get("InputfieldSelect");
  $f->set("label", __("City"));
  $f->attr("name+id", "city");
  $f->attr("maxlength", 50);
  $f->attr("class", "uk-form-width-medium uk-select");
  $f->attr("value", $this->values["city"]);
  $f->addOptions($options);
  $f->required(true);
  $firstCol->add($f);

  // Champ d’adresse électronique.
  // Processwire mettra automatiquement un champ de répétition
  // de l’adresse.

  $f = wire("modules")->get("InputfieldEmail");
  $f->set("label", __("Email"));
  $f->set('confirm', 1);
  $f->attr("name+id", "email");
  $f->attr("maxlength", 60);
  $f->attr("class", "uk-form-width-medium");
  $f->attr("value", $this->values["email"]);
  $f->required = true;
  $firstCol->add($f);

  
  // Champ Select avec autosélection JavaScript ("autocomplete").
  // Les valeur peuvent provenir de ProcessWire.
  // Ces options seront cependant envoyée au moteur Twig pour traitement JavaScript.
  // On ajoute à partir d’ici dans la deuxième colonne.

  $optionsLanguages = '
    "ActionScript",
    "AppleScript",
    "Asp",
    "BASIC",
    "C",
    "C++",
    "Clojure",
    "COBOL",
    "ColdFusion",
    "Erlang",
    "Fortran",
    "Groovy",
    "Haskell",
    "Java",
    "JavaScript",
    "Lisp",
    "Perl",
    "PHP",
    "Python",
    "Ruby",
    "Scala",
    "Scheme"';

  $f = modules()->get("InputfieldText");
  $f->set("label", __("Programming languages"));
  $f->attr("name+id", "prlanguages");
  $f->attr("maxlength", 50);
  $f->attr("class", "uk-form-width-medium");
  $f->attr("value", $this->values["prlanguages"]);
  $f->wrapClass("autocomplete");
  $f->required(true);
  $secondCol->add($f);

  // Champ de type “radio”.
  // Les options peuvent provenir de ProcessWire.
  // Remarquez l’option "optionColumns"
  // Si 0, ul class = InputfieldRadiosStacked. Si 1, ul class = InputfieldRadiosFloated

  $options = [
   'fr' => __("French"),
   'en' => __("English")
  ];

  $f = modules()->get("InputfieldRadios");
  $f->set("label", __("Language"));
  $f->attr("name+id", "language");
  $f->attr("maxlength", 50);
  $f->attr("class", "uk-form-width-medium uk-radio");
  $f->attr("value", $this->values["language"]);
  $f->set('optionColumns', 1); 
  $f->addOptions($options);
  $f->required(true);
  $secondCol->add($f);

  // Champ de date avec sélecteur JavaScript.

  $f = modules()->get("InputfieldDatetime");
  $f->set("label", __("Date"));
  $f->attr("name+id", "date");
  $f->attr("maxlength", 50);
  $f->attr("class", "uk-form-width-medium ");
  $f->attr("value", $this->values["date"]);
  $f->datepicker = 3;  // calendrier de type "popup"
  $f->dateInputFormat = "Y-m-d";
  $f->attr("value", time());
  $f->required(true);
  $f->prependMarkup("<div id='datepicker'>"); // Le champ est entouré d’une classe.
  $f->appendMarkup("</div>"); // On doit fermer la balise.
  $secondCol->add($f);

  
  // Textarea.
  // Ce dernier champ est attaché directement à $form si bien
  // qu’il prendra tout l’espace requis par CSS.

  $f = modules()->get("InputfieldTextarea");
  $f->set("label", __("Your message"));
  $f->attr("name+id", "message");
  $f->attr("rows", 10);
  $f->attr("class", "uk-form-width-medium uk-input");
  $f->attr("value", $this->values["message"]);
  $f->required(true);
  $f->attr("placeholder", __('Your message'));
  $form->add($f);

  
  // Submit.
  // Un formulaire n’est rien sans le bouton d’envoi!

  $f = modules()->get("InputfieldSubmit");
  $f->attr("id", "pf-submit-btn");
  $f->attr("name", "pf_submit_btn");
  $f->attr("value", __("Continue"));
  $f->attr("class", "blue-btn large");
  $form->add($f);

  // Nos options de langage de programmation sont envoyées
  // au moteur Twig.

  $this->view->set("optionsLanguages", $optionsLanguages);

  return $form;
 }

Présentation du formulaire dans Twig

C’est dans le fichier de présentation (view) qu’on insère les divers appels JavaScript nécessaires à l’activation de certaines propriétés. Le formulaire, en soi, est transmis par la variable {{form}}. Ceci peut être intégré de manière plus élégante. Il s’agit ici d’une démonstration. Remarquez les appels des différents scripts Jquery appartenant à ProcessWire. Le dernier script provient de W3Schools (je n’ai donc pas traduit les commentaires. Ici, tout est possible, notamment une gestion plus en ligne avec les standards UX/UI actuels.

<link rel="stylesheet"
  href="/wire/modules/Inputfield/InputfieldDatetime/timepicker/jquery-ui-timepicker-addon.min.css"/>

<div class="basic-page">{{ page.body | raw }}</div>
<div class="ui-widget">
{{ form  | raw }}
</div>

<script src="/wire/modules/Jquery/JqueryCore/JqueryCore.js"></script>
<script src="/wire/modules/Jquery/JqueryUI/JqueryUI.js"></script>
<script type='text/javascript'
    src='/wire/modules/Inputfield/InputfieldDatetime/InputfieldDatetime.min.js?v=106-1529786878'></script>
<script src="/wire/modules/Inputfield/InputfieldDatetime/timepicker/jquery-ui-timepicker-addon.min.js"></script>
{% if language == 'francais' %}
<script src="/wire/modules/Inputfield/InputfieldDatetime/timepicker/i18n/jquery-ui-timepicker-fr.js"></script>
<script src="/wire/modules/Jquery/JqueryUI/i18n/jquery.ui.datepicker-fr.js"></script>
{% endif %}

{#Autocomplete script from https://www.w3schools.com/howto/howto_js_autocomplete.asp#}
{#Can be placed, of course, into a file#}

<script>
$(function () {
    var availableTags = [{{ optionsLanguages | raw }}];
    autocomplete(document.getElementById("prlanguages"), availableTags);
});

function autocomplete(inp, arr) {
    /*the autocomplete function takes two arguments,
    the text field element and an array of possible autocompleted values:*/
    var currentFocus;
    /*execute a function when someone writes in the text field:*/
    inp.addEventListener("input", function (e) {
        var a, b, i, val = this.value;
        /*close any already open lists of autocompleted values*/
        closeAllLists();
        if (!val) {
            return false;
        }
        currentFocus = -1;
        /*create a DIV element that will contain the items (values):*/
        a = document.createElement("DIV");
        a.setAttribute("id", this.id + "autocomplete-list");
        a.setAttribute("class", "autocomplete-items");
        /*append the DIV element as a child of the autocomplete container:*/
        this.parentNode.appendChild(a);
        /*for each item in the array...*/
        for (i = 0; i < arr.length; i++) {
            /*check if the item starts with the same letters as the text field value:*/
            if (arr[i].substr(0, val.length).toUpperCase() == val.toUpperCase()) {
                /*create a DIV element for each matching element:*/
                b = document.createElement("DIV");
                /*make the matching letters bold:*/
                b.innerHTML = "<strong>" + arr[i].substr(0, val.length) + "</strong>";
                b.innerHTML += arr[i].substr(val.length);
                /*insert a input field that will hold the current array item's value:*/
                b.innerHTML += "<input type='hidden' value='" + arr[i] + "'>";
                /*execute a function when someone clicks on the item value (DIV element):*/
                b.addEventListener("click", function (e) {
                    /*insert the value for the autocomplete text field:*/
                    inp.value = this.getElementsByTagName("input")[0].value;
                    /*close the list of autocompleted values,
                    (or any other open lists of autocompleted values:*/
                    closeAllLists();
                });
                a.appendChild(b);
            }
        }
    });
    /*execute a function presses a key on the keyboard:*/
    inp.addEventListener("keydown", function (e) {
        var x = document.getElementById(this.id + "autocomplete-list");
        if (x) x = x.getElementsByTagName("div");
        if (e.keyCode == 40) {
            /*If the arrow DOWN key is pressed,
            increase the currentFocus variable:*/
            currentFocus++;
            /*and and make the current item more visible:*/
            addActive(x);
        } else if (e.keyCode == 38) { //up
            /*If the arrow UP key is pressed,
            decrease the currentFocus variable:*/
            currentFocus--;
            /*and and make the current item more visible:*/
            addActive(x);
        } else if (e.keyCode == 13) {
            /*If the ENTER key is pressed, prevent the form from being submitted,*/
            e.preventDefault();
            if (currentFocus > -1) {
                /*and simulate a click on the "active" item:*/
                if (x) x[currentFocus].click();
            }
        }
    });

    function addActive(x) {
        /*a function to classify an item as "active":*/
        if (!x) return false;
        /*start by removing the "active" class on all items:*/
        removeActive(x);
        if (currentFocus >= x.length) currentFocus = 0;
        if (currentFocus < 0) currentFocus = (x.length - 1);
        /*add class "autocomplete-active":*/
        x[currentFocus].classList.add("autocomplete-active");
    }

    function removeActive(x) {
        /*a function to remove the "active" class from all autocomplete items:*/
        for (var i = 0; i < x.length; i++) {
            x[i].classList.remove("autocomplete-active");
        }
    }

    function closeAllLists(elmnt) {
        /*close all autocomplete lists in the document,
        except the one passed as an argument:*/
        var x = document.getElementsByClassName("autocomplete-items");
        for (var i = 0; i < x.length; i++) {
            if (elmnt != x[i] && elmnt != inp) {
                x[i].parentNode.removeChild(x[i]);
            }
        }
    }

    /*execute a function when someone clicks in the document:*/
    document.addEventListener("click", function (e) {
        closeAllLists(e.target);
    });
}
</script>

Transmission de fichiers

Philipp Urlich, un programmeur connu dans le monde de ProcessWire, a déjà publié un exemple de transmission de fichiers. Le champ InputFileField fonctionne de la même manière que les autres. Ce sera le traitement du fichier qui différera et qui devra être conçu avec soin. Tout dépendant du type de formulaire exigeant une telle transmission, la programmation différera. L’exemple de Philipp crée une page ProcessWire. On peut se référer à un tutoriel de W3Schools pour un autre exemple de traitement.

Rappelons ici que le formulaire doit être traité comme une faille de sécurité dans un site web. Cela s’applique d’autant plus quand il s’agit de fichiers !

Code source