Skip to content

Instantly share code, notes, and snippets.

@dankahle
Last active August 29, 2015 14:05
Show Gist options
  • Save dankahle/68bc60aa4b7c0a76680b to your computer and use it in GitHub Desktop.
Save dankahle/68bc60aa4b7c0a76680b to your computer and use it in GitHub Desktop.
form error message strategies in angular

Forms can be quite tedious in angular, mostly having to do with messages. There's several options: highight the invalid fields or show messages, show messages only when dirty, disable submit button, etc. I don't like the disabled submit button as it leaves the user guessing which field is invalid, and... if they're not guessing which field is invalid, then all invalid fields or messages are "always" shown, I don't care for that as well. My solution this time around was to show messages only when dirty, leaving the submit button enabled, then setting all fields to dirty upon submission. So, no messages shown until the submit button is hit or they visit the field Upon submit all messages are shown for invalid fields. This feels right.

Also, played around a bit with ng-module-options.updateOn and have to say, blur isn't cool. You just can't wait for a blur event for error message updates or even field updates (a select won't change it's value after you select an option until you blur away). The ux felt more responsive using the "default" option, so just let the default ride (updates on every key hit).

Embedded messages vs ngMessages module

I have to say, I had my doubts when I first ran through the docs and examples on the ngMessages module, but working on this example has turned me around. I found it much cleaner and much more resuable to use the ngMessages module. Surely the global messages alone make it worthwhile. The ng-messages expression is a bit of a mystery. I.e. it's looking for an object hash, but will take booleans too. Not sure how far that can be pushed, but surely there's code that's looking for booleans and disregarding them in the hash it's gonna use. Was nice to see it allowed and followed boolean additions. That made all the difference as I was wondering how to limit the messages to dirty only.

Form errors using classes

I'm not a big fan of these, but this example turned out better than I'd have thought. The main problem was multiple messages for the same field. I.e. how to know which one is causing the issue. Case in point "required" on the email control, and the field shows invalid after you start typing as it rolls from required to invalid email errors. I tossed in a message for the latter, which seemed appropriate enough, i.e. maybe the solution is to augment style changes with messages. Also, the setting dirty trick didn't work with classes as the form.field.$dirty setting didn't set the ng-dirty class. Also, the trick I saw on the web: setting form.field.$setViewValue(form.field.$viewValue) didn't set it to dirty either (as stated it would). There's not form.field.$setDirty(), but there is form.field.$setTouched, so I ran with ng-touched instead and all worked fine.

formEmbeddedMessages.html:
allow them to hit submit button, old-style embedded messages

formNgMessages.html:
same deal, but using a global message template and the new ngMessages module

formClasses.html: uses classes to set a red outline on invalid fields (instead of messages), yet augments the email control with a message to discern between a required error and an invalid email error.

messages.html:
global message template

<!--
This worked out better than I would have thought, maybe there's a place for this in
simple forms, but when more than one message, say email, there's no discerning
between errors, is it required? I guess that's easy to see in ui, just not
easy to see invalid email is all. So.. I added a message for email. It doesn't show
on submit which is nice, i.e. needs to set a value before the validator runs.
-->
<!DOCTYPE html>
<html ng-app="app">
<head>
<style>
.ng-invalid [ng-message] {color: red}
input.ng-touched.ng-invalid, textarea.ng-touched.ng-invalid, select.ng-touched.ng-invalid {outline: 1px solid red;}
</style>
<script src="vendor/angular.js"></script>
<script src="vendor/angular-messages.js"></script>
</head>
<body ng-controller="ctrl">
<form name="form" class="css-form" novalidate >
<table>
<tr>
<td>Name:*</td>
<td>
<input type="text" ng-model="user.name" name="name" required/>
</td>
</tr>
<tr>
<td>Email:*</td>
<td>
<input type="email" ng-model="user.email" name="email" required/>
<span ng-messages="form.email.$touched && form.email.$error">
<span ng-message="email">Invalid email</span>
</span>
</td>
</tr>
<tr>
<td>Gender:*</td>
<td>
<input name="rad1" type="radio" ng-model="user.gender" value="male" required/>male
<input name="rad2" type="radio" ng-model="user.gender" value="female" required/>female
</td>
</tr>
<tr>
<td>Agree:*</td>
<td>
<input type="checkbox" ng-model="user.agree" name="agreeChk" required/>
I agree: <input name="agreeText" ng-show="user.agree" type="text" ng-model="user.agreeSign" required/>
</td>
</tr>
<tr>
<td>State:*</td>
<td>
<select name="state" ng-model="state" ng-options="s.name for s in states" required>
<option value="">Please Select</option>
</select>
</td>
</tr>
<tr>
<td>Comments:*</td>
<td>
<textarea name="comments" ng-model="comments" cols="30" rows="3" required=""></textarea>
</td>
</tr>
<tr>
<td></td>
<td>
<button ng-click="reset()" ng-disabled="unchanged(user)">RESET</button>
<button ng-click="save(user)">SAVE</button>
</td>
</tr>
</table>
</form>
{{form.email.$error}}<br>
<script>
var app = angular.module('app', ['ngMessages']);
app.controller('ctrl', function ($scope, $log) {
var log = $scope.log = $log.log;
$scope.master = {};
$scope.user = {};
$scope.states = [
{id: 1, name: 'Arizona'},
{id: 2, name: 'Cali'}
]
$scope.save = function () {
this.master = this.user;
}
$scope.save = function () {
this.form.name.$setTouched();
this.form.email.$setTouched();
this.form.rad1.$setTouched();
this.form.rad2.$setTouched();
this.form.agreeChk.$setTouched();
this.form.agreeText.$setTouched();
this.form.state.$setTouched();
this.form.comments.$setTouched();
this.form.name.$setTouched();
// $setViewValue was supposed to set to $dirty, but didn't,
// no $setDirty() method, but there is a $setTouched, so we'll use
// that instead
// this.form..$setViewValue(this.form..$viewValue)
if (this.form.$invalid)
return;
this.log('success')
angular.copy($scope.user, $scope.master);
};
$scope.unchanged = function () {
return angular.equals(this.master, this.user);
}
}); // ctrl
</script>
<script type="text/ng-template" id="messages">
<span ng-message="required">Required</span>
<span ng-message="email">Invalid email address</span>
<span ng-message="url">Invalid url</span>
<span ng-message="min">Value is too small</span>
<span ng-message="max">Value is too large</span>
<span ng-message="minlength">Value is too short</span>
<span ng-message="maxlength">Number is too long</span>
</script>
</body>
</html>
<!--
I'm retracting the updateOn:blur for email and such. Seems it's more responsive
when doing it on each key stroke, plus errors drop when their valid, not on
blur, plus there where stuff you had to do "default" on anyway as the stupid dropdown
wouldn't update until you blurred out, unacceptable.
Another addition is the email validity which showed right off the bat the the agree message which showed after
checkbox, but before textbox don't show "until you hit save for first time". Makes sense.
-->
<!DOCTYPE html>
<html ng-app="app">
<head>
<style>
.error {color:red;}
</style>
<script src="vendor/angular.js"></script>
</head>
<body ng-controller="ctrl">
<form name="form" class="css-form" novalidate >
<table>
<tr>
<td>Name:*</td>
<td>
<input type="text" ng-model="user.name" name="name" required />
<span class="error" ng-show="form.name.$dirty && form.name.$error.required">Required</span>
</td>
</tr>
<tr>
<td>Email:*</td>
<td>
<input type="email" ng-model="user.email" name="email" required/>
<span class="error" ng-show="form.email.$dirty && form.email.$error.required">required</span>
<span class="error" ng-show="form.save && form.email.$dirty && form.email.$error.email">This is not a valid email.</span>
</td>
</tr>
<tr>
<td>Gender:*</td>
<td>
<input name="gender" type="radio" ng-model="user.gender" value="male" required/>male
<input type="radio" ng-model="user.gender" value="female" />female
<span class="error" ng-show="form.gender.$dirty && form.gender.$error.required">pick a sex</span>
</td>
</tr>
<tr>
<td>Agree:*</td>
<td>
<input type="checkbox" ng-model="user.agree" name="agreed" />
I agree: <input ng-show="user.agree" type="text" ng-model="user.agreeSign" />
<span class="error" ng-show="form.save && form.agreed.$dirty && (!user.agree || !user.agreeSign)">Please agree and sign.</span>
</td>
</tr>
<tr>
<td>State:*</td>
<td>
<select name="state" ng-model="state" ng-options="s.name for s in states" required>
<option value="">Please Select</option>
</select>
<span class="error" ng-show="form.state.$dirty && form.state.$error.required">Required</span>
</td>
</tr>
<tr>
<td>Comments:*</td>
<td>
<textarea name="comments" ng-model="comments" cols="30" rows="3" required=""></textarea>
<span class="error" ng-show="form.comments.$dirty && form.comments.$error.required">Required</span>
</td>
</tr>
<tr>
<td></td>
<td>
<button ng-click="reset()" ng-disabled="unchanged(user)">RESET</button>
<button ng-click="save(user)">SAVE</button>
</td>
</tr>
</table>
</form>
<script>
var app = angular.module('app', []);
app.controller('ctrl', function ($scope, $log) {
var log = $scope.log = $log.log;
$scope.master = {};
$scope.user = {};
$scope.states = [
{id:1, name:'Arizona'},
{id:2, name:'Cali'}
]
$scope.save = function(){
this.master = this.user;
}
$scope.save = function(){
this.form.save = true;
this.form.name.$dirty = true;
this.form.email.$dirty = true;
this.form.gender.$dirty = true;
this.form.agreed.$dirty = true;
this.form.state.$dirty = true;
this.form.comments.$dirty = true;
if(this.form.$invalid)
return;
this.log('success')
angular.copy($scope.user, $scope.master);
};
$scope.unchanged = function(){
return angular.equals(this.master, this.user);
}
}); // ctrl
</script>
</body>
</html>
<!--
I'm rather impressed with the reusability of global messages. You'd have to do: [ng-message] {display:block} for
small screens. Added a custom message and have a strategy in the submit handler for it, but decided to have it
more responsive by "watching" the validation and updating the form variable accordingly.
Also added a custom validator (directive) for an example of how to implement that. Initially copied the documentation's
example, but it's old and they have a new way to do validation now (with ngModelController.$validators), so just
copied one of theirs (maxlengthdirective) then modified that. Works both on model>>view and view>>model, which is
how it should be. This example preloads an invalid value in the field (name), when the page loads, then you hit "submit"
you should see an error, you only will if your validation is also model>>view. This used to mean adding it to
ngModelController.$formatters as well, but $validators handles both it turns out. Nice.
-->
<!DOCTYPE html>
<html ng-app="app">
<head>
<style>
.error [ng-message], .ng-invalid [ng-message] {
color: red;
/* display:block; //need to do this for small screens */
}
</style>
<script src="vendor/angular.js"></script>
<script src="vendor/angular-messages.js"></script>
</head>
<body ng-controller="ctrl">
<form name="form" class="css-form" novalidate >
<table>
<tr>
<td>Name:*</td>
<td>
<input type="text" ng-model="user.name" name="name" onlyval="dank" required />
<span ng-messages="form.name.$dirty && form.name.$error" ng-messages-include="messages.html">
<span ng-message="onlyval">Should be dank</span>
</span>
</td>
</tr>
<tr>
<td>Email:*</td>
<td>
<input type="email" ng-model="user.email" name="email" required/>
<span ng-messages="form.email.$dirty && form.email.$error" ng-messages-include="messages.html">
<span ng-message="email">Overridden email message</span>
</span>
</td>
</tr>
<tr>
<td>Gender:*</td>
<td>
<input name="gender" type="radio" ng-model="user.gender" value="male" required/>male
<input type="radio" ng-model="user.gender" value="female" />female
<span ng-messages="form.gender.$dirty && form.gender.$error" ng-messages-include="messages.html"></span>
</td>
</tr>
<tr>
<td>Agree:*</td>
<td>
<input type="checkbox" ng-model="user.agree" name="agreeChk" required/>
I agree: <input name="agreeText" ng-show="user.agree" type="text" ng-model="user.agreeSign" required/>
<span ng-messages="form.agreeChk.$dirty && form.agreeChk.$error" ng-messages-include="messages.html"></span>
<span ng-messages="user.agree && form.agreeText.$dirty && form.agreeText.$error" ng-messages-include="messages.html"></span>
</td>
</tr>
<tr>
<td>State:*</td>
<td>
<select name="state" ng-model="user.state" ng-options="s.name for s in states" required>
<option value="">Please Select</option>
</select>
<span ng-messages="form.state.$dirty && form.state.$error" ng-messages-include="messages.html"></span>
</td>
</tr>
<tr>
<td>Comments:*</td>
<td>
<textarea name="comments" ng-model="user.comments" cols="30" rows="3" required=""></textarea>
<span ng-messages="form.comments.$dirty && form.comments.$error" ng-messages-include="messages.html"></span>
</td>
</tr>
<tr>
<td></td>
<td>
<button ng-click="reset()" ng-disabled="unchanged(user)">RESET</button>
<button ng-click="save(user)">SAVE</button>
</td>
</tr>
</table>
</form>
<div class="error" ng-messages="form.custom.$error">
<span ng-message="menAz">Can't have men from az</span>
</div>
form.name.$error: {{form.name.$error}}<br>
user.name: {{user.name}}<br>
<script>
var app = angular.module('app', ['ngMessages']);
app.directive('onlyval', function() {
return {
restrict: 'A',
require: '?ngModel',
link: function(scope, elm, attr, ctrl) {
if (!ctrl) return;
var onlyval = "";
attr.$observe('onlyval', function(value) {
onlyval = value;
ctrl.$validate();
});
ctrl.$validators.onlyval = function(value) {
return ctrl.$isEmpty(value) || value.toLowerCase() == onlyval;
};
}
};
});
app.controller('ctrl', function ($scope, $log) {
var log = $scope.log = $log.log;
$scope.master = {};
$scope.user = {name:'danks'};
$scope.states = [
{id: 1, name: 'Arizona'},
{id: 2, name: 'Cali'}
]
$scope.save = function () {
this.master = this.user;
}
$scope.$watch("user.gender && user.state && (user.gender == 'male' " +
"&& user.state.name.toLowerCase() == 'arizona')", function(newval, oldval){
if(newval)
$scope.form.custom = {$error: {menAz:true}};
else
$scope.form.custom = undefined;// no newval == oldval filter as we need to set this first time in
})
$scope.save = function () {
this.form.save = true;
this.form.name.$dirty =
this.form.email.$dirty =
this.form.gender.$dirty =
this.form.agreeChk.$dirty =
this.form.agreeText.$dirty =
this.form.state.$dirty =
this.form.comments.$dirty = true;
var valid = true;
if(this.form.custom)
valid = false;
if(!valid)
return;
/*
// could handle custom errors here in submit, but not as responsive as scope.watch
if(this.user.gender == 'male' && this.user.state.name.toLowerCase() == 'arizona'){
this.form.custom = {$error: {menAz:true}};
valid = false;
}
else
this.form.custom = undefined;
if(!valid)
return;
*/
if (this.form.$invalid)
return;
this.log('success')
angular.copy($scope.user, $scope.master);
};
$scope.unchanged = function () {
return angular.equals(this.master, this.user);
}
}); // ctrl
</script>
<script type="text/ng-template" id="messages">
<span ng-message="required">Required</span>
<span ng-message="email">Invalid email address</span>
<span ng-message="url">Invalid url</span>
<span ng-message="min">Value is too small</span>
<span ng-message="max">Value is too large</span>
<span ng-message="minlength">Value is too short</span>
<span ng-message="maxlength">Number is too long</span>
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment