Sunday, March 20, 2016

Client and server AngularJS validation

Сегодня мы с вами поговорим о том, как сделать валидацию для наших приложений, используя AngularJS. Об использовании валидации на стороне клиента вы можете посмотреть здесь в документации. Мы с вами рассмотрим, как можно использовать одновременно клиентскую и серверную валидацию. Начнем, пожалуй, с первой части и посмотрим, как реализовать валидацию на стороне клиента.
AngularJS Client Side Validation
Для этого у меня было создано приложение, в котором я реализовал добавление студентов и отображение их. Для того чтобы посмотреть, как это работает, взглянем на модель данных, которую мы будем проверять.
public class Student
{
    [Key]
    public int Id { get; set; }

    [RegularExpression(@"^[A-Z]+[a-zA-Z''-'\s]*$")]
    [StringLength(50, MinimumLength = 1)]
    [Display(Name = "Last Name")]
    public string LastName { get; set; }

    [Display(Name = "First Name")]
    [StringLength(50, MinimumLength = 2, ErrorMessage = "First name must be between 2 and 50 characters.")]
    public string FirstName { get; set; }

    public string FullName
    {
        get
        {
            return FirstName + " " + LastName;
        }
    }
}
Это все взаимодействует с нашим контроллером через WebAPI контроллер.
public class StudentsController : ApiController
{
    private StudentContext db = new StudentContext();

    // GET: api/Students
    public IQueryable<Student> GetStudents()
    {
        return db.Students;
    }

    // GET: api/Students/5
    [ResponseType(typeof(Student))]
    public IHttpActionResult GetStudent(int id)
    {
        Student student = db.Students.Find(id);
        if (student == null)
        {
            return NotFound();
        }

        return Ok(student);
    }

    // PUT: api/Students/5
    [ResponseType(typeof(void))]
    public IHttpActionResult PutStudent(int id, Student student)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        if (id != student.Id)
        {
            return BadRequest();
        }

        db.Entry(student).State = EntityState.Modified;

        try
        {
            db.SaveChanges();
        }
        catch (DbUpdateConcurrencyException)
        {
            if (!StudentExists(id))
            {
                return NotFound();
            }
            else
            {
                throw;
            }
        }

        return StatusCode(HttpStatusCode.NoContent);
    }

    // POST: api/Students
    [ResponseType(typeof(Student))]
    public IHttpActionResult PostStudent(Student student)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        db.Students.Add(student);
        db.SaveChanges();

        return CreatedAtRoute("DefaultApi", new { id = student.Id }, student);
    }

    // DELETE: api/Students/5
    [ResponseType(typeof(Student))]
    public IHttpActionResult DeleteStudent(int id)
    {
        Student student = db.Students.Find(id);
        if (student == null)
        {
            return NotFound();
        }

        db.Students.Remove(student);
        db.SaveChanges();

        return Ok(student);
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            db.Dispose();
        }
        base.Dispose(disposing);
    }

    private bool StudentExists(int id)
    {
        return db.Students.Count(e => e.Id == id) > 0;
    }
}
Загружаем мы студентов для редактирования с помощью контроллера editStudentCtr.
(function () {
    'use strict';

    angular
        .module('app')
        .controller('editStudentCtr', editStudentCtr);

    editStudentCtr.$inject = ['$scope', '$http', '$routeParams', '$location'];

    function editStudentCtr($scope, $http, $routeParams, $location) {
        console.log("editStudentCtr");
        var id = $routeParams.studentId;

        var url = 'api/Students/' + id;
        $http.get(url)
            .success(function (data) {
                $scope.student = data;
            })
            .error(function (error) {
                console.log(error.message);
            });

        $scope.save = function () {
            $http.put('api/Students/' + id, $scope.student).success(function () {
                $location.path("");
            }).error(function (error) {
                console.log(error.Message);
            });
        };

        $scope.cancel = function () {
            $location.path("");
        };
    }
})();
Теперь рассмотрим, как это работало раньше до валидации.
<div id="createNewStudent">
    <div class="row">
        <form class="form-horizontal" role="form" name="studentForm">
            <input type="hidden" id="studentId" ng-model="student.Id">
            <div class="form-group">
                <label class="control-label col-md-2" for="firstName">First Name:</label>
                <div class="col-md-10">
                    <input id="firstName" class="form-control" ng-model="student.FirstName" />
                </div>
            </div>

            <div class="form-group">
                <label class="control-label col-md-2" for="lastName">Last Name:</label>
                <div class="col-md-10">
                    <input id="lastName" class="form-control" ng-model="student.LastName" />
                </div>
            </div>

            <div class="form-group">
                <div class="col-md-offset-2 col-md-10">
                    <button class="btn btn-primary" id="saveStudent" ng-click="save()">Save</button>
                    <button class="btn btn-primary" id="cancelStudent" ng-click="cancel()">Cancel</button>
                </div>
            </div>
        </form>
    </div>
</div>
Если запустить это пример, то можно увидеть, что когда мы не вводим никаких данных, то есть поля для ввода у нас пустые, кнопка сохранения все еще продолжает работать.
Это очень плохо. У нас сработает только проверка на сервере, если она там присутствует. А что же делать, если ее забыли там прописать? По сути, мы с вами разрешаем сами сохранять невалидную модель. Поскольку это нам ни к чему, пора приступить к добавлению валидации на клиентскую сторону. Для того чтобы это все заработало, нам нужно установить свойство name для наших input контролов, для того чтобы с ними нормально мог работать AngularJS, и добавить директиву required, и самое главное – нужно установить для нашей формы директиву novalidate. Novalidate директива необходима, чтобы не разрешать делать дефолтную валидацию, которая есть в браузере (будет красная рамка вокруг контролов, которую проставляет вам конкретный браузер). 
<div id="createNewStudent">
    <div class="row">
        <form class="form-horizontal" role="form" name="studentForm" novalidate>
            <input type="hidden" id="studentId" ng-model="student.Id">
            <div class="form-group">
                <label class="control-label col-md-2" for="firstName">First Name:</label>
                <div class="col-md-10">
                    <input id="firstName" name="firstName" class="form-control" ng-model="student.FirstName" required/>
                </div>
            </div>

            <div class="form-group">
                <label class="control-label col-md-2" for="lastName">Last Name:</label>
                <div class="col-md-10">
                    <input id="lastName" name="lastName" class="form-control" ng-model="student.LastName" required/>
                </div>
            </div>

            <div class="form-group">
                <div class="col-md-offset-2 col-md-10">
                    <button class="btn btn-primary" id="saveStudent" ng-click="save()">Save</button>
                    <button class="btn btn-primary" id="cancelStudent" ng-click="cancel()">Cancel</button>
                </div>
            </div>
        </form>
    </div>
</div>
Ниже подчеркнуты изменения, которые произошли в нашем примере.
К сожалению, после того как мы внесем эти изменения, кнопка Save будет все равно доступна для сохранения. Для того чтобы она перестала быть доступна, нам нужно указать для нее через директиву ng-disable невозможность ее нажать, если на форме присутствуют невалидные данные.
<button class="btn btn-primary" id="saveStudent" ng-click="save()" ng-disabled="studentForm.$invalid">Save</button>
Теперь, как минимум, мы будем видеть, есть ли у нас ошибки в модели. Мы не можем ничего сохранить в базу.
Но этого недостаточно. Нет подсветки полей, в которых нужны данные, нет предупреждений о том, что данные не введены. Поэтому начнем, пожалуй, с добавления текста о том, что наши поля обязательные для заполнения. Для этого под нашим полем ввода для FirstName добавим следующий блок span:
<span class="help-block" ng-show="studentForm.firstName.$dirty && studentForm.firstName.$error.required">
    The FirstName field is required.
</span>
Здесь мы с помощью директивы ng-show, если у нас срабатывает условие studentForm.firstName.$dirty && studentForm.firstName.$error.required показываем текст The FirstName field is required. Ниже приведен список того, как это все работает.
  • $pristine: It will be TRUE, if the user has not interacted with the form yet
  • $dirty: It will be TRUE, if the user has already interacted with the form.
  • $valid: It will be TRUE, if all containing form and controls are valid
  • $invalid: It will be TRUE, if at least one containing form and control is invalid.
  • $error: Is an object hash, containing references to all invalid controls or forms, where:
    • keys are validation tokens (error names)
    • values are arrays of controls or forms that are invalid with given error.
Если кратко: $pristine – если мы не трогали форму или контрол, $dirty – если взаимодействовали с контролом, $valid – если контрол или форма валидны, $invalid – соответственно, если форма или контрол невалидные, и $error – если содержатся какие-то ошибки. Ниже приведены встроенные токены проверки, которые могут помочь в проверке формы.
  • email
  • max
  • maxlength
  • min
  • minlength
  • number
  • pattern
  • required
  • url
AngularJS также предоставляет соответственные директивы для работы с CSS стилями.
  • ng-pristine
  • ng-dirty
  • ng-valid
  • ng-invalid
Для примера ниже приведен также список атрибутов, которые можно применить на input поле.
  • ng-required
  • ng-minlength
  • ng-maxlength
  • ng-min
  • ng-max
  • ng-pattern
Часть из них мы сегодня и будем использовать.
Думаю, после расписания такого количества теории понятно, как оно работает. Осталось добавить такой же блок span для поля LastName.
<span class="help-block" ng-show="studentForm.lastName.$dirty && studentForm.lastName.$error.required">
    The LastName field is required.
</span>
Полный измененный код приведен ниже:
<div class="form-group">
    <label class="control-label col-md-2" for="firstName">First Name:</label>
    <div class="col-md-10">
        <input id="firstName" name="firstName" class="form-control" ng-model="student.FirstName" required/>
        <span class="help-block" ng-show="studentForm.firstName.$dirty && studentForm.firstName.$error.required">
            The FirstName field is required.
        </span>
    </div>
</div>

<div class="form-group">
    <label class="control-label col-md-2" for="lastName">Last Name:</label>
    <div class="col-md-10">
        <input id="lastName" name="lastName" class="form-control" ng-model="student.LastName" required/>
        <span class="help-block" ng-show="studentForm.lastName.$dirty && studentForm.lastName.$error.required">
            The LastName field is required.
        </span>
    </div>
</div>
Если запустить проект, то можно увидеть, что нужные данные у нас показываются, если мы ничего не вводим и не трогаем поля.
Как-то все равно оно выглядит не очень. Для меня привычно, что рамочка вокруг поля с ошибкой должна быть подсвечена. Это можно сделать, воспользовавшись комбинацией bootstrap + angularjs validation. Для этого с помощью ng-class будем проставлять для атрибута has-error. Ниже приведена реализация всего этого.
<div class="col-md-10" ng-class="{'has-error': studentForm.firstName.$dirty && studentForm.firstName.$invalid}">
    <input id="firstName" name="firstName" class="form-control" ng-model="student.FirstName" required/>
    <span class="help-block" ng-show="studentForm.firstName.$dirty && studentForm.firstName.$error.required">
        The FirstName field is required.
    </span>
</div>
Аналогичное действие проводим для следующего div блока, в котором прописано поле input для изменения LastName. Посмотрим, как будет выглядеть измененная форма для добавления и редактирования студентов.
Как видите, это уже больше похоже на правду и подобно тому, что вы видели при валидации, например, на ASP.NET MVC. Предлагаю закончить до конца наш пример и добавить проверку на максимальное и минимальное количество символов для наших полей, чтобы валидация соответствовала действительности. Для этого нам нужно будет воспользоваться директивами ng-minlength и ng-max-length. Ниже приведен обновленный пример с добавлением этих директив и отображение нужного текста, если данные по количеству символов невалидные.
<div class="form-group">
    <label class="control-label col-md-2" for="firstName">First Name:</label>
    <div class="col-md-10" ng-class="{'has-error': studentForm.firstName.$dirty && studentForm.firstName.$invalid}">
        <input id="firstName" name="firstName" class="form-control" ng-model="student.FirstName" required ng-minlength="1" ng-maxlength="50"/>
        <span class="help-block" ng-show="studentForm.firstName.$dirty && studentForm.firstName.$error.required">
            The FirstName field is required.
        </span>
        <span class="help-block" ng-show="studentForm.firstName.$error.minlength">
            The LastName field must be more than 1 symbols
        </span>
        <span class="help-block" ng-show="studentForm.firstName.$error.maxlength">
            The LastName field must be less than 50 symbols
        </span>
    </div>
</div>

<div class="form-group">
    <label class="control-label col-md-2" for="lastName">Last Name:</label>
    <div class="col-md-10" ng-class="{'has-error': studentForm.lastName.$dirty && studentForm.lastName.$invalid}">
        <input id="lastName" name="lastName" class="form-control" ng-model="student.LastName" required ng-minlength="2" ng-maxlength="50" />
        <span class="help-block" ng-show="studentForm.lastName.$dirty && studentForm.lastName.$error.required">
            The LastName field is required.
        </span>
        <span class="help-block" ng-show="studentForm.lastName.$error.minlength">
            The LastName field must be more than 2 symbols
        </span>
        <span class="help-block" ng-show="studentForm.lastName.$error.maxlength">
            The LastName field must be less than 50 symbols
        </span>
    </div>
</div>
Как видите, кода стало намного больше, так как добавились две дополнительные проверки в каждый div блок. Что из этого получилось, можно посмотреть ниже.
Как видите, у нас появилась проверка уже и на длину текста с помощью AngularJS валидации. Вся проверка у нас была только на стороне клиента. Но как вы понимаете, на сервере могут добавлять разные уровни проверки, или мы что-то забыли проверить, так что настало самое время реализовать еще серверную проверку. Тем более что у нас полностью не покрыта реализация LastName.
[RegularExpression(@"^[A-Z]+[a-zA-Z''-'\s]*$")]
[StringLength(50, MinimumLength = 1)]
[Display(Name = "Last Name")]
public string LastName { get; set; }
Мы ее можем сделать на клиенте через ng-pattern, который позволяет реализовать регулярки, но я предпочту это сделать на стороне сервера. Тем более, думаю, это может вам когда-то пригодиться.
AngularJS with ASP.NET Server side validation
Давайте посмотрим, как происходит добавление нового студента с помощью AngularJS.
$scope.save = function () {
    $http.post('api/Students', $scope.student).success(function () {
        $location.path("");
    }).error(function (error) {
        console.log(error.Message);
    });
};
Результат мы просто залогировали. И если у нас сервер вернет ошибку, нам нужно подсветить нужный участок кода, если у нас ошибка с нашей моделью данных. Посмотрите, например, на серверный метод post, чтобы понять, как это работает.
// POST: api/Students
[ResponseType(typeof(Student))]
public IHttpActionResult PostStudent(Student student)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    db.Students.Add(student);
    db.SaveChanges();

    return CreatedAtRoute("DefaultApi", new { id = student.Id }, student);
}
То есть, если модель невалидная, мы просто проглатываем ошибку. Сейчас мы рассмотрим, что нужно делать и как с этим бороться.
На помощь нам приходят директивы с AngularJS. Для этого добавляю в свою структуру новую папку directives и создаю файл server-validate.js. Ниже приведена реализация этого файла.
"use strict";

angular.module('app').directive('serverError', function () {
    return {
        restrict: 'A',
        require: '?ngModel',
        link: function (scope, element, attrs, ctrl) {
            return element.on('change keyup', function () {
                return scope.$apply(function () {
                    return ctrl.$setValidity('server', true);
                });
            });
        }
    };
});
Здесь мы добавили, что для каждого элемента мы меняем состояние ‘state’ и уведомляем об этом форму.  Теперь мы в наши поля для редактирования добавим нашу директиву. На рисунке ниже показано, как нужно это сделать.
Теперь я добавлю еще один класс, который будет перегонять то, что мне возвращает сервер, в ту нотацию, которую понимает клиент. Для этого я создам новую папку в helpers и добавлю новый файл, который назову error_helpers.js.
"use strict";

var errorHelper = new ErrorHelper();

function ErrorHelper() {
    function lowerizeFirstLetter(string) {
        return string.charAt(0).toLowerCase() + string.slice(1);
    };

    this.validateForms = function(form, scopeErrors, modelState) {
        angular.forEach(modelState, function(error, field) {
            var existField = lowerizeFirstLetter(field.split('.')[1]);
            if (!form.hasOwnProperty(existField)) {
                console.log(existField + ' not found for validation');
            } else {
                form[existField].$setValidity('server', false);
                scopeErrors[existField] = error.join(', ');
            }
        });
    };
}
С помощью класса ErrorHelper я добавил метод validateForms, который принимает форму, массив с ошибками и modelState. Затем просто бегает по ошибкам и для тех полей, которым с сервера вернулось значение об ошибке, проставляем поле ‘server’ в false. Если поле не найдено на форме, то я это просто логирую. На самом деле, вам лучше бросать ошибку в этом случае. Я делаю это с помощью toast.
Здесь есть еще более плохая вещь, которую постарайтесь не делать ни в коем случае. Вот она.
var errorHelper = new ErrorHelper();
Я слегка ленив, поэтому создал глобальную переменную, чтобы иметь доступ со всех своих контроллеров. Хотя можно было все это де сделать через injection в AngularJS. Поэтому лучше делать, как правильно.
Затем нужно поменять логику сохранения в контроллере. Для этого изменим метод save(), как показано ниже.
$scope.errors = {}; //clean up previous server errors

$scope.save = function () {
    $http.post('api/Students', $scope.student).success(function () {
        $location.path("");
    }).error(function (error) {
        $scope.errors = {};
        errorHelper.validateForms($scope.studentForm, $scope.errors, error.modelState);
        console.log(error.Message);
    });
};
Мы добавили объект errors, в котором будем хранить список ошибок, которые нам вернет наш сервер. Если я запущу пример и введу в поле LastName текст “dddd”, который не подходит для регулярного выражения, которое проверяется на сервере, я получу соответствующую ошибку:
Но к сожалению, на форме ничего не изменится, и о том, что у меня случилась ошибка я не узнаю.
Итак, поле подсветилось, а что за ошибка, я так и не узнал. Для того чтобы показать соответствующий текст, нам нужно добавить еще один span. Ниже приведен пример его реализации для поля LastName.
<span class="help-block" ng-show="studentForm.lastName.$error.server">{{errors.lastName}}</span>
Полный код приведен ниже.
<div class="form-group">
    <label class="control-label col-md-2" for="lastName">Last Name:</label>
    <div class="col-md-10" ng-class="{'has-error': studentForm.lastName.$dirty && studentForm.lastName.$invalid}">
        <input id="lastName" name="lastName" class="form-control" ng-model="student.LastName" required server-error ng-minlength="2" ng-maxlength="50" />
        <span class="help-block" ng-show="studentForm.lastName.$dirty && studentForm.lastName.$error.required">
            The LastName field is required.
        </span>
        <span class="help-block" ng-show="studentForm.lastName.$error.minlength">
            The LastName field must be more than 2 symbols
        </span>
        <span class="help-block" ng-show="studentForm.lastName.$error.maxlength">
            The LastName field must be less than 50 symbols
        </span>
        <span class="help-block" ng-show="studentForm.lastName.$error.server">{{errors.lastName}}</span>
    </div>
</div>
Запускаем опять наш пример и проверяем, что серверная валидация работает.
Итоги
Сегодня мы рассмотрели, как реализовать валидацию с помощью AngularJS как на стороне сервера, так и на стороне клиента. Ниже вы найдете исходники, в которых сами все сможете запустить и посмотреть. Ничего сверхсложного в этом всем нет. Нужно только знать, как работают AngularJS директивы и то, как нужно делать валидацию. Надеюсь, что эта статья поможет вам сэкономить немного времени, если вам предстанет задача реализовать свою валидацию в AngularJS.

Исходники: AngularJS validation sample