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!

No comments:

Post a Comment