Friday, March 27, 2015

Angular Scopes and Factories

If you checked out my last post you know I needed to open a jQuery UI dialog from Angular.  This ultimately led me down a long and winding path to Angular factories and creating my own scope.

First the problem, in full detail.  I needed a link that would open a jQuery UI dialog to a form that needed rich controls (show/hide based on data and whatnot).  We'll call that dialog Details.  The Details dialog has a button that opens a History Summary dialog, which also needed rich controls.  One of the rich controls was a link that would open a History Details dialog.  The History Details dialog would look and behave exactly the same as the Details dialog except that the History Details dialog would not have the button that opens a History Summary.  So.  Link A opens Dialog 1, which opens Dialog 2, which has Link B, which opens Dialog 3, which is exactly like Dialog 1.

All of that convolutedness (yes, it's a word, or it is now anyway) had me really scratching my head over how to deal with all the scopes I was creating, not to mention all the new DOM elements I was creating.  I ended up writing a Directive (for the link), a Controller (for the rich controls on the Details and History Summary dialogs), a Service (to retrieve the markup to render, and the data to populate), and a Factory (to handle the scopes and actually opening the dialogs).  Yup, I'm pretty awesome.

First, the Directive:
myApp.directive('dialogLink', ['dialogFactory',
        function (dialogFactory) {
            return {
                // restrict this directive to be used as an element only
                restrict: 'E',
                require: '?ngModel',
                scope: {
                    "ngModel": "="
                },
                link: function (scope, element, attr) {
                    // watch the attribute so that once it populates, we render the value to the screen
                    scope.$watch(function () { return attr.displayValue; }, function (value) {
                        // only render the value if it's an actual value
                        if (value != undefined) {
                            scope.DisplayValue = value;
                        }
                    });
                    // bind the click event to open the dialog
                    element.bind("click", function (e) {
                        dialogFactory.open(attr);
                        
                        e.preventDefault();
                        e.stopPropagation();
                    });
                },
                template: "<a href='#'>{{DisplayValue}}</a>"
            };
        }
  ]
);

Use it like this:
<dialog-link display-value="{{SomeProperty}}"></dialog-link>

Now the Controller:
myApp.controller('dialogController', ['$scope', '$compile', 'dialogService', 'dialogFactory',
        function ($scope, $compile, dialogService, dialogFactory) {
            var historySummaryDialog;

            $scope.CloseHistorySummaryDialog = function() {
                historySummaryDialog.dialog('close');
            };

            $scope.CloseDialog = function () {
                dialogFactory.close($scope);
            };
            
            $scope.Initialize = function () {
                // get the detail data
                dialogService.getDetailData().then(function (data) {
                    $scope.Data = data;
                });
            };
            
            $scope.ShowHistorySummary = function () {
                var historyTitle = "History Summary Dialog";                
                dialogService.getHistorySummaryMarkup().then(function (markup) {
                    // compiles the current scope into the history summary dialog so the current scope ($scope) is used for the history summary dialog as well as the current dialog
                    historySummaryDialog = $compile(markup)($scope);
                    historySummaryDialog.dialog({
                        open: function() {
                            $(this).css({ 'max-height': dialogService.MaxDialogHeight, 'overflow-y': 'auto' });
                        },
                        width: dialogService.DialogWidth,
                        maxWidth: 1000,
                        height: 'auto',
                        fluid: true,
                        title: historyTitle,
                        modal: true,
                        closeOnEscape: true
                    });
                });
            };

            if (!$scope.PreventInit) {
                $scope.Initialize();
            }
        }
    ]
);

And the Service:
myApp.service('dialogService', ['$http', '$q', '$rootScope',
        function ($http, $q, $rootScope) {
            this.DialogWidth = $(window).width() * .8;
            this.MaxDialogHeight = $(window).height() * .8;
            
            this.getDetailData = function () {
                var deferred = $q.defer();

                $http({
                    url: "/GetDetailData",
                    method: "GET"
                })
                    .success(deferred.resolve)
                    .error(deferred.reject);

                return deferred.promise;
            };
            
            this.getDetailMarkup = function () {
                var deferred = $q.defer();

                $http({
                    url: "/Pages/Dialog/Detail.html",
                    method: "GET",
                    cache: true
                })
                    .success(deferred.resolve)
                    .error(deferred.reject);

                return deferred.promise;
            };
            
            this.getHistoricDetailData = function () {
                var deferred = $q.defer();

                $http({
                    url: "/GetHistoricDetailsData",
                    method: "GET"
                })
                    .success(deferred.resolve)
                    .error(deferred.reject);

                return deferred.promise;
            };
            
            this.getHistorySummaryMarkup = function () {
                var deferred = $q.defer();

                $http({
                    url: "/Pages/Dialog/HistorySummary.html",
                    method: "GET",
                    cache: true
                })
                    .success(deferred.resolve)
                    .error(deferred.reject);

                return deferred.promise;
            };
        }
    ]
);

And finally, the pièce de résistance, the Factory:
myApp.factory('dialogFactory', ['dialogService', '$compile', '$rootScope', '$controller', 
        function (dialogService, $compile, $rootScope, $controller) {
            return {
                open: function (attr) {
                    dialogService.getDetailMarkup().then(function (markup) {
                        var title;
                        // create a new scope manually (instead of allowing the ng-controller directive in the markup do it
                        var scope = $rootScope.$new();
                        // prevent the data from being retrieved twice
                        scope.PreventInit = true;

                        var dataPromise;
                        
                        if (attr.getHistory == undefined) {
                            dataPromise = dialogService.getDetailData();
                            title = "Details";
                        } else {
                            dataPromise = dialogService.getHistoricDetailData();
                            title = "History Details";
                        }
                        
                        dataPromise.then(function (data) {
                            scope.Data = data;

                            // instantiate a new instance of the controller
                            var controller = $controller('dialogController', { $scope: scope });
                            // bind the controller to the markup
                            $(markup).children().data('$ngControllerController', controller);
                            // compile the new scope against the markup
                            scope.Dialog = $compile(markup)(scope);
              
                            scope.Dialog.dialog({
                              width: dialogService.DialogWidth,
                              maxWidth: 1000,
                              height: 'auto',
                              fluid: true,
                              title: title,
                              modal: true,
                              closeOnEscape: true
                            });                            
                        });
                    });
                },
                close: function (scope) {
                    // destroy the jQuery UI dialog
                    scope.Dialog.dialog('destroy');
                    // remove the dynamically injected DOM element
                    $('[ng-controller="dialogController"]').each(function (index) {
                        var element = $('[ng-controller="dialogController"]')[index];
                        if (element != null && angular.element(element) != null && angular.element(element).scope().$id == scope.$id) {
                            element.remove();
                        }
                    });
                    // destroy the scope
                    scope.$destroy();
                }
            };
        }
    ]
);

There's so much fun going on here that I'm probably going to explain it all in another (or several) posts. I also forgot to mention earlier that the contents of the Details Dialog (aka Dialog 1) also have to be available outside of a dialog. That's where the Initialize function and PreventInit property come in to play. By specifying PreventInit when I create the scope myself in the factory, I can stop the data from being loaded during the Initialize function. But when the markup includes the ng-controller directive to create a new scope (for the standalone implementation of the Details Dialog) that property isn't set so the Initialize function gets called and data is retrieved. I'm very pleased with myself for figuring this one out. And it only took me a full day!

Debug Test

I upgraded to Visual Studio 2012 Ultimate a few weeks ago so I could generate a couple of UML diagrams from the code I had already written (yes, I know that's a backward process, leave me alone).  Anyway, after I upgraded I found that I couldn't build my solution anymore because of the test projects I had.  Everything worked fine before I upgrade, then didn't work fine after I upgraded.

It turns out the fix was pretty simple.  I found it here, and reproduced the answer in my blog in case for some reason Stack loses the answer.

"I was getting the same output after upgrading a test project from VS 2010 to VS 2012 Ultimate Update 3. The message was displayed in Test Output window after using MSTest command to Debug Selected Tests.
I tried to debug tests using Resharper 8 Unit Test Session window. The message in the result window was "Test wasn't run".
The solution that helped me was to modify the test project settings to enable native code debugging as instructed at this link: Uncaught exception thrown by method called through reflection
In case the link does not work:
  1. Go to the project right click and select properties.
  2. Select 'Debug' tab on the left.
  3. Go to ‘Enable Debuggers’ on the bottom
  4. Check ‘Enable Native code debugging’ (or 'Enable unmanaged code debugging', depends on version) check box
Thanks to GalDude33 for posting the solution."
And the answer I just copy/pasted was posted by Branko on October 3, 2013.