11

I am writing a function that can create an email template from a HTML template and some information that is given. For this I am using the $compile function of Angular.

There is only one problem I cannot seem to solve. The template consists of a base template with an unlimited amount of ng-include's. When I use the 'best practice' $timeout (advised here) It works when I remove all the ng-include's. So that is not what I want.

The $timeout example:

return this.$http.get(templatePath)
    .then((response) => {
       let template = response.data;
       let scope = this.$rootScope.$new();
       angular.extend(scope, processScope);

       let generatedTemplate = this.$compile(jQuery(template))(scope);
       return this.$timeout(() => {
           return generatedTemplate[0].innerHTML;
       });
    })
    .catch((exception) => {
        this.logger.error(
           TemplateParser.getOnderdeel(process),
           "Email template creation",
           (<Error>exception).message
        );
        return null;
     });

When I start to add ng-include's to the template this function starts to return templates that are not yet fully compiled (a workarround is nesting $timeout functions). I believe this is because of the async nature of a ng-include.


Working code

This code returns the html template when it is done rendering (function can now be reused, see this question for the problem). But this solution is a big no go since it is using the angular private $$phase to check if there are any ongoing $digest's. So I am wondering if there is any other solution?

return this.$http.get(templatePath)
   .then((response) => {
       let template = response.data;
       let scope = this.$rootScope.$new();
       angular.extend(scope, processScope);

       let generatedTemplate = this.$compile(jQuery(template))(scope);
       let waitForRenderAndPrint = () => {
           if (scope.$$phase || this.$http.pendingRequests.length) {
               return this.$timeout(waitForRenderAndPrint);
           } else {
               return generatedTemplate[0].innerHTML;
           }
        };
        return waitForRenderAndPrint();
    })
    .catch((exception) => {
        this.logger.error(
           TemplateParser.getOnderdeel(process),
           "Email template creation",
           (<Error>exception).message
         );
         return null;
     });

What I want

I would like to have a functionality that could handle an unlimited amount of ng-inlude's and only return when the template has succesfully been created. I am NOT rendering this template and need to return the fully compiled template.


Solution

After experimenting with @estus answer I finally found an other way of checking when $compile is done. This resulted in the code below. The reason I am using $q.defer() is due to the fact that the template is resolved in an event. Due to this I cannot return the result like a normal promise (I cannot do return scope.$on()). The only problem in this code is that it depends heavily on ng-include. If you serve the function a template that doesn't have an ng-include the $q.defer is never resovled.

/**
 * Using the $compile function, this function generates a full HTML page based on the given process and template
 * It does this by binding the given process to the template $scope and uses $compile to generate a HTML page
 * @param {Process} process - The data that can bind to the template
 * @param {string} templatePath - The location of the template that should be used
 * @param {boolean} [useCtrlCall=true] - Whether or not the process should be a sub part of a $ctrl object. If the template is used
 * for more then only an email template this could be the case (EXAMPLE: $ctrl.<process name>.timestamp)
 * @return {IPromise<string>} A full HTML page
*/
public parseHTMLTemplate(process: Process, templatePath: string, useCtrlCall = true): ng.IPromise<string> {
   let scope = this.$rootScope.$new(); //Do NOT use angular.extend. This breaks the events

   if (useCtrlCall) {
       const controller = "$ctrl"; //Create scope object | Most templates are called with $ctrl.<process name>
       scope[controller] = {};
       scope[controller][process.__className.toLowerCase()] = process;
    } else {
       scope[process.__className.toLowerCase()] = process;
    }

    let defer = this.$q.defer(); //use defer since events cannot be returned as promises
    this.$http.get(templatePath)
       .then((response) => {
          let template = response.data;
          let includeCounts = {};
          let generatedTemplate = this.$compile(jQuery(template))(scope); //Compile the template

           scope.$on('$includeContentRequested', (e, currentTemplateUrl) => {
                        includeCounts[currentTemplateUrl] = includeCounts[currentTemplateUrl] || 0;
                        includeCounts[currentTemplateUrl]++; //On request add "template is loading" indicator
                    });
           scope.$on('$includeContentLoaded', (e, currentTemplateUrl) => {
                        includeCounts[currentTemplateUrl]--; //On load remove the "template is loading" indicator

            //Wait for the Angular bindings to be resolved
            this.$timeout(() => {
               let totalCount = Object.keys(includeCounts) //Count the number of templates that are still loading/requested
                   .map(templateUrl => includeCounts[templateUrl])
                   .reduce((counts, count) => counts + count);

                if (!totalCount) { //If no requests are left the template compiling is done.
                    defer.resolve(generatedTemplate.html());
                 }
              });
          });
       })
       .catch((exception) => {                
          defer.reject(exception);
       });

   return defer.promise;
}
Mr.wiseguy
  • 4,092
  • 10
  • 35
  • 67

2 Answers2

3

$compile is synchronous function. It just compiles given DOM synchronously and doesn't care about what's going on in nested directives. If nested directives have asynchronously loaded templates or other things that prevents their content from being available on the same tick, this is not a concern for parent directive.

Due to how data binding and Angular compiler work, there's no distinct moment when DOM can be considered certainly 'complete', because changes may occur in every place, any time. ng-include may involve bindings too, and included templates may be changed and loaded at any moment.

The actual problem here is the decision that didn't take into account how this will be managed later. ng-include with random template is ok for prototyping but will lead to design problems, and this is one of them.

One way to handle this situation is to add some certainty on which templates are involved; well-designed application cannot afford to be too loose on its parts. The actual solution depends on where this template originates from and why it contains random nested templates. But the idea is that used templates should be put to template cached before they will be used. This can be done with build tools like gulp-angular-templates. Or by doing requests prior to ng-include compilation with $templateRequest (which essentially does $http request and puts it to $templateCache) - doing $templateRequest is basically what ng-include does.

Although $compile and $templateRequest are synchronous when templates are cached, ng-include is not - it becomes fully compiled on the next tick, i.e. $timeout with zero delay (a plunk):

var templateUrls = ['foo.html', 'bar.html', 'baz.html'];

$q.all(templateUrls.map(templateUrl => $templateRequest(templateUrl)))
.then(templates => {
  var fooElement = $compile('<div><ng-include src="\'foo.html\'"></ng-include></div>')($scope);

  $timeout(() => {
   console.log(fooElement.html());
  })
});

Generally putting templates in use to cache is the preferable way to get rid of asynchronicity that Angular templates bring to compilation lifecycle - not only for ng-include but for any directives.

Another way is to use ng-include events. This way the application becomes more loose and event based (sometimes it is a good thing but most times it's not). Since each ng-include emits an event, the events need to be counted, and when they are, this means that a hierarchy of ng-include directives has been completely compiled (a plunk):

var includeCounts = {};

var fooElement = $compile('<div><ng-include src="\'foo.html\'"></ng-include></div>')($scope);

$scope.$on('$includeContentRequested', (e, currentTemplateUrl) => {
  includeCounts[currentTemplateUrl] = includeCounts[currentTemplateUrl] || 0;
  includeCounts[currentTemplateUrl]++;
})
// should be done for $includeContentError as well
$scope.$on('$includeContentLoaded', (e, currentTemplateUrl) => {
  includeCounts[currentTemplateUrl]--;

  // wait for a nested template to begin a request
  $timeout(() => {
    var totalCount = Object.keys(includeCounts)
    .map(templateUrl => includeCounts[templateUrl])
    .reduce((counts, count) => counts + count);

    if (!totalCount) {
      console.log(fooElement.html());
    }
  });
})

Notice that both options will only handle asynchronicity that is caused by asynchronous template requests.

Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • Thank you for the answer. Yet I cannot seem to find a way to integrate the second solution into my function (see my topic question). The problem is that the event is never triggerd on any of the events when I set the event watch on my created scope object. Do you have an example how I should integrate this into my function? oh and your plunkr doesn't work. It doesn't give me any html output. – Mr.wiseguy Jun 20 '17 at 06:52
  • The plunk works. It has `console.log` statements. Check the console. I'm not sure what you mean about integration. You need to set up watchers on the scope and call $compile, just that. The order shouldn't matter here but try to set up watchers first. Consider providing a plunk that can recreate the problem if this doesn't work for you. Any way, ng-include is legacy directive since 1.0 and should be avoided if possible because it doesn't conform to current Angular best practices. – Estus Flask Jun 20 '17 at 07:44
  • I just found out that due to the fact that I am using $rootScope.$new() (I do not have any scope in the service) The events are not fired. Do you know why and if $rootScope is causing it, do you know any solution? see http://plnkr.co/edit/ZEVSG7TBpYirR77UDxcF?p=preview – Mr.wiseguy Jun 20 '17 at 11:59
  • I'm not sure what you mean. $rootScope.$new() provides proper hierarchy for a child scope, so events should propagate up the hierarchy. This really depends on what exactly happens in code that stays behind the scenes. You can also try to set up event listeners on root scope with `$rootScope.$on` instead of `scope.$on`, if the things became messed up this may probably help. It's hard to say anything else without seeing how this works in your case. – Estus Flask Jun 20 '17 at 12:05
  • $rootScope.$on() did the trick. The plunkr now works (http://plnkr.co/edit/ZEVSG7TBpYirR77UDxcF?p=preview). Yet it doesn't work in my project. This is due to angular.extend. It seems to mess with the $scope events (events are not fired untill I remove the angular.extend from my code). Do you know any solution for this? – Mr.wiseguy Jun 20 '17 at 13:09
  • Nope, $rootScope.$on isn't necessary there. The point is to maintain proper scope hierarchy. A template should be compiled with same scope where the listeners were set. [`scope`, not `$scope`](http://plnkr.co/edit/iWcxsvnRczD67AhhAr9b?p=preview). Of course, it will work with $rootScope too, but that's antipattern. – Estus Flask Jun 20 '17 at 13:19
  • Yes, `extend` will screw up the scope because it will overwrite all internal properties - Angular keeps them enumberable. All `$...` properties should be skipped during copying. It's not clear why it needs to be extended at all, this is probably XY problem. If you need `processScope` to be inherited - just inherit it with `processScope.$new()`. – Estus Flask Jun 20 '17 at 13:33
1

I think you get stuck by chain the promise and compile event. I followed the serial of your questions and this maybe what you are looking for, the compiled template string with recursive ng-include.

First, we need to define ourself the function to detect when the compile is completed, there are couple ways to achieve that, but the duration checking is my best bet.

// pass searchNode, this will search the children node by elementPath, 
// for every 0.5s, it will do the search again until find the element
function waitUntilElementLoaded(searchNode, elementPath, callBack){

    $timeout(function(){

        if(searchNode.find(elementPath).length){
          callBack(elementPath, $(elementPath));
      }else{
        waitUntilElementLoaded(searchNode, elementPath, callBack);
      }
      },500)


  }

In below example, directive-one is the container element to wrap up all of the output template that I need, so you could change it to what-ever element that you like. By using $q of Angular, I will expose the promise function to capture the output template since it works async.

$scope.getOutput = function(templatePath){


  var deferred = $q.defer();
    $http.get(templatePath).then(function(templateResult){
      var templateString = templateResult.data;
      var result = $compile(templateString)($scope) 


     waitUntilElementLoaded($(result), 'directive-one', function() {

       var compiledStr = $(result).find('directive-one').eq(0).html();
        deferred.resolve(compiledStr);
     })

    })

  return deferred.promise;


  }



  // usage

  $scope.getOutput("template-path.html").then(function(output){
      console.log(output)
    })

TL;DR; My Demo plunker

In extra, if you are using the TypeScript 2.1, you could use async/await to make the code looks more cleaner instead of using callback. It would be something like

var myOutput = await $scope.getOutput('template-path')
Telvin Nguyen
  • 3,569
  • 4
  • 25
  • 39
  • Are you implying that the $compile function is asynchronous but doesn't implement any kind of "done" callback? – Eric MORAND Jun 14 '17 at 12:02
  • 1
    @EricMORAND $compile is an async function that does not have any hooks that can tell you when it is done. It has to do with the fact that elements in the template are also async (example: ng-include) and also do not have any hooks. due to this $compile cannot tell you when it is done. $timeout is recommened, because it adds an event to the end of the browser stack. Most of the time the $compile is done when $timeout is executed. Unfortunately ng-include ruens this due to it being also async and creating events at the end of the browser stack. – Mr.wiseguy Jun 14 '17 at 12:36
  • @Telvin Nguyen, Thank you for your answer. Yet this example does not work for me, due to the fact that I do not know what is imported in the template (how much ng-includes there are). due to this I cannot determine where to place the ID that will tell my function it is done compiling. Also it is using jQuery. A library that I don't have access to in this project. – Mr.wiseguy Jun 14 '17 at 12:36
  • @Mr.wiseguy, thanks for the confirmation. That's what I feared. That's a huge mistake from Angular's team. – Eric MORAND Jun 14 '17 at 12:43
  • @Mr.wiseguy: My example is using jQuery and ID of the container element, they are not required. In fact, the demo is expressing the idea how to resolve this problem. You totally can do the same without jQuery. There are also various of ways to capture the inner HTML of compiled no matter you have idea about what inside (get first element innerHTML like what you are doing). Through your questions, I see you almost get it done, it is just a little bit more. Sorry if this cannot help you out but I do not have enough time to rewrite in another version to illustrate it again :) – Telvin Nguyen Jun 14 '17 at 13:07
  • @TelvinNguyen, thank you for your effort. The reason this is not going to work is due to the fact that the .innerHTML is constantly updated (after every $timeout the .innerHTML is a bit bigger). And the problem is that I do not know the length of the final result. Due to this I cannot check if the innerHTML has any length or is the length of the expected result. – Mr.wiseguy Jun 15 '17 at 05:56