Our work weeks are made up of victories both big and small. What really gets me writing is the small. The a-ha! moments. The high fives (internal ones count too). The green builds. The git pushes.
I spent my week creating dynamic forms and lists in Angular. I made them, fought with them, tested them, and came out the other side in one piece. Here's my story:
Custom form validators are a powerful feature in Angular.
Say I've got a form, and an input for a name. I want the name to be unique from a list of pre-existing names (shout out to Beatles fans):
form.html
<form name="nameform">
<input name="nameinput" ng-model="fifthMember" unique-name-validator current-names="theBeatles" />
</form>
controller.js
$scope.theBeatles = ['John', 'George', 'Paul', 'Ringo'];
Take a look at the attribute unique-name-validator
and current-names
. That's a reference to the custom validator, which wraps itself in a directive:
angular.module('addABeatle').directive('uniqueNameValidator', function(){
return {
require: 'ngModel',
scope: {
currentNames: '='
},
link: function($scope, _e, _a, modelController){
modelController.$validators.uniqueName = function(newName){
return !_.contains($scope.currentNames, newName);
};
}
};
});
Requiring ngModel
gives you access to the model controller that handles the ng-model="fifthBeatles"
. On that controller, a validator is added. It is simply a function that returns true if the input is valid, false if it's invalid.
From there, I use the current-names
attribute to test if the name is unique (lodash used here). It gets attached to $scope
when I isolate it on the directive.
Testing, after some setup work, is pretty straight forward.
You create an element and $compile
it. To get a reference to the input, I leveraged Angular's behavior of attach forms onto the current $scope
.
describe('directive: uniqueNameValidator', function(){
var $scope, input;
beforeEach(inject(function($compile, $rootScope){
$scope = $rootScope.$new();
$scope.theBeatles = ['John', 'George', 'Paul', 'Ringo'];
var element = '<form name="nameform"><input name="nameinput" ng-model="fifthMember" unique-name-validator current-names="theBeatles" /></form>';
$compile(element)($scope);
input = $scope.nameform.nameinput;
}));
Then the tests become a matter of setting the view's value, and testing for $valid
or $invalid
:
it('marks a unique name $valid', function(){
input.$setViewValue('Pete Best'); // Beatles dropout!
$scope.$digest();
expect(input.$valid).toBe(true);
});
it('marks a not unique name $invalid', function(){
input.$setViewValue('Paul');
$scope.$digest();
expect(input.$invalid).toBe(true);
});
I was working on a UI bit where you click on a modal to add or remove some items from a list. I wanted the modal to have a shallow copy of the items list, so that a cancel wouldn't update the original items list; only save would. Since a line of code is worth a thousand explanations:
controller.js
var items = ['Clean room', 'Do laundry'];
openModal = function() {
modal.open(items.slice()); // passing in items to modal
};
modal.js
remove = function(item) {
_.pull(items, item);
}
cancel = function() {
this.close();
}
So when modal.cancel
is called, controller.items
should be unmodified.
In my unit test (jasmine, below), I wanted to test that modal.items
was a shallow copy.
test.js
expect(modal.items).not.toEqual(controller.items);
// Fail
But that doesn't work. slice
returns a new array, but maintains the references inside the array. This subtlety is lost on toEqual
, and lodash/underscore's _.isEqual
. Plain javascript to the rescue:
test.js
expect(modal.items !== controller.items).toBe(true);
// Pass
Oh yeah.
To cover my bases, I still used toEqual
to test that controller.items
was passed to modal.items
so my test looked like:
expect(modal.items).toEqual(controller.items);
expect(modal.items !== controller.items).toBe(true);
// Pass
In an Angular directive, you can use the bindToController
property to set your $scope injections on the controller instance. It all works great, but things get murky when it comes to testing.
If you want to pass in mock data in a directive controller during a test, you have to modify how you instantiate the controller. It turns out a third argument to $controller is boolean called later
that will return a function.
On that function's instance
property, you can add your mock injections. Then when you invoke the function, you'll get the controller instance with your mocks attached.
A quick example:
angular.module('todoapp', [])
.directive('todoList', function(){
scope: {
items: '='
},
bindToController: true
controller: 'Todo'
})
.controller('Todo', function(){
var controller = this;
controller.amountOfTodos = function(){
return controller.items.length;
};
});
describe('getAmountOfTodos', function(){
var controller = $controller('Todo', {}, true);
angular.extend(controller.instance, {
items: [
'Take out laundry'
]
});
controller = controller();
it('returns the count of todo items', function(){
expect(controller.getAmountOfTodos()).toEqual(1);
});
});
Props. and open issue.
Not too long ago, I wrote about storing the value of a filtered ng-repeat. But what if I wanted the display to show the filtered results, and I wanted to use the un-filtered results somewhere else in the template?
Consider this example:
<input ng-model="searchText" />
<ul>
<li ng-repeat="item in getItems() | filter:searchText as items"></li>
</ul>
<span>{{items.length}} items!</span>
If getItems()
returns two items, the span will show 2 items!
. Once we start entering search text, getItems()
will still return two items, but the repeat will be filtered. Then, the span would show 1 items!
or 0 items!
depending on what our searchText
was.
To make sure the item count persists even when a user is filtering, you can modify how you assign the output of getItems()
:
<li ng-repeat="item in (items = getItems()) | filter:searchText"></li>
In that example, items
is being assigned before filtering. So our items will remain the same, even when a user is searching. And when they search, the display will only show the filtered results.
Putting it all together:
<input ng-model="searchText" />
<ul>
<li ng-repeat="item in (items = getItems()) | filter:searchText"></li>
</ul>
<span>{{items.length}} items!</span>
These may be small things. But this week, they represented huge victories.
Ian McNally is a Front End Specialist and Senior Software Consultant for Stride. He writes at ia-n.com and tweets @_ianmcnally.