Существует over 10.000 плагинов, библиотек и бандлов охватывающих какую-либо одну сторону проблемы. Комплексного решения мне найти так и не удалось. Оценив трудоёмкость объединения двух наиболее популярных решения (Knockout-Validation и APYJsFormValidationBundle) для первой и второй части задачи я решил написать всё с нуля. Подробности под катом.
Валидация в Symfony2
В моём случае, правила валидации задаются в аннотациях. Для освежения памяти приведу листинг:
/** * 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; // .... }
Первое что нужно сделать это распарсить эти комментарии. Конечно, это уже делает сам фреймворк. Результаты парсинга хранятся в кэше по адресу «app/cache/dev/annotations/» или «app/cache/prod/annotations/», в зависимости от окружения. Немного подумав, я написал небольшой метод:
/** * Читает аннотации классов сущностей. * * @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; }
Вероятно такой код — плохой пример для подражания, однако со своей задачей он справляется. Перепишу его потом.
В результате на клиенте мы можем получить нечто подобное:
Валидация и KnockoutJS
После того как правила валидации известны можно приступать к написанию клиентского кода. Идея реализации была позаимствована у Knockout Validation. Приведу пример задания правил валидации в этом плагине:
var myComplexValue = ko.observable() myComplexValue.extend({ required: true }) .extend({ minLength: 42 }) .extend({ pattern: { message: 'Hey this doesnt match my pattern', params: '^[A-Z0-9].$' }});
То есть, суть в использовании extender’ов появившихся со второй ветки Knockout. Extender’ы позволяет изменять или дополнять поведение любых видов observables. Рассмотрим пример:
var name = ko.observable('habrahabr').extend({MinLength: 42});
При обновлении наблюдаемого свойства name нокаут попытается найти extender c именем MinLength и, в случае успеха, вызовет его. В качестве параметров extender’у будет передано само наблюдаемое свойство и число 42.
Теперь реализуем сам extender:
ko.extenders.MinLength = function(observavle, params) { // .... };
Идея ясна, перейдём к реализации. Возьмём для примера следующую модель:
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('Модель НЕ валидна'); } }; })();
За исключением ko.validation.init и self.isValid тут должно быть всё понятно. ko.validation.init — это функция инициализации валидатора, принимающая в качестве аргументов модель и объект содержащий информацию об аннотациях полученный из Symfony. Метод isValid будет добавляться к модели в момент инициализации валидатора.
<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>
Свойства isError и message это флаг наличия ошибки и сообщение об ошибке соответственно. Оба эти свойства являются наблюдаемыми и добавляются к основному свойству в момент инициализации.
AppViewModel.name.isError = ko.observable(); // Флаг наличия ошибки AppViewModel.name.message = ko.observable(); // Сообщение об ошибке AppViewModel.name.typeError = ''; // Валидатор установивший ошибку
Для целевой аудитории поста это не должно быть проблемой, но на всякий случай поясню: в JavaScript всё является объектами, вернее сказать для, каждого типа существует объектная обёртка. Преобразования происходят автоматически по мере необходимости. Это же справедливо и для функций. По этому, ни что не мешает нам, добавить несколько свойств к свойству AppViewModel.name, являющемуся, по сути, функцией.
Алгоритм валидации формы будет выглядеть следующим образом:
— ни чего не делаем до тех пор, пока пользователь не пытается отправить форму
— после первой, неудачной по причине не валидности, отправки проверяем поля при каждом их обновлении (keyup и change).
Теперь я приведу код целиком, а затем разберу его подробно:
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; }; } } })();
Добавим сразу пару методов валидации:
// 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); });
Общее устройство
Код организован в соответствии с паттерном проектирования, названным Стефаном Стояновым «Модуль», в его книге «Javascript patterns». Т.е. анонимная немедленно вызываемая функция, возвращает объект с двумя методами: init и addAssert. Внутри замыкания определён метод isValid.
Метод isValid. Валидация модели
Проверяет валидность модели. Метод вызывается в контексте модели, т.е. this внутри метода isValid это AppViewModel. Первым делом он устанавливает наблюдаемое свойство модели validate в true. Это сигнализирует о попытке отправить форму. Само свойство validate добавляется к модели в процессе инициализации методом init.
Далее метод пробегает по всем наблюдаемым свойствам модели и проверяет их флаги ошибки.
Метод init. Инициализация валидации
Сначала метод добавляет к модели выше упомянутое наблюдаемое свойство validate и метод isValid. За тем циклом проходит по полям, для которых указаны ограничения и для которых существуют одноимённые наблюдаемые свойства, в модели добавляя последним: isError и message. Второй, вложенный цикл обходит ограничения и пытается расширить поле соответствующим extender’ом. В качестве параметра extender’у передаётся объект с параметрами ограничения, полученными из кэша Symfony, с добавленными к нему ссылками на модель (AppViewModel) и списком всех ограничений для этого поля.
Метод addAssert. Регистрация нового метода валидации
Метод принимает три параметра: name — имя нового метода валидации, validate — функция валидации, checkAsserts — функция подтверждающая выставление ошибки. Последний параметр рассмотрим немного позже.
Тело метода extender’а заворачивается в вычисляемое (computed) свойство чтобы обеспечить перезапуск валидации при обновлении AppViewModel.validate.
Метод checkAsserts
Это опциональный параметр метода addAssert. Он нужен, чтобы проверять, не выставит ли какой-либо другой валидатор ошибку. Например, при проверке длины строки введённой в поле. В случае если поле пусто я хочу сказать «заполните поле», а если его длинна меньше 3х символов — «имя должно содержать не менее 3х символов» и т.п. Но нет никакой гарантии что проверка «MinLength» произойдёт позднее «NotBlank». Вот пример метода валидации (extender’а) MinLengt:
// 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); } );
Конечно, можно строить объект со списком валидаторв, сортируя их в определённом порядке. Перебор свойств в объекте происходит в порядке их присвоения. Это не прописано в стандартах, но в реальности это достаточно надёжное правило. Однако возникает вопрос в том, как внутри устроен нокаут и будет ли оно устроен так же в следующей версии. Так что, вариант с костылеобразной на первый взгляд функцией кажется мне пока оптимальным.
Использование
Правила валидации применяются ко всем observable свойствам, имеющим одноимённые поля в сущностном классе Symfony с описанными ограничениями. Соответственно, если какое-либо поле не нужно проверять на стороне клиента решение очевидно — дать ему другое имя или удалить свойство из js-объекта, полученного при чтении аннотаций.
Есть небольшая демка на codepen: codepen.io/alexismaster/pen/LAaqc
На последок обещанные улучшения метода readEntityAnnotations. Получить аннотации можно через сервис валидации:
// Получим информацию об ограничениях свойства "name" сущностного класса "User" $validator = $this->get('validator'); $metadata = $validator->getMetadataFactory()->getClassMetadata("Acme\\UsersBundle\\Entity\\User"); var_dump($metadata->properties['name']->constraints);
Ссылки:
github.com/Abhoryo/APYJsFormValidationBundle — Symfony-бандл генерирующий JS код для валидации
github.com/Knockout-Contrib/Knockout-Validation
habrahabr.ru/post/136782/ — Интересный пост о KnockoutJS и Extenders
phalcon-docs-ru.readthedocs.org/ru/latest/reference/annotations.html — Парсер аннотаций
habrahabr.ru/post/133270/ — Кастомные аннотации в Symfony 2
ссылка на оригинал статьи http://habrahabr.ru/post/188720/
Добавить комментарий