Validation d'un formulaire Symfony au sein d'un formulaire DevExtreme

Introduction

Bilendi Technology a migré une grande partie de son infrastructure et de ses outils de Zend Framework 1 vers Symfony, comme son programme phare et historique Maximiles.

Afin de suivre l'activité et de gérer nos membres et nos contacts pour mieux comprendre les besoins des clients, nous utilisons de plus en plus les composants de la suite d'outils DevExtreme. Ils permettent, avec un minimum de Javascript, de manipuler les données avec de nombreuses possibilités visuelles.

A travers un exemple concret, on va montrer comment utiliser un formulaire Symfony dans un composant DevExtreme dxForm et valider les données côté serveur.

Supposons que nous voulions enregistrer un contact, contenant un email, une date de naissance, un pays et un optin (par exemple pour décider d'activer l'envoi d'une newsletter).

Création du formulaire et du controleur

On commence par construire le form type. Disons que le mail et la date de naissance sont requis pour l'exemple. Pour simplifier la validation en ajax, on va désactiver ici la CSRF protection. Après ajout des contraintes de validation, voici ce que donne notre formulaire :

<?php

namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;

class RegisterType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('email', EmailType::class, [
                'label' => 'Email',
                'constraints' => [
                    new NotBlank(),
                    new Email(),
                ]
            ])
            ->add('dateOfBirth', DateType::class, [
                'label' => 'Date of birth',
                'widget' => 'single_text',
                'constraints' => [
                    new NotBlank(),
                ]
            ])
            ->add('country', CountryType::class, [
                'label' => 'Country',
                'required' => false,
            ])
            ->add('optinNewsletter', CheckboxType::class, [
                'label' => 'Optin newsletter',
                'value' => 'Yes',
                'required' => false,
                'false_values' => ['no'],
                'constraints' => [
                    new NotNull()
                ]
            ]);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'csrf_protection' => false
        ]);
    }
}

On peut maintenant écrire l'action register. Lorsque le formulaire précédent sera posté, on soumet les données. On retourne une JsonResponse avec un message et un statut différent en fonction de la validité des données :

<?php

namespace App\Controller;

use App\Form\Type\RegisterType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class IndexController extends AbstractController
{
    /**
     * @Route("/register", name="app_register", options={"expose"=true})
     */
    public function register(Request $request): Response
    {
        $form = $this->createForm(RegisterType::class);
        $form->handleRequest($request);

        if ($request->isMethod('POST')) {
            $form->submit($request->request->all());

            if ($form->isSubmitted() && $form->isValid()) {
                return new JsonResponse(['message' => 'The form has been validated successfully']);
            }
            return new JsonResponse(['message' => 'The form contains errors'], Response::HTTP_BAD_REQUEST);
        }

        return $this->render('index/register.html.twig', [
            'form' => $form->createView(),
            'formData' => $form->getData()
        ]);
    }
}

Créer une extension Twig

On va avoir besoin de récupérer certaines propriétés du formulaire Symfony, pour les utiliser dans notre dxForm. Pour cela, on va créer une extension Twig afin de présenter les données au format attendu par DevExtreme pour construire chaque champs. Voici un exemple de ce que l'on peut faire pour récupérer les données nécessaires :

<?php

namespace App\Twig;

use Symfony\Component\Form\ChoiceList\View\ChoiceView;
use Symfony\Component\Form\FormView;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class DxFormExtension extends AbstractExtension
{
    public function getFunctions()
    {
        return [
            new TwigFunction('dxform_item', [$this, 'getFormItem'], ['is_safe' => ['html']])
        ];
    }

    public function getFormItem(FormView $view, array $options = []): string
    {
        $dxFormItem = [
            'dataField' => isset($options['dataField']) ? $options['dataField'] : $view->vars['name'],
            'label' => [
                'text' => $view->vars['label']
            ],
            'value' => $view->vars['value'],
            'isRequired' => $options['isRequired'] ?? $view->vars['required'],
        ];

        if (isset($view->vars['choices'])) {
            $dataSource = [];
            foreach ($view->vars['choices'] as $groupLabel => $choice) {
                if ($choice instanceof ChoiceView) {
                    $dataSource[] = ['value' => $choice->value, 'label' => $choice->label, 'data' => $choice->data];
                }
            }

            $editorType = $options['editorType'] ?? 'dxSelectBox';
            $editorOptions = [
                'dataSource' => $dataSource,
                'searchEnabled' => true,
                'valueExpr' => 'value',
                'displayExpr' => 'label',
                'switchedOffText' => 'No',
                'switchedOnText' => 'Yes'
            ];
        }

        $editorType = $editorType ?? ($options['editorType'] ?? '');
        if ('' !== $editorType) {
            $dxFormItem['editorType'] = $editorType;
        }

        $editorOptions = array_merge(($options['editorOptions'] ?? []), ($editorOptions ?? []));
        if (!empty($editorOptions)) {
            $dxFormItem['editorOptions'] = $editorOptions;
        }

        return json_encode($dxFormItem, JSON_NUMERIC_CHECK);
    }
}

Ajouter la structure dxForm de DevExtreme

Du coté javascript, on va : * charger un fichier qui sera responsable de rendre chaque composant * construire le dxForm * écouter le clic sur le bouton "Valider" pour poster le formulaire. On affichera un message de succès ou d'erreur en utilisant le composant ui/notify de DevExtreme

Voici ce que l'on obtient :

{# fichier index/register.html.twig #}

{% extends 'base_bo.html.twig' %}

{% block body %}
    <h1>Register a contact</h1>
    <form id="form-container" method="post">
        <div class="row">
            <div id="form" class="flex-col-reverse col-xs-12"></div>
        </div>
        <div class="form-group fix-action-form text-center">
            <div class="btn-group">
                <button type="input" class="btn btn-primary" name="save">Save</button>
            </div>
        </div>
    </form>
{% endblock %}

{% block javascript_footer %}
    {{ parent() }}

    {% include 'index/dxform-items.html.twig' %}

    <script type="text/javascript">
        let form = $('#form').dxForm({
            labelLocation: 'top',
            showColonAfterLabel: false,
            showValidationSummary: true,
            items: [
                getDxformEmail(),
                getDxformDateOfBirth(),
                getDxformCountry(),
                getDxformOptinNewsletter()
            ],
        }).dxForm('instance')

        $('button[name="save"]').on('click', function(e) {
            e.preventDefault()
            let valid = form.validate()
            if (!valid.isValid) {
                return
            }

            const d = $.Deferred()
            $.ajax({
                url: Routing.generate('app_register'),
                type: 'POST',
                data: form.option('formData'),
                dataType: 'json',
            }).fail(function(e) {
                DevExpress.ui.notify(e.responseJSON.message, 'error', 1000)
                return d.reject()
            }).then(function(e) {
                DevExpress.ui.notify(e.message, 'success', 1000)
                return d.resolve()
            })
            return d.promise()
        })
    </script>
{% endblock %}

Ajouter des fonctions Javascript pour chaque champ en utilisant l'extension Twig

Le rendu des composants va se faire en déclarant une fonction JS à chaque fois, qui utilisera la fonction twig dxform_item définie plus haut. Le mail sera validé en asynchrone, par un appel serveur pour valider sa syntaxe lorsque le champs perdra le focus.

{# fichier index/dxform-items.html.twig #}

{% block dxform_items %}
<script type="text/javascript">
    let getDxformEmail = function() {
        let email = {{ dxform_item(form.email) }}
        email.validationRules = [
            {
                type: 'required',
                message: 'Email is required'
            },
            {
                type: 'async',
                validationCallback: function(e) {
                    const d = $.Deferred()
                    $.ajax({
                        url: Routing.generate('app_validate_email'),
                        type: 'POST',
                        data: {email: e.value},
                        dataType: 'json',
                    }).fail(function(e) {
                        return d.reject('The email is not valid')
                    }).then(function(e) {
                        return d.resolve()
                    })
                    return d.promise()
                }
            }
        ]
        return email
    }

    let getDxformDateOfBirth = function() {
        let dateOfBirth = {{ dxform_item(form.dateOfBirth, {
            editorType: 'dxDateBox',
            editorOptions: {
                inputAttr: {
                    autocomplete: 'none'
                },
                dateSerializationFormat: 'yyyy-MM-dd',
                invalidDateMessage: 'Date of birth is not valid'
            }
        }) }}
        dateOfBirth.editorOptions.max = new Date()
        dateOfBirth.label.type = 'date'
        dateOfBirth.label.format = 'dd/MM/yyyy'
        dateOfBirth.validationRules = [{
            type: 'required',
            message: 'Date of birth is required'
        }]
        return dateOfBirth
    }

    let getDxformCountry = function() {
        return {{ dxform_item(form.country, {
            editorOptions: {
                inputAttr: {
                    autocomplete: 'none'
                },
                searchEnabled: true,
                noDataText: 'This country does not exist',
            }
        }) }}
    }

    let getDxformOptinNewsletter = function() {
        return {{ dxform_item(form.optinNewsletter, {
            editorType: 'dxSwitch',
            editorOptions: {
                value: true,
                switchedOffText: 'No',
                switchedOnText: 'Yes'
            }
        }) }}
    }
</script>
{% endblock %}

Ajouter une méthode pour valider côté serveur l'email

Il va nous rester à écrire une fonction pour valider le mail à la volée. On peut injecter le service de validation de Symfony et retourner une 400 si le mail n'est pas bon :

/**
 * @Route("/validate-email", name="app_validate_email", condition="request.isXmlHttpRequest()", methods={"POST"}, options={"expose"=true})
 */
public function validateEmail(Request $request, ValidatorInterface $validator): JsonResponse
{
    $errors = $validator->validate($request->request->get('email'), [
        new Email(['mode' => Email::VALIDATION_MODE_STRICT])
    ]);

    if (0 === count($errors)) {
        return new JsonResponse(null, Response::HTTP_NO_CONTENT);
    }
    return new JsonResponse(['message' => 'This email is not valid'], Response::HTTP_BAD_REQUEST);
}

Tests de validation

En cas d'erreur

La liste des erreurs remonte dans un conteneur HTML, ainsi qu'au niveau de chaque champs si on clique dessus :

Error-form, mar. 2021

En cas de succès

Lorsque le mail est valide, un indicateur vert apparait à droite au niveau du champs. Un message de succès s'affiche lorsque la validation intégrale du formulaire a réussi :

Success-form, mar. 2021