Bilendi Technology - Mot-clé - javascriptDES TECHNOLOGIES INNOVANTES DÉVELOPPÉES EN INTERNE ET CONÇUES POUR RÉPONDRE AUX BESOINS DE NOS CLIENTS ET PARTENAIRES2022-11-23T08:40:51+01:00Bilendi Technologyurn:md5:449d083a7a76e29cf1c2691e99df25f8DotclearValidation d'un formulaire Symfony au sein d'un formulaire DevExtremeurn:md5:512054df390e7cbd3db9e42bba62aa4c2021-03-23T09:14:00+01:002021-03-23T09:23:35+01:00SébastienDéveloppementdevexpressformulairejavascriptsymfonytwig<h2>Introduction</h2>
<p>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 <a href="https://www.maximiles.com">Maximiles</a>.</p>
<p>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 <a href="https://js.devexpress.com/">DevExtreme</a>. Ils permettent, avec un minimum de Javascript, de manipuler les données avec de nombreuses possibilités visuelles.</p>
<p>A travers un exemple concret, on va montrer comment utiliser un <a href="https://symfony.com/doc/current/forms.html">formulaire Symfony</a> dans un <a href="https://js.devexpress.com/Demos/WidgetsGallery/Demo/Form/Validation/jQuery/Light">composant DevExtreme dxForm</a> et valider les données côté serveur.</p>
<p>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).</p>
<h2>Création du formulaire et du controleur</h2>
<p>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 <a href="https://symfony.com/doc/current/security/csrf.html">CSRF protection</a>. Après ajout des contraintes de validation, voici ce que donne notre formulaire :</p>
<pre><code class="php"><?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
]);
}
}
</code></pre>
<p>On peut maintenant écrire l'action <code>register</code>. Lorsque le formulaire précédent sera posté, on soumet les données. On retourne une <code>JsonResponse</code> avec un message et un statut différent en fonction de la validité des données :</p>
<pre><code class="php"><?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()
]);
}
}
</code></pre>
<h2>Créer une extension Twig</h2>
<p>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 <a href="https://symfony.com/doc/current/templating/twig_extension.html">extension Twig</a> 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 :</p>
<pre><code class="php"><?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);
}
}
</code></pre>
<h2>Ajouter la structure dxForm de DevExtreme</h2>
<p>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 <a href="https://js.devexpress.com/Demos/WidgetsGallery/Demo/Button/PredefinedTypes/jQuery/Light/">ui/notify de DevExtreme</a></p>
<p>Voici ce que l'on obtient :</p>
<pre><code class="twig">{# 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 %}
</code></pre>
<h2>Ajouter des fonctions Javascript pour chaque champ en utilisant l'extension Twig</h2>
<p>Le rendu des composants va se faire en déclarant une fonction JS à chaque fois, qui utilisera la fonction twig <code>dxform_item</code> définie plus haut. Le mail sera validé en asynchrone, par un appel serveur pour valider sa syntaxe lorsque le champs perdra le focus.</p>
<pre><code class="twig">{# 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 %}
</code></pre>
<h2>Ajouter une méthode pour valider côté serveur l'email</h2>
<p>Il va nous rester à écrire une fonction pour valider le mail à la volée. On peut injecter le <a href="https://symfony.com/doc/current/validation.html#using-the-validator-service">service de validation de Symfony</a> et retourner une 400 si le mail n'est pas bon :</p>
<pre><code class="php">/**
* @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);
}
</code></pre>
<h2>Tests de validation</h2>
<h3>En cas d'erreur</h3>
<p>La liste des erreurs remonte dans un conteneur HTML, ainsi qu'au niveau de chaque champs si on clique dessus :</p>
<p><img src="https://bilendi.tech/public/.error-form-validation_m.png" alt="Error-form, mar. 2021" style="margin: 0 1em 1em 0;" height="179" width="448" /></p>
<h3>En cas de succès</h3>
<p>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 :</p>
<p><img src="https://bilendi.tech/public/.sucess-form-validation_m.png" alt="Success-form, mar. 2021" style="margin: 0 1em 1em 0;" height="179" width="448" /></p>