This is the fifth part in a multiple part blog series on building ASP.NET 5 (ASP.NET vNext) apps with AngularJS. In this series of blog posts, I show how you can create a simple Movie app using ASP.NET 5, MVC 6, and AngularJS.
- ASP.NET 5 and AngularJS Part 1, Grunt, Uglify, and AngularJS
- ASP.NET 5 and AngularJS Part 2, Using the MVC 6 Web API
- ASP.NET 5 and AngularJS Part 3, Adding Client Routing
- ASP.NET 5 and AngularJS Part 4, Using Entity Framework 7
- ASP.NET 5 and AngularJS Part 5, Form Validation
- ASP.NET 5 and AngularJS Part 6, Security
- ASP.NET 5 and AngularJS Part 7, Running on a Mac
You can download the code discussed in this blog post from GitHub:
https://github.com/StephenWalther/MovieAngularJSApp
In this blog post, I explain how to implement both client-side and server-side form validation.
Client-Side Validation
Let’s start with the client-side validation. We’ll use the built-in features of AngularJS to validate the movie add and edit forms.
Here’s the add.html page:
<h1>Add Movie</h1> <div class="row"> <div class="col-md-5"> <form name="formAdd" novalidate ng-submit="add()"> <ng-include src="'/partials/_edit.html'"></ng-include> <a href="/" class="btn btn-default">Cancel</a> <button type="submit" ng-disabled="formAdd.$invalid" class="btn btn-primary">Save</button> </form> </div> </div>
And, here’s the edit.html page:
<h1>Edit Movie</h1> <div class="row"> <div class="col-md-5"> <form name="formEdit" novalidate ng-submit="edit()"> <ng-include src="'/partials/_edit.html'"></ng-include> <a href="/" class="btn btn-default">Cancel</a> <button type="submit" ng-disabled="formAdd.$invalid" class="btn btn-primary">Save</button> </form> </div> </div>
The add and edit forms contain the FORM element and the BUTTON elements for the forms. The FORM element includes a novalidate attribute that disables HTML5 validation. This is necessary because the HTML5 validation attributes conflict with the AngularJS validation directives.
Notice that the BUTTON element includes an ng-disabled directive. This directive is used to prevent a user from submitting the form when there are any client-side validation errors.
You’ll notice that both forms use the ng-include directive to include a partial named _edit.html. The _edit.html contains the actual form fields.
<div class="bg-danger validationErrors" ng-show="validationErrors"> <ul> <li ng-repeat="error in validationErrors">{{error}}</li> </ul> </div> <div class="form-group"> <label for="Title">Movie Title</label> <input name="Title" type="text" class="form-control" placeholder="Enter title" ng-model="movie.Title" ng-required="true" ng-minlength="3" /> </div> <div class="form-group"> <label for="Director">Director</label> <input name="Director" type="text" class="form-control" placeholder="Enter director" ng-model="movie.Director" ng-required="true" /> </div> <div class="form-group"> <label for="TicketPrice">Ticket Price</label> <input name="TicketPrice" type="text" class="form-control" placeholder="Enter ticket price" ng-model="movie.TicketPrice"> </div> <div class="form-group"> <label for="Release">Release Date</label> <p class="input-group" ng-controller="DatePickerController"> <input type="text" class="form-control" datepicker-popup ng-model="movie.ReleaseDate" is-open="opened" /> <span class="input-group-btn"> <button type="button" class="btn btn-default" ng-click="open($event)"> <i class="glyphicon glyphicon-calendar"></i> </button> </span> </p> </div>
The _edit.html form contains fields for the movie Title, Director, Ticket Price and Release Date. AngularJS directives are used to perform the following validation:
-
Title – Required, Must be at least 3 characters.
-
Director – Required.
-
TicketPrice – Required.
If validation fails, then the ng-invalid CSS class is applied to the field and the field appears with a red background.
input.ng-invalid.ng-touched { background-color: #FA787E; } input.ng-valid.ng-touched { background-color: #78FA89; }
I ran into one minor wrinkle when I created the _edit.html partial. When the partial was requested from the server, the web.config file that I created in part 3 of this blog series redirected the request to the index.html page and I got an infinite regression. To flatten this wrinkle, I had to exclude the /partials/ folder from the path rewrite.
<!-- from http://stackoverflow.com/questions/25916851/wrapping-staticfilemiddleware-to-redirect-404-errors --> <configuration> <system.webServer> <modules runAllManagedModulesForAllRequests="true" /> <rewrite> <rules> <!--Redirect selected traffic to index --> <rule name="Index Rule" stopProcessing="true"> <match url=".*" /> <conditions logicalGrouping="MatchAll"> <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" /> <add input="{REQUEST_URI}" matchType="Pattern" pattern="^/api/" negate="true" /> <add input="{REQUEST_URI}" matchType="Pattern" pattern="^/partials/" negate="true" /> </conditions> <action type="Rewrite" url="/index.html" /> </rule> </rules> </rewrite> </system.webServer> </configuration>
Updating the Movie Model
Client-side validation is only one half of the validation story. The other half of validation is handled by the ASP.NET 5 framework.
Here’s what the updated Movie model looks like:
using System; using System.ComponentModel.DataAnnotations; namespace MovieAngularJSApp.Models { public class Movie { public int Id { get; set; } [Required(ErrorMessage="Movie Title is Required")] [MinLength(3, ErrorMessage="Movie Title must be at least 3 characters")] public string Title { get; set; } [Required(ErrorMessage = "Movie Director is Required.")] public string Director { get; set; } [Range(0, 100, ErrorMessage ="Ticket price must be between 0 and 100 dollars.")] public decimal TicketPrice { get; set; } [Required(ErrorMessage="Movie Release Date is required")] public DateTime ReleaseDate { get; set; } } }
Notice that validation attributes are applied to the properties of the Movies model. For example, the [Required] and [MinLength] attributes are applied to the Title property.
Updating the Movies Database
Because I made changes to the Movie model, I need to update the database to match the new model. I opened up a Command Prompt, navigated to the project folder and ran the following two commands:
k ef migration add movieProps k ef migration apply
Unfortunately, the second command threw an exception because I was trying to add a DateTime column to the existing Movies database table. Because the table already contains data, I got the exception.
The easiest fix for this issue is to drop the database. I found the MoviesDatabase.mdf and MoviesDatabase_log.ldf in my c:\users\stephen folder and deleted the two files. When I ran the k ef migration apply command again, everything worked fine.
Updating the Server Controller
The next step is to update the Web API controller on the server. The modified MoviesController looks like this:
using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNet.Mvc; using MovieAngularJSApp.Models; namespace MovieAngularJSApp.API.Controllers { [Route("api/[controller]")] public class MoviesController : Controller { private readonly MoviesAppContext _dbContext; public MoviesController(MoviesAppContext dbContext) { _dbContext = dbContext; } [HttpGet] public IEnumerable<Movie> Get() { return _dbContext.Movies; } [HttpGet("{id:int}")] public IActionResult Get(int id) { var movie = _dbContext.Movies.FirstOrDefault(m => m.Id == id); if (movie == null) { return new HttpNotFoundResult(); } else { return new ObjectResult(movie); } } [HttpPost] public IActionResult Post([FromBody]Movie movie) { if (ModelState.IsValid) { if (movie.Id == 0) { _dbContext.Movies.Add(movie); _dbContext.SaveChanges(); return new ObjectResult(movie); } else { var original = _dbContext.Movies.FirstOrDefault(m => m.Id == movie.Id); original.Title = movie.Title; original.Director = movie.Director; original.TicketPrice = movie.TicketPrice; original.ReleaseDate = movie.ReleaseDate; _dbContext.SaveChanges(); return new ObjectResult(original); } } return new BadRequestObjectResult(ModelState); } [HttpDelete("{id:int}")] public IActionResult Delete(int id) { var movie = _dbContext.Movies.FirstOrDefault(m => m.Id == id); _dbContext.Movies.Remove(movie); _dbContext.SaveChanges(); return new HttpStatusCodeResult(200); } } }
Notice that the Post() method (called by both the Add and Edit forms) checks ModelState. If ModelState is not valid then the following line of code is executed:
return new BadRequestObjectResult(ModelState);
The BadRequestObjectResult action result returns all of the validation error messages in ModelState to the client. The BadRequestObjectResult also returns a 400 Bad Request status code.
Here’s what the response looks like in Chrome Developer Tools:
Be warned that the BadRequestObjectResult is not included in Beta 2 of the ASP.NET 5 framework. You need to switch to Beta 3. I describe how to use the Nightly Builds of ASP.NET 5 in the following blog post:
http://stephenwalther.com/archive/2015/01/15/upgrading-visual-studio-2015-preview-to-asp-net-5mvc-6-rc
Updating the Client Controllers
The very final step is to update the client-side controllers to display any validation errors returned from the server.
The moviesControllers.js file below contains updated MoviesAddController and MoviesEditController functions.
(function () { 'use strict'; angular .module('moviesApp') .controller('MoviesListController', MoviesListController) .controller('MoviesAddController', MoviesAddController) .controller('MoviesEditController', MoviesEditController) .controller('MoviesDeleteController', MoviesDeleteController) .controller('DatePickerController', DatePickerController); /* Movies List Controller */ MoviesListController.$inject = ['$scope', 'Movie']; function MoviesListController($scope, Movie) { $scope.movies = Movie.query(); } /* Movies Create Controller */ MoviesAddController.$inject = ['$scope', '$location', 'Movie']; function MoviesAddController($scope, $location, Movie) { $scope.movie = new Movie(); $scope.add = function () { $scope.movie.$save( // success function () { $location.path('/'); }, // error function (error) { _showValidationErrors($scope, error); } ); }; } /* Movies Edit Controller */ MoviesEditController.$inject = ['$scope', '$routeParams', '$location', 'Movie']; function MoviesEditController($scope, $routeParams, $location, Movie) { $scope.movie = Movie.get({ id: $routeParams.id }); $scope.edit = function () { $scope.movie.$save( // success function () { $location.path('/'); }, // error function (error) { _showValidationErrors($scope, error); } ); }; } /* Movies Delete Controller */ MoviesDeleteController.$inject = ['$scope', '$routeParams', '$location', 'Movie']; function MoviesDeleteController($scope, $routeParams, $location, Movie) { $scope.movie = Movie.get({ id: $routeParams.id }); $scope.remove = function () { $scope.movie.$remove({id:$scope.movie.Id}, function () { $location.path('/'); }); }; } /* Movies Delete Controller */ DatePickerController.$inject = ['$scope']; function DatePickerController($scope) { $scope.open = function ($event) { $event.preventDefault(); $event.stopPropagation(); $scope.opened = true; }; } /* Utility Functions */ function _showValidationErrors($scope, error) { $scope.validationErrors = []; if (error.data && angular.isObject(error.data)) { for (var key in error.data) { $scope.validationErrors.push(error.data[key][0]); } } else { $scope.validationErrors.push('Could not add movie.'); }; } })();
Notice that both the MoviesAddController and MoviesEditController functions now include an error callback. When a 400 Bad Request is returned from the server, the error callback is invoked and the _showValidationErrors() function is called.
The _showValidationErrors() function adds the error messages from ModelState to the $scope.validationErrors array. The contents of the validationErrors array is displayed in the _edit.html form:
<div class="bg-danger validationErrors" ng-show="validationErrors"> <ul> <li ng-repeat="error in validationErrors">{{error}}</li> </ul> </div>
Summary
In this blog post, I focused on explaining how you can perform validation when using ASP.NET 5 and AngularJS. I explained how you can use AngularJS directives – such as the ng-required directive – to perform client-side validation. I also explained how you can take advantage of ModelState and the BadRequestObjectResult when validating data submitted to the ASP.NET Web API.
In the next blog post, I discuss ASP.NET 5 security.
I remember those days when we added validation once in the model and it propagated to the client. I knew it was too good to last forever 🙁
I think there is a fundamental difference between input validation and domain validation and trying to autogenerate one from the other ends in misery. Except for simple apps, you want to do your validation in your domain layer (domain validation) and then enable each presentation layer to handle validation in different ways (input validation). Maybe 🙂 I haven’t entirely convinced myself.
In ASP.NET MVC you can add validation to the ViewModel and you also add validation to the Entities. Still you always need the same validation running on the client and on the server when the data is posted. So I agree that you do need more layers of validation but there is one layer that always runs on both the client and the server.
Great series… thanks !!
Yes indeed great series, however I have never understood why AngularJS has become so popular with ASP.NET MVC developers, since AngularJS obvious interrupts and complicates MVC development.
@Mohammad – You have a much better use experience when using client-side technologies such as AngularJS: you can create modal popups, drag and drop, and a fancier UI. It does make things more complicated, but it is definitely worth it.
Hey Stephen – Even in the Microsoft.AspNet.Mvc 6 beta 4-12472 I dont see the BadRequestObjectResult. Did they completely remove it or am I doing something wrong?
@ARD — it was added in a commit 17 days ago. I see it on GitHub: https://github.com/aspnet/Mvc/blob/5262dfd577be0f3e48473a9ddc9a40a000916028/src/Microsoft.AspNet.Mvc.Core/ActionResults/BadRequestObjectResult.cs
It looks like it just inherits from the base ObjectResult and changes the status code. So you could use the ObjectResult to do the same thing.