Tuesday, December 20, 2016

Testing "this" with Jasmine

I had cause to test a function that references this in JavaScript and, although it ended up being easy, it took some figuring to get there.  Rather than have to figure it all out again (and maybe to save someone else some trouble, too) I'm putting my solutions here (yes, there are two solutions).

Here is the function I needed to test:
$scope.dataBound = function(e) {
    var data = this.dataSource.data();
    for (var i = data.length; --i >= 0;) {
        if (!data[i].IsBundle) {
            var row = $('#grid')
                .data('kendoGrid')
                .tbody.find('tr[data-uid="' + data[i].uid + '"]');
            $(row).find('td.k-hierarchy-cell .k-icon').removeClass();
        }
    }
};

The first - and easiest - solution is to set whatever you need on scope.  In Angular, when this is referenced, it's referring to scope so in my case I was using this.dataSource.data() so I was able to just set an object directly on scope called dataSource that had a data function.
it('should set this on scope', function() {
    // arrange
    scope.dataSource = {
        data: function() {
            return [];
        }
    };
    spyOn(scope.dataSource, 'data').and.callThrough();

    // act
    scope.dataBound();

    // assert
    expect(scope.dataSource.data).toHaveBeenCalledTimes(1);
});

The second - and in my opinion more correct - solution is to invoke the function using call and pass whatever you want this to be as your first parameter.
it('should set pass this using call', function() {
    // arrange
    var thisToSet = {
        dataSource: {
            data: function() {
                return [];
            }
        }
    };
    spyOn(thisToSet.dataSource, 'data').and.callThrough();

    // act
    scope.dataBound.call(thisToSet);

    // assert
    expect(thisToSet.dataSource.data).toHaveBeenCalledTimes(1);
});

They're pretty similar, but I prefer using call because it should work outside Angular as well.

Mozilla has a pretty sweet explanation of this and how to use it in case you want more detailed information.

Kendo UI Directives

Below is a list of directives found in the Kendo UI module.  I was trying to figure out how to set just the data of a data source on a grid and couldn't find a list.  I came across a question on SO asking for a list, but there was no answer.  I found another question on SO on how to list out the registered items in an Angular module, ran that against the kendo.directives module and voila!

This list is from the 2016.3.1118 release (please note that this is just a list of directives so if you're looking for something else you'll have to modify the code and get it yourself).

The code I used to generate the list:
   1:  angular.module('kendo.directives')['_invokeQueue'].forEach(function(value) {
   2:      if (value[1] === 'directive') {
   3:          console.log(value[2][0]);
   4:      }
   5:  });

The list:
2kendoAlert
2kendoAttribution
2kendoBarcode
2kendoBreadcrumbs
2kendoButton
2kendoCalendar
2kendoChart
2kendoConfirm
2kendoDiagram
2kendoDialog
2kendoDraggable
2kendoEditable
2kendoEditor
2kendoGantt
2kendoGrid
2kendoGroupable
2kendoMap
2kendoMenu
2kendoNavigator
2kendoNotification
2kendoPager
2kendoPopup
2kendoPrompt
2kendoReorderable
2kendoResizable
2kendoScheduler
2kendoSelectable
2kendoSlider
2kendoSortable
2kendoSparkline
2kendoSplitter
2kendoSpreadsheet
2kendoSurface
2kendoTooltip
2kendoTouch
2kendoUpload
2kendoValidator
2kendoWindow
kActionsheetContext
kAlign
kAllDayEventTemplate
kAltRowTemplate
kAltTemplate
kColumnHeaderTemplate
kDataCellTemplate
kDateHeaderTemplate
kDetailTemplate
kEditTemplate
kEmptyTemplate
kendoAutoComplete
kendoColorPalette
kendoColorPicker
kendoColumnMenu
kendoColumnSorter
kendoComboBox
kendoContextMenu
kendoDatePicker
kendoDateTimePicker
kendoDropDownList
kendoDropTarget
kendoDropTargetArea
kendoFileBrowser
kendoFilterCell
kendoFilterMenu
kendoFilterMultiCheck
kendoFlatColorPicker
kendoImageBrowser
kendoLinearGauge
kendoListView
kendoMaskedTextBox
kendoMediaPlayer
kendoMobileActionSheet
kendoMobileApplication
kendoMobileBackButton
kendoMobileButton
kendoMobileButtonGroup
kendoMobileCollapsible
kendoMobileDetailButton
kendoMobileDrawer
kendoMobileFooter
kendoMobileHeader
kendoMobileLayout
kendoMobileListView
kendoMobileLoader
kendoMobileModalView
kendoMobileNavBar
kendoMobilePane
kendoMobilePopOver
kendoMobilePopup
kendoMobileRecurrenceEditor
kendoMobileScroller
kendoMobileScrollView
kendoMobileScrollViewPage
kendoMobileShim
kendoMobileSplitView
kendoMobileSwitch
kendoMobileTabStrip
kendoMobileTimezoneEditor
kendoMobileView
kendoMultiSelect
kendoNumericTextBox
kendoPanelBar
kendoPivotConfigurator
kendoPivotFieldMenu
kendoPivotGrid
kendoProgressBar
kendoQRCode
kendoRadialGauge
kendoRangeSlider
kendoRecurrenceEditor
kendoResponsivePanel
kendoSearchBox
kendoSelectBox
kendoStaticList
kendoStockChart
kendoTabStrip
kendoTimePicker
kendoTimezoneEditor
kendoToolBar
kendoTreeList
kendoTreeMap
kendoTreeView
kendoViewTitle
kendoVirtualList
kendoVirtualScrollable
kendoZoomControl
kErrorTemplate
kEventTemplate
kHeaderTemplate
kIcon
kLinkTemplate
kMajorTimeHeaderTemplate
kMinorTimeHeaderTemplate
kRel
kRowHeaderTemplate
kRowTemplate
kSelectTemplate
kTemplate
kTransition

Thursday, December 1, 2016

Testing a Request in an ApiController

I recently came across a situation where I needed to test an action method on an ApiController to make sure the correct response was returned to the user based on the request.  In this particular case I was testing the ability to upload a file to an API and I needed to return a 400 (Bad Request) error if the content type of the request was not right.  I knew the code was working (I know, it wasn't TDD, but sometimes you have to roll with the punches), but I was having a hard time testing it.  To make things worse I couldn't use shims or fakes because the build server kept blowing up on them.  Fortunately, I came across a couple of really helpful blogs that pointed me in the right direction.

First, Shiju Varghese's blog on writing unit tests for an ApiController.  Next I used William Hallat's blog on testing a file upload to finish things off.

What I ended up with is pretty neat and easy to use, customized to meet my needs.  As usual, I'm posting it here so I don't have to redo the work next time.

The first key to making this work is declaring the controller.  Let's just say that our controller has a single object injected, an ILogger (a fairly common practice).  Instead of just doing this:
var controller = new ExampleController(_moqLogger.Object);

We want to do this:
   1:  var controller = new ExampleController(_moqLogger.Object)
   2:  {
   3:      Request = new HttpRequestMessage
   4:      {
   5:          Content = new ObjectContent(typeof(string), null, new JsonMediaTypeFormatter()),
   6:          Method = HttpMethod.Post
   7:      }
   8:  };

UPDATE: We actually also want to include an HttpConfiguration to prevent another error I was getting later:
   1:  var request = new HttpRequestMessage
   2:  {
   3:      Content = new ObjectContent(typeof(string), null, new JsonMediaTypeFormatter()),
   4:      Method = HttpMethod.Post
   5:  };
   6:  request.Properties.Add(HttpPropertyKeys.HttpConfigurationKey, new HttpConfiguration());
   7:  var controller = new ExampleController(_moqLogger.Object)
   8:  {
   9:      Request = request
   8:  };

This declares that the request passed to the controller will actually be specified to contain content and a method (POST in this case).  Now that we have that, our tests won't fail with the awful (and unhelpful) "Object reference not set to an instance of an object" error you might be seeing.

In this case we've specified that the content will be a string, but we haven't specified any actual content.  But as I said before I needed to test whether the content was a file that had been uploaded, then also take certain actions based on that file.  To do that I actually needed to fake a file upload in the request itself.

A little bit of setup (this happens before each test run not each test):
   1:  [TestFixtureSetUp]
   2:  public void SetUpFixture()
   3:  {
   4:      using (var outFile = new StreamWriter(_testFile))
   5:      {
   6:          outFile.WriteLine("some test data");
   7:      }
   8:  }

The test:
   1:  [Test]
   2:  public void DoSomethingShouldReturnAnOkResult()
   3:  {
   4:      // arrange
   5:      var multipartContent = BuildFormDataContent();
   6:      multipartContent.Add(new StringContent("some value"), "someKey");
   7:   
   8:      var controller = new ExampleController(_moqLogger.Object)
   9:      {
  10:          Request = new HttpRequestMessage
  11:          {
  12:              Content = multipartContent,
  13:              Method = HttpMethod.Post
  14:          }
  15:      };
  16:   
  17:      // act
  18:      var response = controller.DoSomething();
  19:   
  20:      // assert
  21:      Assert.IsInstanceOf<OkResult>(response.Result);
  22:  }

The method called by the test:
   1:  private string _testFile = "test.file";
   2:   
   3:  private MultipartFormDataContent BuildFormDataContent()
   4:  {
   5:      var multipartContent = new MultipartFormDataContent("boundary=---011000010111000001101001");
   6:              
   7:      var fileStream = new FileStream(_testFile, FileMode.Open, FileAccess.Read);
   8:      var streamContent = new StreamContent(fileStream);
   9:      streamContent.Headers.ContentType = new MediaTypeHeaderValue("multipart/form-data");
  10:   
  11:      multipartContent.Add(streamContent, "TheFormDataKeyForTheFile", _testFile);
  12:   
  13:      return multipartContent;
  14:  }

And finally, the controller action method:
   1:  [HttpPost]
   2:  public async Task<IHttpActionResult> DoSomething()
   3:  {
   4:      if (!Request.Content.IsMimeMultipartContent("form-data"))
   5:      {
   6:          _logger.Information(() => "Unsupported media type");
   7:          return BadRequest("Unsupported media type");
   8:      }
   9:   
  10:      try
  11:      {
  12:          var root = @"C:\";
  13:          var provider = new MultipartFormDataStreamProvider(root);
  14:          await Request.Content.ReadAsMultipartAsync(provider);
  15:   
  16:          var someValue = provider.FormData.GetValues("someKey").FirstOrDefault();
  17:   
  18:          foreach (var file in provider.FileData)
  19:          {
  20:              var fileInfo = new FileInfo(file.LocalFileName);
  21:              // do something with the file here that returns a boolean
  22:              if(someOtherMethod()){
  23:                  return Ok();
  24:              }            
  25:   
  26:              return InternalServerError(new Exception("An error was encountered while processing the request"));
  27:          }
  28:   
  29:          return Ok();
  30:      }
  31:      catch (Exception ex)
  32:      {
  33:          return InternalServerError(ex);
  34:      }
  35:  }

This solved my problem and enabled me to test my action method on my controller.

Friday, November 18, 2016

Testing Exception Messages with MS Test

I usually use NUnit to do my C# unit tests, but when I need to use the Microsoft Fakes framework I have to use the built-in (to Visual Studio) MS Test framework.  This is because Fakes requires instrumentation ability that NUnit just doesn't have.  It also means I can't use the Resharper test runner like I normally do.  Neither of those changes is usually a problem, but today I found out that testing an exception message in MS Test is actually a bit tricky.

You can use the
[ExpectedException]
attribute around a test to indicate to MS Test that you expect that test to throw an exception. However, even though there is an overload for that attribute that accepts a message, it's not actually used to test whether the thrown exception has the message you specify in the attribute. So
[ExpectedException(typeof(ArgumentNullException), "A name is required")]
doesn't actually check whether the message on the exception is
"A name is required"
. Instead, if that test fails (which would happen if an exception wasn't thrown since you're expecting that one will be thrown) the text that will appear in Test Explorer is
"A name is required"
. The good news is that there's an easy way around this. The bad news is you still have to create your own attribute to do it. It's not complicated. h/t to BlackjacketMack on SO for posting his solution here.

   1:  public class ExpectedExceptionWithMessageAttribute : ExpectedExceptionBaseAttribute
   2:  {
   3:      public Type ExceptionType { get; set; }
   4:   
   5:      public string ExpectedMessage { get; set; }
   6:   
   7:      public ExpectedExceptionWithMessageAttribute(Type exceptionType)
   8:      {
   9:          ExceptionType = exceptionType;
  10:      }
  11:   
  12:      public ExpectedExceptionWithMessageAttribute(Type exceptionType, string message)
  13:      {
  14:          ExceptionType = exceptionType;
  15:          ExpectedMessage = message;
  16:      }
  17:   
  18:      protected override void Verify(Exception exception)
  19:      {
  20:          if (exception.GetType() != ExceptionType)
  21:          {
  22:              NUnit.Framework.Assert.Fail(
  23:                  "ExpectedExceptionWithMessageAttribute failed. Expected exception type: {0}. Actual exception type: {1}. Exception message: {2}",
  24:                  ExceptionType.FullName, exception.GetType().FullName, exception.Message);
  25:          }
  26:   
  27:          var actualMessage = exception.Message.Trim();
  28:   
  29:          if (ExpectedMessage != null)
  30:          {
  31:              Assert.AreEqual(ExpectedMessage, actualMessage);
  32:          }
  33:      }
  34:  }

You use it just like you used
[ExpectedException]
earlier.
[ExpectedExceptionWithMessage(typeof(ArgumentNullException), "A name is required")]

More Fun with Shims

I'm finally back to doing some server side code (as opposed to the client stuff I've been working exclusively with for several months) and I found myself in need of some unit tests.  It's been a long time since I used a shim from Microsoft's Fakes framework so I had to poke and prod it for a while to work and I don't want to forget what I did.  Here goes:

In this particular example I was working with the WSUS API provided by Microsoft to interact with WSUS.  I specifically was trying to save a new signing certificate, but I obviously didn't want to actually do that on a WSUS server.  The solution was to use shims and stubs this time.

   1:  private string _fileNameParameter = "empty";
   2:  private SecureString _passwordParameter = new SecureString();

   1:  private StubIUpdateServer PrepareIUpdateServerStub()
   2:  {
   3:      FakesDelegates.Action<string, SecureString> setSigningCertificateAction = (fileName, password) =>
   4:      {
   5:          _fileNameParameter = fileName;
   6:          _passwordParameter = password;
   7:      };
   8:   
   9:      var iUpdateServerConfiguration = new StubIUpdateServerConfiguration
  10:      {
  11:          SetSigningCertificateStringSecureString = setSigningCertificateAction
  12:      };
  13:   
  14:      var iUpdateServerStub = new StubIUpdateServer
  15:      {
  16:          GetConfiguration = () => iUpdateServerConfiguration
  17:      };
  18:   
  19:      return iUpdateServerStub;
  20:  }

   1:  [TestMethod]
   2:  public void SetSigningCertificateShouldPassParametersToWsusApiAndReturnTrue()
   3:  {
   4:      using (ShimsContext.Create())
   5:      {
   6:          // arrange
   7:          const string expectedFileName = "fileName";
   8:          const string expectedPasswordString = "password";
   9:          var expectedPassword = new SecureString();
  10:          foreach (var c in expectedPasswordString)
  11:          {
  12:              expectedPassword.AppendChar(c);
  13:          }
  14:   
  15:          ShimAdminProxy.GetUpdateServerStringBooleanInt32 = (name, isSsl, port) => PrepareIUpdateServerStub();
  16:   
  17:          var wsusRepository = new WsusRepository();
  18:   
  19:          // act
  20:          var result = wsusRepository.SetSigningCertificate(expectedFileName, expectedPassword);
  21:   
  22:          // assert
  23:          Assert.AreEqual(expectedFileName, _fileNameParameter);
  24:          Assert.AreEqual(expectedPassword.ToString(), _passwordParameter.ToString());
  25:          Assert.IsTrue(result);
  26:      }
  27:  }

I broke out a bunch of the stub creation stuff so I could reuse it across multiple tests.  That's what the PrepareIUpdateServerStub method is for.  That's also why I have the global variables _fileNameParameter and _passwordParameter.  The method I was testing is essentially a pass-through to the WSUS API that we reuse in multiple applications so the method itself was fairly straightforward (check if a file and password were passed, then pass them on to WSUS).

Like I said at the beginning, working with Shims, Fakes, and Stubs is always a challenge for me so hopefully this documentation will help me next time I need to do it.

Tuesday, November 15, 2016

Local NPM Package

If you find yourself writing an NPM package, but you don't want to release it before you test it, there's a neat way to install a package from your local machine to another project.  You can use npm link instead of npm install.

Go to the directory containing the package you're writing (that you want to test by referencing in another project) and make sure to do an npm init on that directory (this creates the package.json file used by npm).  Once you're done run npm link.  Now go to the project you want to reference the package in and run npm link and pass it the name of the package you used during npm init.

That's it!

Thursday, October 20, 2016

TemplateUrl Function for Dynamic Templating

I've come across multiple situations while using Angular JS where I'd like to dynamically load a template into a directive.  It used to be pretty difficult, but in more recent versions (I'm not sure when it was introduced) you can pass a function to templateUrl to somewhat dynamically determine which template you want to use.  Most recently this came in handy when I was nesting a bunch of tables within each other (yes, it was a valid use case), but I also wanted those tables to be available alone.

I made a template for each table - 5 total - and then passed the relative url of the template I wanted as an attribute to the directive.  So it worked out like this:

<ea-children details="childrenFirst" template-url="/templates/first.html"></ea-children>
<ea-children details="childrenSecond" template-url="/templates/second.html"></ea-children>
<ea-children details="childrenThird" template-url="/templates/third.html"></ea-children>

As you can see I was able to reuse the same directive three times with potentially drastically different markup.  The templateUrl function looks like this:

templateUrl: function (element, attributes) {
    return attributes.templateUrl;
}

One major item to note is that you don't have access to scope variables because the template is requested before scope is initialized.

Hopefully next time I need to do this (assuming I'm still using Angular 1.x) I'll remember to check here first.  Happy coding!

Tuesday, October 11, 2016

Angular Tree View

For the second (or maybe third) time I faced a situation that would drastically benefit from a Tree View control.  Since I love Angular and was using it anyway, I finally set out to create a Tree View control I could reuse and be proud of.  It's pretty basic, but that's kinda the point.  There's actually a little bit of styling on this one, too.  Once again I'm going to try to get it up on Github at some point, but for now you can check it out on plnkr.

UPDATE: I've changed the directive significantly, checked it into Github and also created an npm package out of it called angular-tree-view.  I'm still updating a lot of it, but it's alive and in the wild now!

Check it out on Github
Check it out on npm

An important piece to note is that this feature actually uses two different directives nested within each other.  There's the parent item, which is a tree-view and then there's the child item, which is a tree-view-item.  Each tree-view-item can have another tree-view in it, which can then have another tree-view-item, which can then have another tree-view, etc.

Directive Requirements:
  • Each item can have an unlimited number of sub-items
  • Each sub-item can have an unlimited number of sub-items (ad infinitum)
  • The user can specify a callback to be invoked when a sub-item with no sub-items of its own is clicked
  • The selected item should be indicated by the application of an "active-branch" class
Directive Module Code:
angular.module('ea.treeview', []).directive('eaTreeView', function() {
  return {
        restrict: 'E',
        scope: {
            items: '=',
            isRoot: '@',
            callback: '&'
        },
        link: function(scope) {
            scope.callback = scope.callback();
        },
        controller: function ($scope) {
            this.isRoot = $scope.isRoot;
        },
        template: '<ea-tree-view-item item="item" callback="callback" data-ng-repeat="item in items"></ea-tree-view-item>'
    }
}).directive('eaTreeViewItem', function() {
  return {
        restrict: 'E',
        scope: {
            item: '=',
            callback: '&'
        },
        require: '^eaTreeView',
        link: function (scope, element, attributes, controller) {
          scope.callback = scope.callback();
            scope.activate = function () {
              var activeElements = document.getElementsByClassName('active-branch');
              angular.forEach(activeElements, function(activeElement){
                angular.element(activeElement).removeClass('active-branch');
              });
              element.children('div').addClass('active-branch');
              scope.callback(scope.item);
            };

            scope.hasChildren = function () {
                return !!scope.item.items && !!scope.item.items.length;
            };

            scope.hasParent = function() {
                return !controller.isRoot;
            };

            scope.toggleExpanded = function() {
                scope.item.expanded = !scope.item.expanded;
            };
        },
        templateUrl: 'treeViewItem.html'
    }
});

The treeViewItem Template:
<div>
    <div data-ng-if="hasChildren()" class="tree-parent" data-ng-class="{'tree-child': hasParent()}">
        <div data-ng-click="toggleExpanded()" class="clickable">
            <i class="fa" data-ng-class="{'fa-chevron-right': !item.expanded, 'fa-chevron-down': item.expanded}"></i>
            <span>{{item.display}}</span>
        </div>
        <ea-tree-view data-ng-if="item.expanded" callback="callback" items="item.items"></ea-tree-view>
    </div>
    <div data-ng-if="!hasChildren()" data-ng-click="activate()" class="tree-child clickable">{{item.display}}</div>
</div>

A dash of styling:
.clickable {
  cursor: pointer;
}

.active-branch {
    background-color: lightgrey;
}

.tree-parent {
  padding-left: 0;
}

.tree-child {
  padding-left: 1em;
}

.tree-final-child {
  padding-left: 2em;
}

And finally some usage:
<ea-tree-view items="model.items" is-root="true" callback="go"></ea-tree-view>

angular.module('demo', ['ea.treeview']).controller('mainController', function($scope) {
  $scope.model = {
    items: [
      {
        display: 'Abe',
        items: [
          {display: 'Homer', items: [{display: 'Bart'}, {display: 'Lisa'}, {display: 'Maggie'}]},
          {display: 'Herb'},
          {display: 'Abbie'}
        ]
      },
      {
        display: 'Jacqueline',
        items: [
          {display: 'Patty'},
          {display: 'Selma'},
          {display: 'Marge', items: [{display: 'Bart'}, {display: 'Lisa'}, {display: 'Maggie'}]}
        ]
      }
    ]
  };
  
  $scope.go = function (item) {
      console.log("You could have navigated!", item);
  };
});

Angular Tri-State Loading Directive

I had a situation where I wanted to load some data asynchronously and display a visual cue to the user that the action had been completed.  However, there was a possibility that more than one result could be returned and the user would have to take some action to confirm which item they want to use.  I also wanted to show the user a "loading" message so they'd have the visual cue that something was happening.  Since I was already using Angular and Font Awesome I decided the best way to go about it would be to use a directive.  I prefer my directives to be configurable as much as possible so I can reuse them in the future with relative ease (that is the point of directives after all).  This is what I came up with.  I will at some point get it uploaded to Github, but for now this is the code.  (You can check it out on plnkr, too).

First I had to create the directive.  I've refactored it a little bit so that it's usable as a separate module by just including 'ea.loading' in your module import list when you declare your module.

Directive Requirements:

  • User can configure which icons to use, but defaults will be used if nothing is specified
  • User can configure which color(s) to use on which states and no defaults will be used
  • User can specify a callback function and parameters for each of the three states separately (for click events only)
  • User can specify an initial state, but a default of 0 (do not show) will be used if nothing is specified

Directive Module Code:
angular.module('ea.loading', []).directive('eaTriStateLoading', function() {
  return {
    restrict: 'E',
    scope: {
      animate: '@',
      initialValue: '@',
      loadingCallback: '&',
      loadingCallbackParameters: '=',
      loadingClass: '@',
      loadingColor: '@',
      notOkCallback: '&',
      notOkClass: '@',
      notOkColor: '@',
      okCallback: '&',
      okClass: '@',
      okColor: '@',
      state: '='
    },
    link: {
      pre: function(scope, element, attributes) {
        scope.animate = scope.animate || true;
        scope.state = parseInt(scope.initialValue) || 0;
        scope.loadingClass = scope.loadingClass || 'fa-spinner';
        scope.okClass = scope.OkClass || 'fa-check';
        scope.notOkClass = scope.notOkClass || 'fa-exclamation-triangle';
      },
      post: function(scope, element, attributes) {
        function buildParameterObject(parameters) {
          var result = {};
          
          for (var i = parameters.length; --i >= 0;) {
            var parameter = parameters[i];
            for (var j = Object.keys(parameter).length; --j >= 0;) {
              // key is the name of the parameter
              // parameter[key] is the name of the property on scope that will contain the value to be passed to the parameter
              var key = Object.keys(parameter)[j];
              var value = scope.$parent[parameter[key]] || parameter[key].substring(1, parameter[key].length - 1);
              result[key] = value;
            }
          }
          
          return result;
        }
        
        scope.click = function($event) {
          var parameter = {};
          if (scope.state === 1 && !!scope.loadingCallback && angular.isDefined(scope.loadingCallback) && angular.isFunction(scope.loadingCallback)) {
            if (!!scope.loadingCallbackParameters) {
              parameter = buildParameterObject(scope.loadingCallbackParameters);
            }
            parameter.$event = $event;
            scope.loadingCallback(parameter);
          } else if (scope.state === 2 && angular.isDefined(scope.okCallback) && angular.isFunction(scope.okCallback)) {
            if (!!scope.okCallbackParameters) {
              parameter = buildParameterObject(scope.okCallbackParameters);
            }
            parameter.$event = $event;
          } else if (scope.state === 3 && angular.isDefined(scope.noOkCallback) && angular.isFunction(scope.noOkCallback)) {
            if (!!scope.notOkCallbackParameters) {
              parameter = buildParameterObject(scope.notOkCallbackParameters);
            }
            parameter.$event = $event;
            scope.notOkCallback(parameter);
          }
        };
      },
    },
    template: '<i class="fa" ng-class="{\'{{loadingClass}}\': state === 1, \'fa-pulse\': state === 1 && animate === true, \'{{okClass}}\': state === 2, \'{{notOkClass}}\': state === 3}" ng-click="click($event)" ng-style="{color: (state === 1 ? loadingColor : state === 2 ? okColor : notOkColor)}"></i>'
  }
});

And some use cases:
<ea-tri-state-loading loading-callback-parameters="model.firstDemoLoadingCallbackParameters" loading-callback="clickCallback(property, model.age, $event)" loading-color="blue" not-ok-color="red" ok-callback="decrement()" ok-color="green" state="model.firstDemoValue"></ea-tri-state-loading>
<ea-tri-state-loading loading-class="fa-circle-o-notch" loading-callback-parameters="[{'property': '\'secondDemoValue\''}]" loading-callback="clickCallback(property, $event)" loading-color="purple" not-ok-color="yellow" ok-color="orange" initial-value="2" state="model.secondDemoValue"></ea-tri-state-loading>
<ea-tri-state-loading animate="false" ok-class="fa-rocket" state="model.thirdDemoValue"></ea-tri-state-loading>
<ea-tri-state-loading not-ok-class="fa-remove" state="model.fourthDemoValue"></ea-tri-state-loading>
<ea-tri-state-loading state="model.fifthDemoValue"></ea-tri-state-loading>
And finally the controller that would back those use cases:
angular.module('demo', ['ea.loading']).controller('mainController', function($scope) {
  $scope.states = {
    first: 1,
    second: 2,
    third: 3
  };
  $scope.model = {
    firstDemoValue: 1,
    secondDemoValue: 1,
    thirdDemoValue: 1,
    fourthDemoValue: 2,
    fifthDemoValue: 3,
    age: 12,
    firstDemoLoadingCallbackParameters: [
      {'property': '"firstDemoValue"'},
      {'value': 'age'}
    ]
  };
  
  $scope.clickCallback = function(property, value, $event) {
    console.log('property', property);
    console.log('value', value);
    console.log('$event', $event);
  };
  
  $scope.simulateLoading = function(property) {
    $scope.model[property] = 1;
  }
  
  $scope.simulateOk = function(property) {
    $scope.model[property] = 2;
  };
  
  $scope.simulateNotOk = function(property) {
    $scope.model[property] = 3;
  };
});

Wednesday, September 14, 2016

Order of Import

Hi! It's been a while and I know you missed me.  Awwww, you're sweet.  I missed you, too.

Anyway, I had an issue this morning that I wanted to document in case it happens again. I'm setting up unit testing on a new project with Karma and Jasmine and I was getting a weird error for a while.  Specifically, I was getting "TypeError: Attempted to assign to readonly property" and it was driving me crazy (my wife would tell you that's quite a short drive).  Eventually I figured out that my scripts were being referenced in the wrong order in my Karma config file.  Because of alphabetization I was importing angular-mocks.js before angular.min.js (apparently hyphens come before periods).  I switched them around and voila! Now I'm all set again.

Bonus: I was also running into several weird errors with angular-mocks until I went back and grabbed an older version. Since this project is using AngularJS 1.3.5 I went back to angular-mocks 1.3.19 and now everything works as expected.

Tuesday, August 2, 2016

Free eBooks!!!!

It seems like about once per year Microsoft decides to give away a ton of eBooks just because they can.  Well, it's that time of year.  If you go here you can view all of the books that are available and download them one at a time.  Alternately, you can paste the following into a word document, save it as "index.html" without the quotes, then open that file in your web browser to download all of them at once.  That's what I did and it took about 30 minutes over WiFi at home (I have high speed Internet access so if you happen to be using dial-up, you'll want to keep that in mind).  I just noticed that the last few downloads failed because I lost my connection due to a storm while I was downloading.  I have no way of knowing which files were downloaded now.  Ugh.  But whatever, I can't do everything for you.

I don't need all of these books, but I'm a big fan of having resources and not needing them instead of needing them and not having them.  And since Eric says right there on his blog that he wants us to download all of these, I felt obliged to do so.  Happy reading!

<html>
 <head>
  <title>Download eBooks</title>
 </head>
 <body>
  <script>
   (function() {
    var endpoints = [
     "http://ligman.me/29ngkYn",
     "http://ligman.me/29jL5wW",
     "http://ligman.me/29afIRV",
     "http://ligman.me/29pyHgR",
     "http://ligman.me/29dmbfC",
     "http://ligman.me/29ollRF",
     "http://ligman.me/29gvv67",
     "http://ligman.me/29pzkHg",
     "http://ligman.me/29CWQ20",
     "http://ligman.me/1G0Cm7T",
     "http://ligman.me/29vDVrw",
     "http://ligman.me/29CW4SV",
     "http://ligman.me/29G1z1N",
     "http://ligman.me/29yWAAO",
     "http://ligman.me/29DHWHq",
     "http://ligman.me/1HGwMgm",
     "http://ligman.me/29yUVeH",
     "http://ligman.me/29ErxWs",
     "http://ligman.me/29EsHkr",
     "http://ligman.me/29v5zCF",
     "http://ligman.me/1H32nUT",
     "http://ligman.me/29yixk9",
     "http://ligman.me/29vq6d2",
     "http://ligman.me/29cvrjG",
     "http://ligman.me/29pzjTR",
     "http://ligman.me/29rPOf6",
     "http://ligman.me/29ctW9z",
     "http://ligman.me/29gvoHt",
     "http://ligman.me/29gvfEd",
     "http://ligman.me/29LvcBg",
     "http://ligman.me/29DS3gJ",
     "http://ligman.me/1q9L65I",
     "http://ligman.me/29pufLO",
     "http://ligman.me/29wODdA",
     "http://ligman.me/29vch9q",
     "http://ligman.me/29EmfZc",
     "http://ligman.me/29LXmMt",
     "http://ligman.me/29qwKfQ",
     "http://ligman.me/29sp3nZ",
     "http://ligman.me/29yWFFf",
     "http://ligman.me/29ZNaMN",
     "http://ligman.me/29gYy5h",
     "http://ligman.me/29NhudA",
     "http://ligman.me/29jDRJf",
     "http://ligman.me/29conIf",
     "http://ligman.me/29cnLlL",
     "http://ligman.me/29fNYRE",
     "http://ligman.me/29cq9cw",
     "http://ligman.me/29acqhj",
     "http://ligman.me/29fJJCS",
     "http://ligman.me/29nbxWK",
     "http://ligman.me/29fO9MV",
     "http://ligman.me/29pr2x1",
     "http://ligman.me/29oa0kH",
     "http://ligman.me/29i6ntm",
     "http://ligman.me/29n7JVF",
     "http://ligman.me/29fQsQ4",
     "http://ligman.me/29fQo2J",
     "http://ligman.me/29idz8T",
     "http://ligman.me/29dfwlu",
     "http://ligman.me/29fLliP",
     "http://ligman.me/29i83TI",
     "http://ligman.me/29diypX",
     "http://ligman.me/29fNpHO",
     "http://ligman.me/29oefNf",
     "http://ligman.me/29jGTgC",
     "http://ligman.me/29crPhL",
     "http://ligman.me/29FUJIk",
     "http://ligman.me/1G2oDNG",
     "http://ligman.me/29u5uMT",
     "http://ligman.me/29rlRt3",
     "http://ligman.me/29Yd9V0",
     "http://ligman.me/29s4EQ1",
     "http://ligman.me/29vkv6E",
     "http://ligman.me/29A3q8Z",
     "http://ligman.me/29H7ovO",
     "http://ligman.me/1dHMOui",
     "http://ligman.me/1G2pw8X",
     "http://ligman.me/29gnaPK",
     "http://ligman.me/29d7GZH",
     "http://ligman.me/29i2Mvf",
     "http://ligman.me/29rbpkY",
     "http://ligman.me/29e6cTO",
     "http://ligman.me/29hJU1c",
     "http://ligman.me/29bcJtd",
     "http://ligman.me/29bcRZO",
     "http://ligman.me/29d5mS2",
     "http://ligman.me/29cz6OD",
     "http://ligman.me/29fDult",
     "http://ligman.me/29pEdjN",
     "http://ligman.me/29rwoY0",
     "http://ligman.me/29fTQrx",
     "http://ligman.me/29gfrkN",
     "http://ligman.me/29rwoHw",
     "http://ligman.me/29d5AbX",
     "http://ligman.me/29fudHc",
     "http://ligman.me/29FzGG5",
     "http://ligman.me/29osNwd",
     "http://ligman.me/29a22WR",
     "http://ligman.me/29alDpK",
     "http://ligman.me/29fDylj",
     "http://ligman.me/29jPTCx",
     "http://ligman.me/29d5XTw",
     "http://ligman.me/29rUIci",
     "http://ligman.me/29hVdVa",
     "http://ligman.me/29ijDhm",
     "http://ligman.me/29pn5e2",
     "http://ligman.me/29ckVx7",
     "http://ligman.me/29cfePX",
     "http://ligman.me/29vGIvY",
     "http://ligman.me/29hfnPR",
     "http://ligman.me/29fCVFe",
     "http://ligman.me/29a6YLu",
     "http://ligman.me/29n4GwJ",
     "http://ligman.me/29jAS3v",
     "http://ligman.me/29ci56U",
     "http://ligman.me/29nW9L3",
     "http://ligman.me/29hV6ZM",
     "http://ligman.me/29d1qAV",
     "http://ligman.me/29pfpbI",
     "http://ligman.me/29v8nwX",
     "http://ligman.me/29uq452",
     "http://ligman.me/29d1wc3",
     "http://ligman.me/29Y1O7a",
     "http://ligman.me/29a7wRA",
     "http://ligman.me/29pnEEG",
     "http://ligman.me/1FYtDD8",
     "http://ligman.me/1HByNKS",
     "http://ligman.me/1NCfcKC",
     "http://ligman.me/1HCDxl9",
     "http://ligman.me/1HCCCRP",
     "http://ligman.me/1H4Q0e5",
     "http://ligman.me/1JI6V77",
     "http://ligman.me/1CSMobd",
     "http://ligman.me/1jWMJA2",
     "http://ligman.me/1m6xucg",
     "http://ligman.me/1onTg9n",
     "http://ligman.me/1n49kzj",
     "http://ligman.me/1sgBtn4",
     "http://ligman.me/1qZlnOJ",
     "http://ligman.me/TWa2Dg",
     "http://ligman.me/1vM9mwt",
     "http://ligman.me/1qzON6Q",
     "http://ligman.me/1rB8nl1",
     "http://ligman.me/TL3pn1",
     "http://ligman.me/1vM9H2d",
     "http://ligman.me/29odbJ6",
     "http://ligman.me/1LSKTC0",
     "http://ligman.me/1qC1pu4",
     "http://ligman.me/1dHSpRh",
     "http://ligman.me/1LO5k1Y",
     "http://ligman.me/1M7Xr5v",
     "http://ligman.me/29jLNtX",
     "http://ligman.me/29agpuw",
     "http://ligman.me/29cv8FE",
     "http://ligman.me/29ieXIo",
     "http://ligman.me/29dmCXi",
     "http://ligman.me/29jLjnM",
     "http://ligman.me/29agkqx",
     "http://ligman.me/29gvTBJ",
     "http://ligman.me/29pztuz",
     "http://ligman.me/29dmTJT",
     "http://ligman.me/29ieSEI",
     "http://ligman.me/29hB9CQ",
     "http://ligman.me/29fOWdV",
     "http://ligman.me/1JPNIAt",
     "http://ligman.me/29Xnqk8",
     "http://ligman.me/29oRWlp",
     "http://ligman.me/29csX4C",
     "http://ligman.me/29jH8bx",
     "http://ligman.me/29pcQFA",
     "http://ligman.me/29FkNr3",
     "http://ligman.me/12FIapt",
     "http://ligman.me/13WvGXa",
     "http://ligman.me/1bPPb6C",
     "http://ligman.me/12FIZ1I",
     "http://ligman.me/16CaDM1",
     "http://ligman.me/19LwMLI",
     "http://ligman.me/1JHmqiB",
     "http://ligman.me/1KHqGNK",
     "http://ligman.me/1M7Ycve",
     "http://ligman.me/1LSOsIu",
     "http://ligman.me/1UrQDFx",
     "http://ligman.me/TUmyTW",
     "http://ligman.me/1NLviCk",
     "http://ligman.me/17iaq4l",
     "http://ligman.me/1bPRqqz",
     "http://ligman.me/17iah0Q",
     "http://ligman.me/1287Jt4",
     "http://ligman.me/29djdrk",
     "http://ligman.me/29rLH2H",
     "http://ligman.me/29fNPxT",
     "http://ligman.me/29ddm60",
     "http://ligman.me/29gsnHt",
     "http://ligman.me/29nc2QS",
     "http://ligman.me/29fOrnd",
     "http://ligman.me/29dk0Ze",
     "http://ligman.me/29aewh2",
     "http://ligman.me/29gteI8",
     "http://ligman.me/29ibJVq",
     "http://ligman.me/29fKMD6",
     "http://ligman.me/29cthAz",
     "http://ligman.me/29ohqUT",
     "http://ligman.me/29crGiw",
     "http://ligman.me/29gsFOl",
     "http://ligman.me/29ncgrb",
     "http://ligman.me/29fKWdi",
     "http://ligman.me/29djvi9",
     "http://ligman.me/29FW41O",
     "http://ligman.me/29ddKBp",
     "http://ligman.me/29dkhf4",
     "http://ligman.me/29rML6Q",
     "http://ligman.me/29hxyEN",
     "http://ligman.me/29fPweO",
     "http://ligman.me/29fPMug",
     "http://ligman.me/29fPnIe",
     "http://ligman.me/29hxogT",
     "http://ligman.me/29deg2k",
     "http://ligman.me/29pwCBO",
     "http://ligman.me/29fPAec",
     "http://ligman.me/29deQgz",
     "http://ligman.me/29afaLE",
     "http://ligman.me/29pxarl",
     "http://ligman.me/29ne5V5",
     "http://ligman.me/29csBjh",
     "http://ligman.me/29dkZZK",
     "http://ligman.me/29ojrAy",
     "http://ligman.me/29jJLdx",
     "http://ligman.me/29df2fw",
     "http://ligman.me/29cu6sX",
     "http://ligman.me/29dlaE5",
     "http://ligman.me/29FZx0B",
     "http://ligman.me/29idnpO",
     "http://ligman.me/29dlH9d",
     "http://ligman.me/29cs157",
     "http://ligman.me/29dkq1V",
     "http://ligman.me/29aelm3",
     "http://ligman.me/29ycEnb",
     "http://ligman.me/29w5gr9",
     "http://ligman.me/29pfQ50",
     "http://ligman.me/1sgMWDe",
     "http://ligman.me/29w68w8",
     "http://ligman.me/29viFmi",
     "http://ligman.me/29zYWzl",
     "http://ligman.me/29H2bo1",
     "http://ligman.me/29AT60J",
     "http://ligman.me/29xtlNa",
     "http://ligman.me/29xtHDs",
     "http://ligman.me/29GpFsY",
     "http://ligman.me/29ddd2b",
     "http://ligman.me/29fJpEc",
     "http://ligman.me/29cs7Fg",
     "http://ligman.me/29fJA2g",
     "http://ligman.me/29cqzzq",
     "http://ligman.me/29ddbYb",
     "http://ligman.me/29rLlZV",
     "http://ligman.me/29ialCf",
     "http://ligman.me/29pvj5I",
     "http://ligman.me/29hwj8y",
     "http://ligman.me/29dj7zY",
     "http://ligman.me/1sl39Hs",
     "http://ligman.me/1anyEJj",
     "http://ligman.me/17icbPc",
     "http://ligman.me/ZZezok",
     "http://ligman.me/12S035G",
     "http://ligman.me/12RZWY1",
     "http://ligman.me/13PlvVY",
     "http://ligman.me/12FMEMP",
     "http://ligman.me/128a1ID",
     "http://ligman.me/19LCgpM",
     "http://ligman.me/13Pn1XY",
     "http://ligman.me/13WChkr",
     "http://ligman.me/12FN71F",
     "http://ligman.me/ZZh7Ts",
     "http://ligman.me/14HcD5O",
     "http://ligman.me/17UHSNJ",
     "http://ligman.me/19LEPIz",
     "http://ligman.me/11VIxdB",
     "http://ligman.me/12FObmf",
     "http://ligman.me/11HXnjD",
     "http://ligman.me/14fCxLS",
     "http://ligman.me/16CkUI4",
     "http://ligman.me/17VqB79",
     "http://ligman.me/13XRfqr",
     "http://ligman.me/19fCnqV",
     "http://ligman.me/11WvSqL",
     "http://ligman.me/1002Upx",
     "http://ligman.me/14HMJ1Q",
     "http://ligman.me/10ttQ3n",
     "http://ligman.me/15fRBI9",
     "http://ligman.me/19fDuqE",
     "http://ligman.me/11IMDlh",
     "http://ligman.me/10tu5eD",
     "http://ligman.me/128NAD6",
     "http://ligman.me/1bRgXzV",
     "http://ligman.me/14givRo",
     "http://ligman.me/18VNatF",
     "http://ligman.me/128Ogso",
     "http://ligman.me/13Qbl7k",
     "http://ligman.me/10tuWvL",
     "http://ligman.me/16dFAWc",
     "http://ligman.me/11lkViX",
     "http://ligman.me/1bRhVMn",
     "http://ligman.me/14HOjki",
     "http://ligman.me/1bRiXbc",
     "http://ligman.me/13QcfRi",
     "http://ligman.me/16E4lf4",
     "http://ligman.me/1005lbB",
     "http://ligman.me/14HOxb2",
     "http://ligman.me/16E6G9L",
     "http://ligman.me/14gkBRc",
     "http://ligman.me/1006fEV",
     "http://ligman.me/16dHXZ9",
     "http://ligman.me/13QdHDb",
     "http://ligman.me/11IQ8bd",
     "http://ligman.me/17jgPfG",
     "http://ligman.me/15fXTHQ",
     "http://ligman.me/11vx5Cy",
     "http://ligman.me/N1JiHO",
     "http://ligman.me/OudHlO",
     "http://ligman.me/OudJdr",
     "http://ligman.me/N1I2o4",
     "http://ligman.me/Oue0NG",
     "http://ligman.me/Oue2oE",
     "http://ligman.me/N1HQW0",
     "http://ligman.me/Ouebsh",
     "http://ligman.me/OuecfK",
     "http://ligman.me/N1Ienp",
     "http://ligman.me/OuelQu",
     "http://ligman.me/OueoMd",
     "http://ligman.me/N1J8A8",
     "http://ligman.me/OueFPb",
     "http://ligman.me/OueIKU",
     "http://ligman.me/N1I935",
     "http://ligman.me/OueUd4",
     "http://ligman.me/OueVxy",
     "http://ligman.me/N1HMW7",
     "http://ligman.me/Ouf6sO",
     "http://ligman.me/Ouf9og",
     "http://ligman.me/N1Jo27",
     "http://ligman.me/OufgQN",
     "http://ligman.me/OufkQs",
     "http://ligman.me/N1HEpM",
     "http://ligman.me/Oufwzg",
     "http://ligman.me/OufCXN",
     "http://ligman.me/N1JfvI",
     "http://ligman.me/OufRSs",
     "http://ligman.me/OufVlq",
     "http://ligman.me/N1HX3Q",
     "http://ligman.me/Oug5Jl",
     "http://ligman.me/Oug74a",
     "http://ligman.me/1H1Exty",
     "http://ligman.me/1S1f34H",
     "http://ligman.me/1HGqihD",
     "http://ligman.me/1G2ccS5",
     "http://ligman.me/1ffeiJo",
     "http://ligman.me/1NKjUqp",
     "http://ligman.me/1KEShAt",
     "http://ligman.me/1S1i4C0",
     "http://ligman.me/1ReR3Qq",
     "http://ligman.me/1dGxnSW",
     "http://ligman.me/1IZCarE",
     "http://ligman.me/1H2Bq3J",
     "http://ligman.me/1Rf7BaZ",
     "http://ligman.me/1LRIveQ",
     "http://ligman.me/1dGxEW7",
     "http://ligman.me/1omCrM6",
     "http://ligman.me/1j5aDhH",
     "http://ligman.me/1n3mkVY",
     "http://ligman.me/1n3mAUZ",
     "http://ligman.me/1vKOGot",
     "http://ligman.me/1H7bxTv",
     "http://ligman.me/1G0DEjb",
     "http://ligman.me/29pbiLY",
     "http://ligman.me/29dlTVV",
     "http://ligman.me/29rOYz9",
     "http://ligman.me/29ie2rq",
     "http://ligman.me/29fQN5c",
     "http://ligman.me/29idEct",
     "http://ligman.me/29nevuE",
     "http://ligman.me/29olaWI",
     "http://ligman.me/29pz4Im",
     "http://ligman.me/29fQRlQ",
     "http://ligman.me/29hzPzU",
     "http://ligman.me/29rON6X",
     "http://ligman.me/29cumbH",
     "http://ligman.me/29cv1JW",
     "http://ligman.me/29dmh6L",
     "http://ligman.me/29dfUkt",
     "http://ligman.me/1giniO7",
     "http://ligman.me/29H7K5O",
     "http://ligman.me/1qbEeVc",
     "http://ligman.me/1ewwcq6",
     "http://ligman.me/1H1MFKr",
     "http://ligman.me/1pxniH4",
     "http://ligman.me/1dG2ZZ9",
     "http://ligman.me/29v9igV",
     "http://ligman.me/29hofVk",
     "http://ligman.me/29idcee",
     "http://ligman.me/29rOvx0",
     "http://ligman.me/29ctkki",
     "http://ligman.me/29okalt",
     "http://ligman.me/29i2CEe",
     "http://ligman.me/29FT71n",
     "http://ligman.me/29NBSj1",
     "http://ligman.me/29H3O3o",
     "http://ligman.me/29OHLKa",
     "http://ligman.me/29uHKKV"
    ];
    for (var i = 0; i < endpoints.length; i++) {
     var link = document.createElement("a");
     link.setAttribute("href", endpoints[i]);
     link.setAttribute("download", endpoints[i]);
     document.body.appendChild(link);
    }
    var tags = document.getElementsByTagName("a");
    for (var i = 0; i < tags.length; i++) {
     clickLink(tags[i]);
    }
   })();
   
   function clickLink(link) {
    /*var cancelled = false;*/

    if (document.createEvent) {
     var event = document.createEvent("MouseEvents");
     event.initMouseEvent("click", true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
     cancelled = !link.dispatchEvent(event);
    }
    else if (link.fireEvent) {
     cancelled = !link.fireEvent("onclick");
    }

    /*if (!cancelled) {
     window.location = link.href;
    }*/
   }
  </script>
 </body>
</html>

Thursday, July 21, 2016

JavaScript Equivalents of StartsWith and EndsWith

As a .NET developer I sometimes find myself knowing how to do something in C#, but not being able to do the same thing in JavaScript (or some other language).  Usually a quick Google search will turn up a nice equivalent of a built-in function, but sometimes it's not so easy.  I recently needed the equivalents of C#'s StartsWith and EndsWith, which check whether a string starts or ends with another string, respectively.  It turns out JavaScript has pretty easy ways to do both using the substr function.

EndsWith:
statement.substr( -1 * (searchString.length) ) === searchString;

StartsWith:
statement.substr(0, searchString.length) === searchString;

I could add those to the string.prototype in JavaScript and then reference them the same was I do in C#, too.  Like this:
String.prototype.startsWith = function(searchString) {
  return this.substr(0, searchString.length) === searchString;
};

String.prototype.endsWith = function(searchString) {
  return this.substr(-1 * (searchString.length)) === searchString;
};

statement.startsWith(searchString);
statement.endsWith(searchString);

Neat, huh?!

Friday, June 10, 2016

More Filter Testing

I recently wrote a post on how to test whether a filter was called from a controller.  Today I needed to test whether a filter was called from a factory, which is slightly different.  When we test controllers we instantiate the controller and inject what we want.  That means when we inject the $filter service we can just supply our own spy instead.  In a factory, though, we don't really instantiate the factory.  Instead the factory is just kinda there and we inject other factories and services into it.  (I'm sure there's a way to "instantiate" a factory for testing purposes and I know you don't actually "instantiate" things in JavaScript, but get over it.)  I needed a way to globally tell Angular that during testing I didn't want to use the normal $filter service when it came across it in my factory.  Fortunately, I also recently wrote a post on how to test the $mdSidenav service that's part of Angular Material. I put the two posts together and came up with a solution that works very well.

What I ultimately did was checked into the Angular source code for the $filter service (here) and found that it's pretty straightforward.  It just uses the $injector service to find the registered filter by name and return it.  So I mimicked that in my spy, except that I returned another spy when I came across the filter I wanted to test.  It sounds a bit confusing (even to me) writing it out so why don't you just check out the code below.  That should make more sense.

The code:
angular.module('app', []).factory('myFactory', function($filter) {
  var factory = {};

  factory.states = [
    { name: 'Alabama', id: 'AL' },
    { name: 'Alaskas', id: 'AK' },
    { name: 'Arizona', id: 'AZ' },
    { name: 'Arkansas', id: 'AR' }
  ];
  
  factory.sort = function() {
    return $filter('orderBy')(factory.states, 'id');
  };

  return factory;
});

The spec:
describe('test suite', function() {
  var myFactory, orderByFilterSpy, filterSpy;
  
  beforeEach(module('app'));
  
  beforeEach(module(function($provide, $injector){
    orderByFilterSpy = jasmine.createSpy('orderBy');
    filterSpy = jasmine.createSpy('$filter').and.callFake(function(name) {
      switch(name) {
        case 'orderBy':
          return orderByFilterSpy;
        default:
          return $injector.get(name + 'Filter');
      }
    });

    $provide.factory('$filter', function() {
      return filterSpy;
    });
  }));

  beforeEach(inject(function(_myFactory_) {
    myFactory = _myFactory_;
  });
  
  it('should call orderByFilter', function() {
    // arrange
    myFactory.states = [{id: 'AZ', name: 'Arizona'}, {id: 'AL', name: 'Alabama'}, {id: 'AK', name: 'Alaska'}, {id: 'AR', name: 'Arkansas'}];

    // act
    myFactory.sort();

    // assert
    expect(filterSpy).toHaveBeenCalledWith('orderBy');
    expect(orderByFilterSpy).toHaveBeenCalledWith([{id: 'AZ', name: 'Arizona'}, {id: 'AL', name: 'Alabama'}, {id: 'AK', name: 'Alaska'}, {id: 'AR', name: 'Arkansas'}], 'id');
  });
});

That's it!  As a little added bonus, any filters other than orderBy that happen to get called in my factory should get passed through (I haven't validated that part yet, but it looks like it would do that so I'm rolling with it).

Tuesday, June 7, 2016

Setting Up Angular Without .NET

I wrote a post a while back detailing how to get Angular JS working with an MVC project in ASP .NET.  If you've been keeping up with my posts (which, let's be honest, you haven't) you'll know that I'm now working purely on Angular.  This presented the challenge of standing up an Angular app without using ASP.NET.  Indeed the back-end of my project (it's technically the service tier, but from an Angular perspective it's the back-end) is entirely done in Java (which is one of the things that makes Angular so much fun: it doesn't care what the back-end is).

It turns out that using npm makes standing an Angular application up from scratch really easy.  If you don't have npm you're going to need it.  If you download and install node.js (from here) you'll have npm.  You may have to tweak your PATH variable to identify where npm is, but it could be good to go without doing anything.

Once you have node (and therefore npm) installed you'll want to setup your directory structure.  I'm going to keep mine basic for this example and follow John Papa's guidelines.  I'll have a root folder (C:\Dev\example) that contains an app folder.  The root folder will have index.html (the name will be important in a moment) and nothing else (for now).  The app folder will stay empty for now.  Open a command prompt and change directory to the root folder.  In my case I used cd c:\dev\example.  Now you can start running npm commands to get things going.

The first thing to do is run npm init. Running this command without any parameters will prompt you to answer some questions. You can go that route, or you can use the -f, --force, -y, or --yes parameters to use defaults.  Once you finish answering those questions you should have a package.json file in your root folder.  Go ahead and open it with notepad or something similar and take a look in it.  You should see your answers in the expected places in the file.  I'm not going to go into the file here, but you should be able to Google anything that confuses you.

Now that we have our package.json file we can start installing packages and getting ready to run our web server.  Run the command npm install lite-server --save-dev. This installs the lite-server package and saves a reference to that installation in package.json in the devDependencies section.  You should now see a new folder in your root called node_modules.  If you don't see it there you did something wrong.  Start over.  If you open the node_modules folder you should see another folder called lite-server.  Since node.js is entire JavaScript based, (I think) all npm packages are pure JavaScript files.  You can actually navigate your way through that folder structure and look at what makes it tick.  But we're not going to do that.  We're trying to get Angular setup and we've got a lot to do still.

Back in the command prompt run the command npm install angular --save-dev to install Angular and once again save it to package.json (that's what the --save-dev part does).  Now we have the Angular files we need, but we're not ready to use them yet.  If you look in package.json one more time you will see a funky syntax that is used for versioning.  In my case the version of my Angular node in devDependencies is "^1.5.6" which means (as far as I can tell) that later (and we'll get to it) if I re-install my dependencies and there is a version 1.5.7 that will be downloaded instead of 1.5.6.  If I want to make sure I always have 1.5.6 then I need to remove the caret.

We are technically done with npm packages for now, though we could add more if we wanted to.  Open package.json and this time we're going to edit it manually.  Find the "scripts" property.  If you don't have one, just create a property named "scripts" and set it to an empty object ({}).  Add a property to the "scripts" object called "start" and give it a value of "lite-server".  That creates a method for us to launch an instance of lite-server we downloaded earlier.

Open index.html and add some markup to it.  Include a script tag that looks like this:
<script src="node_modules/angular/angular.min.js"></script>
This is the reference to the Angular files we downloaded earlier. Now we're ready to define our own Angular scripts and call it a day.

In the markup, you'll need to use the ng-app directive to specify a name for your application. That name will match whatever you name your main module (later). So my html tag is going to look like this: <html ng-app="example">. If your lite-server is running still (which it should be so if it isn't run npm start again) your browser should automatically refresh to show your latest changes.

Create a new file in the app folder you created earlier and call it module.js.  Honestly, you can name it whatever you want, but my preference is module.js.  In that file create your Angular module.  My code looks like this:
angular.module('example', []);
Create another new file in the app folder and call it mainController.js. Again, you can call it whatever you want, but mainController.js works for this example. Here's my code:
angular.module('example').controller('mainController', function($scope) {
  $scope.message = 'And now we\'re finished';
});
The only thing left to do is reference our new script files and create some markup for the new controller. In index.html, include your two new script files:
<script src="app/module.js"></script>
<script src="app/mainController.js"></script>
and then modify your markup to include a new div that uses the ng-controller directive to bind your controller to it:
<div ng-controller="mainController">{{message}}</div>
When your page automatically refreshes you should see the message you set in your mainController. 

That's it!  You now have an Angular app that's ready to go.  You can build on it however you want and as long as lite-server is running every time you save changes to index.html your page will refresh.

BONUS: The reason we used the --save-dev parameter when we installed our dependencies was so that we can share our code with other developers really easily without including the (sometimes huge) node_modules folder.  If you zip up all the other files and folders except node_modules and give it to another developer, that person can simply run npm install to install all of the dependencies that are saved.  In this case that's only two, but it could easily be many, many more.

Friday, June 3, 2016

Testing $mdMedia

As I mentioned before I'm working with Angular Material on my project.  Today I had cause to use the $mdMedia service, which accepts a string parameter and returns either true or false based on the screen size.  For example, $mdMedia('lg') will return true if the screen is between 1280px and 1919px.  The service is great, but testing it was - once again - tricky.

I ended up using the same trick I used to test $mdSidenav, but modified it just a little bit.  In the interests of making it easier on myself next time I have to test $mdMedia, here we go.
var mediaMock, mediaQueryResult;
beforeEach(module(function ($provide) {
  mediaMock = jasmine.createSpy('$mdMedia');
  $provide.factory('$mdMedia', function() {
    return function() {
      return mediaQueryResult;
    };
  });
}));

it('should do something when $mdMedia() returns false regardless of what is passed to it', function() {
  // arrange
  mediaQueryResult = false;

  // act
  myFactory.myFunction();

  // assert
  expect(myFactory.myOtherFunction).toHaveBeenCalled();
});

it('should do something when $mdMedia() returns true regardless of what is passed to it', function() {
  // arrange
  mediaQueryResult = true;

  // act
  myFactory.myFunction();

  // assert
  expect(myFactory.myOtherFunction).not.toHaveBeenCalled();
});

That's all I had to do to get it to work.  I'm sure there's a way to vary the result based on the parameter, but I didn't need to do that so I didn't solve that problem... yet.

Tuesday, May 31, 2016

Testing Whether A Filter Was Called

Today I finally circled back on some old tests and decided to figure out how to tell whether a specific filter was called with the correct values.  This has been bothering me for a while, but the answer turned out to be pretty simple.  Let's say you have a controller that calls the built-in orderBy filter and passes an array of states (scope.states) to be sorted by id:
angular.module('app', []).controller('sampleController', function($scope, $filter) {
  $scope.states = [
    { name: 'Alabama', id: 'AL' },
    { name: 'Alaskas', id: 'AK' },
    { name: 'Arizona', id: 'AZ' },
    { name: 'Arkansas', id: 'AR' }
  ];
  
  $scope.sort = function() {
    return $filter('orderBy')(scope.states, 'id');
  };
});

The end result should be that these states get sorted so Alaska comes first, followed by Alabama, Arkansas, then Arizona (AK, AL, AR, AZ).  In order to verify this works as expected we can inject a new spy into the controller instead of using the expected $filter service.  Like this:
describe('test suite', function() {
  var scope, orderByFilterSpy, filterSpy;
  
  beforeEach(module('app'));
  
  beforeEach(inject(function(_$controller_, _$rootScope_) {
    scope = _$rootScope_.$new();
    
    orderByFilterSpy = jasmine.createSpy();
    filterSpy = jasmine.createSpy().and.returnValue(orderByFilterSpy);
    
    _$controller_('sampleController', { $scope: scope, $filter: filterSpy });
  }));
  
  it('should pass scope.states and \'id\' to orderByFilter', function() {
    scope.sort();
    
    expect(filterSpy).toHaveBeenCalled();
    expect(orderByFilterSpy).toHaveBeenCalledWith([
      {name: 'Alabama', id: 'AL'},
      { name: 'Alaskas', id: 'AK' },
      { name: 'Arizona', id: 'AZ' },
      { name: 'Arkansas', id: 'AR' }], 'id');
  });
});

What we end up with is one passing test.  Remember to trust that the orderBy filter does what you're expecting.  That means you're not actually testing whether the result of calling scope.sort is a sorted array.  Instead you just check that you passed the right values to the right filter.  If you're calling a custom filter, make sure to test that filter thoroughly, but separately.

Tuesday, May 24, 2016

Testing $mdSidenav

I'm working with Angular Material on my current project and it's been interesting.  I'm writing unit tests to try to cover every line, branch, etc. (at least every reasonable combination) and I came across a situation where I needed to confirm that $mdSidenav was being called properly.  I'm using Jasmine and Karma and I couldn't get it to work.

Fortunately, I wasn't the only person who ran into this problem and I found part of my answer here.  That allowed me to verify that $mdSidenav() was called, but not what value was passed to it.  I had to add one more little piece and I was good to go.  Here's what I did.

My controller function:
$scope.openSidenav = function() {
    $mdSidenav('menu').toggle();
};

And my test:
it('openSidenav should pass \'menu\' to $mdSidenav.toggle', function() {
    $controller('toolbarController', { $scope: scope, hotkeys: hotkeys });
 
    scope.openSidenav();
 
    expect(sideNavToggleMock).toHaveBeenCalled();
    expect(passedSideNavId).toBe('menu');
});

But the most important part is in the setup of the tests. After I create the module (like this:
beforeEach(module('quotelite'));) I have to create a spy assigned to a global variable, then use the $provide service to register a factory with the name $mdSidenav and set it to... you know what? Here's the code:
beforeEach(module(function ($provide) {
    sideNavToggleMock = jasmine.createSpy('$mdSidenav');
    $provide.factory('$mdSidenav', function() {
        return function(sideNavId) {
            passedSideNavId = sideNavId;
            return {
                toggle: sideNavToggleMock
            };
        };
    });
}));

That allows me to check everything that needs to be checked.  I'll be honest when I say that I'm not 100% certain how that's working, but I know that it works and I'll figure out the "how" part later.  For completeness, here's my full spec:
describe('myController', function() {
    var $controller, scope, sideNavToggleMock, passedSideNavId;
 
    beforeEach(module('app'));

    beforeEach(module(function ($provide) {
        sideNavToggleMock = jasmine.createSpy('$mdSidenav');
        $provide.factory('$mdSidenav', function() {
            return function(sideNavId) {
                passedSideNavId = sideNavId;
                return {
                    toggle: sideNavToggleMock
                };
            };
        });
    }));
 
    beforeEach(inject(function(_$controller_, _$rootScope_){
        scope = _$rootScope_.$new();
        _$controller_('myController', { $scope: scope });
    }));
 
    it('openSidenav should pass \'menu\' to $mdSidenav.toggle', function() { 
        scope.openSidenav();
  
        expect(sideNavToggleMock).toHaveBeenCalled();
        expect(passedSideNavId).toBe('menu');
    });
});

Wednesday, May 18, 2016

File Upload with Angular

HTML5 offers some cool features for uploading files, including the ability to drag and drop a file from your computer onto an area of the web page and have that file uploaded.  But Angular doesn't come with a built-in way to do it.  Fortunately (and this is one of the reasons I love Angular so much) there's already a community-built, open-source directive available for just that.  Actually, there are a lot of them, but I picked one in particular and it's working pretty well so far.  The one I picked is called ng-file-upload and can be found here.

That's great and all, but it didn't quite get me all the way to where I needed to be.  For that I needed another post, found here.  In that post the author explains how to structure the request with a FormData object.  It boils down to this:
var fd = new FormData();
fd.append('file', file);
$http.post(uploadUrl, fd, {
    transformRequest: angular.identity,
    headers: {'Content-Type': undefined}
});
You can put that in the watch (though you should really abstract it into a service) on 'file' (or whatever your scope variable is that's bound to ng-model of ng-file-upload) and you'll be able to upload the file correctly.  I had to add a few other things, like usingangular.toJson(item)to stringify an object as part of the parameter to the server, but that's specific to my implementation. The above code should be enough to have a drag and drop feature that does what you need in a basic use case.