By Ian McNally
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:
Writing and testing custom form validators
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
<span class="cm-tag cm-bracket"><</span><span class="cm-tag">form</span> <span class="cm-attribute">name</span>=<span class="cm-string">"nameform"</span><span class="cm-tag cm-bracket">></span> <span class="cm-tag cm-bracket"><</span><span class="cm-tag">input</span> <span class="cm-attribute">name</span>=<span class="cm-string">"nameinput"</span> <span class="cm-attribute">ng-model</span>=<span class="cm-string">"fifthMember"</span> <span class="cm-attribute">unique-name-validator</span> <span class="cm-attribute">current-names</span>=<span class="cm-string">"theBeatles"</span> <span class="cm-tag cm-bracket">/></span> <span class="cm-tag cm-bracket"></</span><span class="cm-tag">form</span><span class="cm-tag cm-bracket">></span>
controller.js
<span class="cm-variable">$scope</span>.<span class="cm-property">theBeatles</span> <span class="cm-operator">=</span> [<span class="cm-string">'John'</span>, <span class="cm-string">'George'</span>, <span class="cm-string">'Paul'</span>, <span class="cm-string">'Ringo'</span>];
Creating a validator
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:
<span class="cm-variable">angular</span>.<span class="cm-property">module</span>(<span class="cm-string">'addABeatle'</span>).<span class="cm-property">directive</span>(<span class="cm-string">'uniqueNameValidator'</span>, <span class="cm-keyword">function</span>(){ <span class="cm-keyword">return</span> { <span class="cm-property">require</span>: <span class="cm-string">'ngModel'</span>, <span class="cm-property">scope</span>: { <span class="cm-property">currentNames</span>: <span class="cm-string">'='</span> }, <span class="cm-property">link</span>: <span class="cm-keyword">function</span>(<span class="cm-def">$scope</span>, <span class="cm-def">_e</span>, <span class="cm-def">_a</span>, <span class="cm-def">modelController</span>){ <span class="cm-variable-2">modelController</span>.<span class="cm-property">$validators</span>.<span class="cm-property">uniqueName</span> <span class="cm-operator">=</span> <span class="cm-keyword">function</span>(<span class="cm-def">newName</span>){ <span class="cm-keyword">return</span> <span class="cm-operator">!</span><span class="cm-variable">_</span>.<span class="cm-property">contains</span>(<span class="cm-variable-2">$scope</span>.<span class="cm-property">currentNames</span>, <span class="cm-variable-2">newName</span>); }; } }; });
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
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.
<span class="cm-variable">describe</span>(<span class="cm-string">'directive: uniqueNameValidator'</span>, <span class="cm-keyword">function</span>(){ <span class="cm-keyword">var</span> <span class="cm-def">$scope</span>, <span class="cm-def">input</span>; <span class="cm-variable">beforeEach</span>(<span class="cm-variable">inject</span>(<span class="cm-keyword">function</span>(<span class="cm-def">$compile</span>, <span class="cm-def">$rootScope</span>){ <span class="cm-variable-2">$scope</span> <span class="cm-operator">=</span> <span class="cm-variable-2">$rootScope</span>.<span class="cm-property">$new</span>(); <span class="cm-variable-2">$scope</span>.<span class="cm-property">theBeatles</span> <span class="cm-operator">=</span> [<span class="cm-string">'John'</span>, <span class="cm-string">'George'</span>, <span class="cm-string">'Paul'</span>, <span class="cm-string">'Ringo'</span>]; <span class="cm-keyword">var</span> <span class="cm-def">element</span> <span class="cm-operator">=</span> <span class="cm-string">'<form name="nameform"><input name="nameinput" ng-model="fifthMember" unique-name-validator current-names="theBeatles" /></form>'</span>; <span class="cm-variable-2">$compile</span>(<span class="cm-variable-2">element</span>)(<span class="cm-variable-2">$scope</span>); <span class="cm-variable-2">input</span> <span class="cm-operator">=</span> <span class="cm-variable-2">$scope</span>.<span class="cm-property">nameform</span>.<span class="cm-property">nameinput</span>; }));
Then the tests become a matter of setting the view's value, and testing for $valid or $invalid:
<span class="cm-variable">it</span>(<span class="cm-string">'marks a unique name $valid'</span>, <span class="cm-keyword">function</span>(){ <span class="cm-variable">input</span>.<span class="cm-property">$setViewValue</span>(<span class="cm-string">'Pete Best'</span>); <span class="cm-comment">// Beatles dropout!</span> <span class="cm-variable">$scope</span>.<span class="cm-property">$digest</span>(); <span class="cm-variable">expect</span>(<span class="cm-variable">input</span>.<span class="cm-property">$valid</span>).<span class="cm-property">toBe</span>(<span class="cm-atom">true</span>); }); <span class="cm-variable">it</span>(<span class="cm-string">'marks a not unique name $invalid'</span>, <span class="cm-keyword">function</span>(){ <span class="cm-variable">input</span>.<span class="cm-property">$setViewValue</span>(<span class="cm-string">'Paul'</span>); <span class="cm-variable">$scope</span>.<span class="cm-property">$digest</span>(); <span class="cm-variable">expect</span>(<span class="cm-variable">input</span>.<span class="cm-property">$invalid</span>).<span class="cm-property">toBe</span>(<span class="cm-atom">true</span>); });
Equality of shallow javascript arrays
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
<span class="cm-keyword">var</span> <span class="cm-variable">items</span> <span class="cm-operator">=</span> [<span class="cm-string">'Clean room'</span>, <span class="cm-string">'Do laundry'</span>]; <span class="cm-variable">openModal</span> <span class="cm-operator">=</span> <span class="cm-keyword">function</span>() { <span class="cm-variable">modal</span>.<span class="cm-property">open</span>(<span class="cm-variable">items</span>.<span class="cm-property">slice</span>()); <span class="cm-comment">// passing in items to modal</span> };
modal.js
<span class="cm-variable">remove</span> <span class="cm-operator">=</span> <span class="cm-keyword">function</span>(<span class="cm-def">item</span>) { <span class="cm-variable">_</span>.<span class="cm-property">pull</span>(<span class="cm-variable">items</span>, <span class="cm-variable-2">item</span>); } <span class="cm-variable">cancel</span> <span class="cm-operator">=</span> <span class="cm-keyword">function</span>() { <span class="cm-keyword">this</span>.<span class="cm-property">close</span>(); }
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
<span class="cm-variable">expect</span>(<span class="cm-variable">modal</span>.<span class="cm-property">items</span>).<span class="cm-property">not</span>.<span class="cm-property">toEqual</span>(<span class="cm-variable">controller</span>.<span class="cm-property">items</span>); <span class="cm-comment">// Fail</span>
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
<span class="cm-variable">expect</span>(<span class="cm-variable">modal</span>.<span class="cm-property">items</span> <span class="cm-operator">!==</span> <span class="cm-variable">controller</span>.<span class="cm-property">items</span>).<span class="cm-property">toBe</span>(<span class="cm-atom">true</span>); <span class="cm-comment">// Pass</span>
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:
<span class="cm-variable">expect</span>(<span class="cm-variable">modal</span>.<span class="cm-property">items</span>).<span class="cm-property">toEqual</span>(<span class="cm-variable">controller</span>.<span class="cm-property">items</span>); <span class="cm-variable">expect</span>(<span class="cm-variable">modal</span>.<span class="cm-property">items</span> <span class="cm-operator">!==</span> <span class="cm-variable">controller</span>.<span class="cm-property">items</span>).<span class="cm-property">toBe</span>(<span class="cm-atom">true</span>); <span class="cm-comment">// Pass</span>
Testing Angular directive's bindToController
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:
controller
<span class="cm-variable">angular</span>.<span class="cm-property">module</span>(<span class="cm-string">'todoapp'</span>, []) .<span class="cm-property">directive</span>(<span class="cm-string">'todoList'</span>, <span class="cm-keyword">function</span>(){ <span class="cm-variable">scope</span>: { <span class="cm-variable">items</span>: <span class="cm-string">'='</span> }, <span class="cm-variable">bindToController</span>: <span class="cm-atom">true</span> <span class="cm-variable">controller</span>: <span class="cm-string">'Todo'</span> }) .<span class="cm-property">controller</span>(<span class="cm-string">'Todo'</span>, <span class="cm-keyword">function</span>(){ <span class="cm-keyword">var</span> <span class="cm-def">controller</span> <span class="cm-operator">=</span> <span class="cm-keyword">this</span>; <span class="cm-variable-2">controller</span>.<span class="cm-property">amountOfTodos</span> <span class="cm-operator">=</span> <span class="cm-keyword">function</span>(){ <span class="cm-keyword">return</span> <span class="cm-variable-2">controller</span>.<span class="cm-property">items</span>.<span class="cm-property">length</span>; }; });
test
<span class="cm-variable">describe</span>(<span class="cm-string">'getAmountOfTodos'</span>, <span class="cm-keyword">function</span>(){ <span class="cm-keyword">var</span> <span class="cm-def">controller</span> <span class="cm-operator">=</span> <span class="cm-variable">$controller</span>(<span class="cm-string">'Todo'</span>, {}, <span class="cm-atom">true</span>); <span class="cm-variable">angular</span>.<span class="cm-property">extend</span>(<span class="cm-variable-2">controller</span>.<span class="cm-property">instance</span>, { <span class="cm-property">items</span>: [ <span class="cm-string">'Take out laundry'</span> ] }); <span class="cm-variable-2">controller</span> <span class="cm-operator">=</span> <span class="cm-variable-2">controller</span>(); <span class="cm-variable">it</span>(<span class="cm-string">'returns the count of todo items'</span>, <span class="cm-keyword">function</span>(){ <span class="cm-variable">expect</span>(<span class="cm-variable-2">controller</span>.<span class="cm-property">getAmountOfTodos</span>()).<span class="cm-property">toEqual</span>(<span class="cm-number">1</span>); }); });
<a href="http://stackoverflow.com/questions/25837774/bindtocontroller-in-unit-tests" target="_blank">Props.</a> and <a href="https://github.com/angular/angular.js/issues/9425" target="_blank">open issue</a>.
Making the most of ng-repeat and filters
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:
<span class="cm-operator"><</span><span class="cm-variable">input</span> <span class="cm-variable">ng</span><span class="cm-operator">-</span><span class="cm-variable">model</span><span class="cm-operator">=</span><span class="cm-string">"searchText"</span> <span class="cm-operator">/></span> <span class="cm-operator"><</span><span class="cm-variable">ul</span><span class="cm-operator">></span> <span class="cm-operator"><</span><span class="cm-variable">li</span> <span class="cm-variable">ng</span><span class="cm-operator">-</span><span class="cm-variable">repeat</span><span class="cm-operator">=</span><span class="cm-string">"item in getItems() | filter:searchText as items"</span><span class="cm-operator">><</span><span class="cm-string-2">/li></span> <span class="cm-operator"><</span><span class="cm-string-2">/ul></span> <span class="cm-operator"><</span><span class="cm-variable">span</span><span class="cm-operator">></span> <span class="cm-variable">items</span><span class="cm-operator">!<</span><span class="cm-string-2">/span></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():
<span class="cm-operator"><</span><span class="cm-variable">li</span> <span class="cm-variable">ng</span><span class="cm-operator">-</span><span class="cm-variable">repeat</span><span class="cm-operator">=</span><span class="cm-string">"item in (items = getItems()) | filter:searchText"</span><span class="cm-operator">><</span><span class="cm-string-2">/li></span>
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:
<span class="cm-operator"><</span><span class="cm-variable">input</span> <span class="cm-variable">ng</span><span class="cm-operator">-</span><span class="cm-variable">model</span><span class="cm-operator">=</span><span class="cm-string">"searchText"</span> <span class="cm-operator">/></span> <span class="cm-operator"><</span><span class="cm-variable">ul</span><span class="cm-operator">></span> <span class="cm-operator"><</span><span class="cm-variable">li</span> <span class="cm-variable">ng</span><span class="cm-operator">-</span><span class="cm-variable">repeat</span><span class="cm-operator">=</span><span class="cm-string">"item in (items = getItems()) | filter:searchText"</span><span class="cm-operator">><</span><span class="cm-string-2">/li></span> <span class="cm-operator"><</span><span class="cm-string-2">/ul></span> <span class="cm-operator"><</span><span class="cm-variable">span</span><span class="cm-operator">></span> <span class="cm-variable">items</span><span class="cm-operator">!<</span><span class="cm-string-2">/span></span>
These may be small things. But this week, they represented huge victories.
About the author
Ian McNally is a Front End Specialist and Senior Software Consultant for Stride. He writes tweets @_ianmcnally.