How vanilla html works with forms
Before discussing Angular, lets have a look at how you would upload a file and do some validation using ordinary vanilla html, without any Javascript.
<form action='/upload' enctype='multipart/form-data' method='post'>
<input type='text' required='' name='fooText'><br>
<input type='file' required='' name='barFile'><br>
<input type='submit' value='submit'>
</form>
Things to notice is the 'enctype' attribute with a value of 'multipart/form-data'. This is necessary in order to correctly encode the form data. Validation is provided via the 'required' attribute. When the user submits the form, the browser will check if the input field has been populated and, if not, will display an error message.
Shortcomings with vanilla HTML
Submitting the form results in a new page request. This is not necessarily what we want and often it would be better to simply send the data to the server behind the scenes. Validation also only occurs on form submission and we may wish instead to validate the form on key input or on when the user exits a field. It must also be said that the browser error messages are extremely ugly, at least in my opinion.
Forms in Angular
The Angular approach to form handling is fundamentally different from ordinary html. The essence of Angular is that the state of the page, your data, is stored in a Javascript object and the html of the page represents a dynamic or 'live' view of this data. Angular provides a means of defining event handlers which act on this data and result in the page being updated.
Below is a snippet of a form in an Angular app. It has some similarities to an ordinary html form but there are some significant differences and it works in a very different way. The 'novalidate' attribute on the form element tells the browser not to carry out native validation. This is because Angular is going to handle this itself.
The ng-model attribute specifies the property on our model in which the value of the input will be stored.
The 'name' attribute is interesting: In an html form the name attribute is for referencing the form through Javascript and for supplying the key for submitted data. In Angular however it provides a way of accessing the FormController. The FormController is an object that represents the state of the form. This should NOT be confused withe form's data that is stored on the current scope. The state of the form tells you whether the form has been interacted with by the user, whether it has been submitted, any errors etc. The FormController is published on the scope by the name attribute of the form and input elements.
The ng-messages and ng-message directives are supplied by Angular's ngMessages module. This module simply facilitates the display of error messages. The value of these directives reference the state of the form through the FormController and when the condition is met display the appropriate messages.
The actual validation rule that we are using here is the build in rule 'required' which is closely analagous to the html rule of the same name. The difference is that validation is carried out by default on every digest cycle which occurs on every change in the scope, for example after every key stroke.
<form name='myform' novalidate>
<input type='text' required ng-model='data.firstName' name='firstName'/>
<input type='file' required ng-model='data.file' name='myfile'/>
<div class="errors" ng-messages="myform.firstName.$touched && myform.firstName.$error" >
<div ng-message="required">a value is required in the text field</div>
</div>
<div class="errors" ng-messages="myform.myfile.$touched && myform.myfile.$error" >
<div ng-message="required">you need to supply a file</div>
</div>
<button ng-click='submit()'>submit</button>
</form>
Here is the Javascript which makes the form work. The submit() method is called when the user clicks on the submit button. The data is read from the scope object, bundled up into a FormData object and sent to the server using the angular $http.post() method. Of note is the options object. This is necessary for setting the correct content-type for the data. A good explanation of why this works can be found here. Unfortunately this code will still not work correctly. The text input field will be correctly validated and its data uploaded, but because Angular does not support the file input field the scope will not contain our file, as indicated by the console.log() in the code and neither validation nor upload will work correctly.
var app = angular.module('myapp', ['ngMessages']);
app.controller('MainCtrl', function ($scope, $http) {
$scope.data = {};
$scope.submit = function () {
var formData = new FormData();
formData.append('fooText', $scope.data.firstName);
formData.append('barFile', $scope.data.file);
console.log( $scope.data.file); // undefined at this point
var options = {
transformRequest: angular.identity,
headers: {'Content-Type': undefined}
};
$http.post('/upload', formData, options).then(successHandler, errorHandler);
};
function successHandler() {}
function errorHandler() {}
});
We can fix this however by the following directive 'file-model' place on the file input field.
<input type='file' file-model required ng-model='data.file' name='myfile'/>
The code for the directive looks like this:
app.directive('fileModel', [function () {
return {
restrict: 'A',
link: function(scope, element, attrs) {
element.bind('change', function(){
scope.$apply(function(){
var file = element[0].files[0];
scope.data.file = file;
});
});
}
};
}]);
What this does is simply bind a handler to the change event of the field and when that fires it manually writes the file into the scope. By wrapping this in the $apply() method we ensure that the digest cycle will run correctly afterwards. The file is now included in the form data when we upload to the server. Also, an error message will be displayed if we fail to provide a file.
Custom validation for file input fields
Having got the file input field working in the same way as other input fields, it would be nice to see if it is possible to write custom validators for a file field. Happily the answer is that, with the previous infrastructure in place, it is! Angular provides two kinds of custom validators: synchronous and asynchronous.Because of the nature of our validation we are going to create an asynchronous validator.
app.directive('imageValidator', function($q) {
return {
restrict : 'A',
require : 'ngModel',
link : function (scope, element, attrs, ctrl) {
ctrl.$asyncValidators.imageValidator = function(modelValue, viewValue) {
var def = $q.defer();
var reader = new FileReader();
var image = new Image();
if(viewValue) {
reader.readAsDataURL(viewValue);
reader.onload = function(_file) {
image.src = _file.target.result;
if (image.width > 400)
def.resolve();
} else {
def.reject();
}
};
}
return def.promise;
}
}
};
});
The imageValidator() function is added as a property to the $asyncValidators property of the controller. The controller in this instance represents the ngModel directive. This is why this directive has to require 'ngModel'. This validator simply checks if the image (assuming the file is an image) has a width greater than 400 pixels. We use a FileReader to transform the file into an image allowing us to query its properties. We are doing the actual checking of the image within an asynchronous callback which is why this needs to be an asynchronous validator. Clearly there are many ways that this code could be improved (checking for file type, for example) but hopefully this conveys the basic idea of custom validators. You would use it by adding the directive to the file input element.We also add a new message which references our new validator.
<input type='file' file-model image-validator required ng-model='data.file' name='myfile'/>
<div class="errors" ng-messages="myform.myfile.$touched && myform.myfile.$error" >
<div ng-message="required">you need to supply a file</div>
<div ng-message="imageValidator">image must be above 400px wide</div>
</div>
Conclusion
It is a mystery to me why Angular does not provide this support for files out of the box since, as we have shown, it is relatively straightforwards to achieve it. You can find a lot more bitching about the problem here and unfortunately the Angular team do not seem hugely enthusiastic about fixing it. Hopefully someone will find this useful. Things left to do is some investigation into the best way to test this.