📜 ⬆️ ⬇️

Symfony2 and KnockoutJS - Form Validation

A few months ago, I began to learn the popular PHP framework Symfony2. Recently, I faced the task of checking the correctness of filling out a form on the client side using the KnockoutJS library. At the same time, the validation rules, in order not to duplicate the code, are preferably taken from the symfony entity class.
There are over 10,000 plug-ins, libraries and bundles covering any one side of the problem. I could not find a comprehensive solution. Assessing the complexity of combining the two most popular solutions (Knockout-Validation and APYJsFormValidationBundle) for the first and second part of the problem, I decided to write everything from scratch. Details under the cut.

Validation in symfony2

In my case, the validation rules are specified in annotations. For refreshment of memory I will provide the listing:

/** * Acme\UsersBundle\Entity\User */ class User implements JsonSerializable { /** * @var string $name . * * @ORM\Column(name="name", type="string", length=255, unique = true, nullable=false) * * @Assert\NotBlank(message=" ") * @Assert\MinLength(limit=3, message="  ") * @Assert\MaxLength(limit=15, message="  ") * @Assert\Regex(pattern="/^[A-z0-9_-]+$/ui", match=true, message="   ") */ private $name; // .... } 

The first thing to do is parse these comments. Of course, the framework itself already does this. The results of the parsing are stored in the cache at the address "app / cache / dev / annotations /" or "app / cache / prod / annotations /", depending on the environment. A little thought, I wrote a small method:

 /** *    . * * @param string $bundle    "Bundle". * @param string $entity         . * @param string $env  ("dev"  "prod"). * @param string $namespace   (  "Acme"). * @return array . */ private function readEntityAnnotations($bundle, $entity, $env = 'prod', $namespace = 'Acme') { $result = array(); $files = glob($_SERVER['DOCUMENT_ROOT'] . '/../app/cache/' . $env . '/annotations/' . $namespace . '-' . $bundle .'Bundle-Entity-' . $bundle . $entity .'$*.php'); foreach ($files as $path) { //        preg_match('/\\$(.*?)\\./', $path, $matches); //   foreach (include $path as $annotation) { //       if (get_parent_class($annotation) === 'Symfony\\Component\\Validator\\Constraint') { $type = preg_replace('/^.*\\\/', '', get_class($annotation)); $annotation = (array)$annotation; unset($annotation['charset']); $result[$matches[1]][$type] = (array)$annotation; } } } return $result; } 

Probably such a code is a bad example to follow, but it copes with its task. I will rewrite it later.
')
As a result, on the client, we can get something like this:
annotations

Validation and KnockoutJS

After the validation rules are known, you can start writing client code. The idea of ​​implementation was borrowed from Knockout Validation . I will give an example of setting validation rules in this plugin:

 var myComplexValue = ko.observable() myComplexValue.extend({ required: true }) .extend({ minLength: 42 }) .extend({ pattern: { message: 'Hey this doesnt match my pattern', params: '^[A-Z0-9].$' }}); 

That is, the essence of using extenders that appeared from the second Knockout branch. Extenders allow you to change or supplement the behavior of any kind of observables. Consider an example:

var name = ko.observable('habrahabr').extend({MinLength: 42});

When updating the observed name property, the knockout will try to find an extender with the name MinLength and, if successful, will call it. As parameters, the observable property and the number 42 will be passed to the extender.

Now we implement the extender itself:

 ko.extenders.MinLength = function(observavle, params) { // .... }; 

The idea is clear, let's move on to implementation. Take for example the following model:

 var AppViewModel = new (function () { var self = this; //     this.name = ko.observable(''); //   this.mail = ko.observable(''); // E-mail //   ko.validation.init(self, _ANNOTATIONS_); //    this.submit = function () { if (self.isValid()) { alert(' '); } else { alert('  '); } }; })(); 

Except for ko.validation.init and self.isValid everything should be clear here. ko.validation.init is a validator initialization function that takes as arguments a model and an object containing information about annotations obtained from symfony. The isValid method will be added to the model when the validator is initialized.

 <form action="#"> <p> <label for=""></label> <input type="text" data-bind="value: name, valueUpdate: 'keyup'"> <span data-bind="visible: name.isError, text: name.message"></span> </p> <p> <label for="">E-mail</label> <input type="text" data-bind="value: mail, valueUpdate: 'keyup'"> <span data-bind="visible: mail.isError, text: mail.message"></span> </p> <button data-bind="click: submit"></button> </form> 

The isError and message properties are an error flag and an error message, respectively. Both of these properties are observable and are added to the main property at the time of initialization.

 AppViewModel.name.isError = ko.observable(); //    AppViewModel.name.message = ko.observable(); //    AppViewModel.name.typeError = ''; //    

For the target audience of the post, this should not be a problem, but just in case I will explain: everything in JavaScript is an object, or rather, for each type there is an object wrapper. Conversions occur automatically as needed. The same is true for functions. Therefore, nothing prevents us from adding several properties to the AppViewModel.name property, which is, in essence, a function.

The form validation algorithm will look as follows:
- do not do anything until the user tries to submit the form
- after the first, unsuccessful due to not validity, sending we check the fields each time they are updated (keyup and change).

Now I will give the code in its entirety, and then analyze it in detail:

 ko.validation = new (function () { /** *   . * @return {Boolean} */ var isValid = function () { this.validate(true); //      for (var opt in this) if (ko.isObservable(this[opt])) { //     if (this[opt].isError !== undefined && this[opt].isError() === true) { return false; } } return true; }; return { /** *  . * @param {object} AppViewModel  . * @param {object} annotations   . */ init: function (AppViewModel, annotations) { var asserts, options; AppViewModel.validate = ko.observable(false); //        for (var field in annotations) if (annotations.hasOwnProperty(field)) { asserts = annotations[field]; //   (AppViewModel)        if (AppViewModel[field] !== undefined && ko.isObservable(AppViewModel[field])) { AppViewModel[field].isError = ko.observable(); //    AppViewModel[field].message = ko.observable(); //    //      for (var i in asserts) if (asserts.hasOwnProperty(i)) { options = {}; options[i] = asserts[i]; //   options[i]['asserts'] = asserts; //    options[i]['AppViewModel'] = AppViewModel; //    //      AppViewModel[field].extend(options); } } } //      AppViewModel.isValid = isValid; }, /** *    . * @param name  . * @param validate  . * @param checkAsserts */ addAssert: function (name, validate, checkAsserts) { //  extender' ko.extenders[name] = function(target, option) { //     "AppViewModel.validate" ko.computed(function () { //          if (validate(target, option) === false && option.AppViewModel.validate()) { checkAsserts = checkAsserts || new Function('t,o', 'return false'); //     if (checkAsserts(target, option) === false) { target.isError(true); //    target.message(option.message); //    target.typeError = name; //   } return; } //          if (target.isError.peek() === true && target.typeError === name) { target.isError(false); } }); return target; }; } } })(); 

Add a couple of validation methods right away:

 // NotBlank ko.validation.addAssert('NotBlank', function (target, option) { return (target().length > 0); }); // MaxLength ko.validation.addAssert('MaxLength', function (target, option) { return (target().length <= option.limit); }); 


Common device

The code is organized in accordance with the design pattern called “Module” by Stefan Stoyanov in his book “Javascript Patterns”. Those. anonymous immediately called function, returns an object with two methods: init and addAssert. Inside the closure, the isValid method is defined.

The isValid method. Model validation
Checks the validity of the model. The method is called in the context of the model, i.e. this inside the isValid method is AppViewModel. First, it sets the observed property of the validate model to true. This signals an attempt to submit a form. The validate property itself is added to the model during the initialization process using the init method.
Further, the method runs over all observable properties of the model and checks their error flags.

Init method Initialization Validation
First, the method adds to the model the above-mentioned observed property validate and the isValid method. For that cycle it goes through the fields for which restrictions are indicated and for which there are observable properties of the same name, adding last in the model: isError and message. The second nested loop bypasses the restrictions and tries to expand the field with the appropriate extender. As a parameter, the extender is passed an object with the constraint parameters obtained from the symfony cache, with model references added to it (AppViewModel) and a list of all constraints for this field.

The addAssert method. Registration of a new validation method
The method takes three parameters: name is the name of the new validation method, validate is the validation function, checkAsserts is the function confirming error setting. The last parameter will be discussed a little later.
The body of the extender method is wrapped in a computed property to ensure that validation is restarted when updating AppViewModel.validate.

CheckAsserts method
This is an optional parameter to the addAssert method. It is needed to check if any other validator will throw an error. For example, when checking the length of the string entered in the field. If the field is empty, I want to say “fill in the field”, and if it is less than 3 characters long - “the name must contain at least 3 characters”, etc. But there is no guarantee that the MinLength check will happen later than NotBlank. Here is an example of the MinLengt validation (extender) method:

 // MinLength ko.validation.addAssert( 'MinLength', function (target, option) { return (target().length >= option.limit); }, function (target, option) { //       "NotBlank" return (target().length === 0 && option.asserts.NotBlank !== undefined); } ); 

Of course, you can build an object with a list of validators, sorting them in a specific order. The enumeration of properties in an object occurs in the order of their assignment. This is not spelled out in the standards, but in reality this is a fairly reliable rule. However, the question arises as to how the knockout is arranged inside and whether it will be arranged the same way in the next version. So, the option with a crutch-like function at first glance seems to be the best for me.

Using

Validation rules apply to all observable properties that have the same field in the symfony entity class with the described restrictions. Accordingly, if a field does not need to be checked on the client’s side, the solution is obvious - give it a different name or remove the property from the js-object obtained when reading annotations.

There is a small demo on codepen: codepen.io/alexismaster/pen/LAaqc

Lastly, the promised improvements to the readEntityAnnotations method. You can get annotations through the validation service:

 //      "name"   "User" $validator = $this->get('validator'); $metadata = $validator->getMetadataFactory()->getClassMetadata("Acme\\UsersBundle\\Entity\\User"); var_dump($metadata->properties['name']->constraints); 


References:
github.com/Abhoryo/APYJsFormValidationBundle - Symfony-bundle generating JS code for validation
github.com/Knockout-Contrib/Knockout-Validation
habrahabr.ru/post/136782 - An interesting post about KnockoutJS and Extenders
phalcon-docs-ru.readthedocs.org/en/latest/reference/annotations.html - Annotation Parser
habrahabr.ru/post/133270 - Custom annotations in Symfony 2

Source: https://habr.com/ru/post/188720/


All Articles