Monday, January 12, 2015

AngularJS Transclusion with nested directives with dynamic templates

Recently I was working on a nested directive, i.e., having a directive inside another directive. In my scenario the typical ng-transclude does not work due to the dynamic templates. Hence I used the transclude function, but still I had to spend some time to get this working. The fix was simple. Below is my problem and my solution for using transclusion in a nested directives with dynamic templates.

On a high level my requirement is developer will be send an array of objects and their types. Based on the type in the edit mode I need to show different UI controls, such as textbox, select, textarea etc. When in the view (non-edit) mode I have to show the text they pass to me (transclusion). Below is the html for my directive.

<notes-input input-list="[{model: vm.model.Compliant, type: 'text'}
                            , {model: vm.model.DOB, type: 'date'}
                            , {model: vm.model.Mentation, type:'dropdown'}
                            , {model: vm.model.Summary, type: 'textarea'}]">
    {{vm.model.Mentation.Value}}
</notes-input>

With the above html, I have to show the Mentation value in the view mode or show the UI controls in the edit mode. I am using xeditable for this and it is going a good job.

I decided to use two directive, one for looping through the array of objects and another which shows different controls based on the object type. The parent directive is easy and straight forward. Here is the code I used:

angular.module('pos').directive('notesInput', notesInput);
notesInput.$inject = [];

function notesInput() {
    return {
        restrict: 'E',
        transclude: true,
        scope: {
            inputList: '=',
        },
        template: '<notes-input-item ng-repeat="item in inputList" item="item">'
                + '<span ng-if="$first" ng-transclude></span>'
                + '</notes-input-item>'
    }
}

As you use I am using ng-repeat to loop through the array and invoking the child directive, notesInputItem. I am also doing a transclusion only for the first item using ng-if. This is straight forward.

In the child directive, based on the type I am dynamically creating the controls. Hence I cannot use the template property of the directive definition object. I am dynamically generating the template html and then compiling it to generate the html as shown below:

    var elementHtml = getTemplate(scope.item); //based on the item type creating the control
    element.html(elementHtml);
    $compile(element.contents())(scope);

This compilation does not understand the ng-transclude at the parent level. So I used transclude function and in that attaching the transclusion to the element as shown below

    transclude(scope, function (clone, scope) {
        element.children().append(clone);
    });

With the above code, I don’t see the transclusion text in the browser. Angular is unable to bind the transclusion. After spending some time I understood that I need to specify the parent directive scope instead of child’s.

    transclude(scope.$parent, function (clone, scope) {
        element.children().append(clone);
    });

When parent scope is specified for the transclusion, Angular was able to bind the transcluded content and the directive performed as expected.
Here is the entire code for my directives:

'use strict'

angular.module('pos').directive('notesInput', notesInput);
notesInput.$inject = [];

function notesInput() {
    return {
        restrict: 'E',
        transclude: true,
        scope: {
            inputList: '=',
        },
        template: '<notes-input-item ng-repeat="item in inputList" item="item">'
                + '<span ng-if="$first" ng-transclude></span>'
                + '</notes-input-item>'
    }
}


angular.module('pos').directive('notesInputItem', notesInputItem);
notesInputItem.$inject = ['$compile', 'session', 'constants'];

function notesInputItem($compile, session, constants) {
    return {
        restrict: 'E',
        transclude: true,
        replace: true,
        scope: {
            item: '=',
        },
        link: link
    }

    function getTemplate(item) {
        var editableSpan = '<span'

        switch (item.type) {
            case "textarea":
                editableSpan += ' editable-textarea="item.model.Value" e-rows="3"'
                break;
            case "dropdown":
                item.model.selectoptions = session.lookups[item.model.LookUpFieldName]
                editableSpan += ' editable-select="item.model.Value" e-ng-options="lookup.FieldDescription as lookup.FieldValue for lookup in item.model.selectoptions"'
                break;
            case "date":
                editableSpan += ' editable-bsdate="item.model.Value"'
                break;
            default:
                editableSpan += ' editable-text="item.model.Value"'
        }

        editableSpan += ' e-name="{{item.model.Name}}"></span>'
        return editableSpan;
    }

    function link(scope, element, attrs, nullController, transclude) {

        var elementHtml = getTemplate(scope.item); //based on the item type creating the control
        element.html(elementHtml);
        $compile(element.contents())(scope);
        transclude(scope.$parent, function (clone, scope) {
            element.children().append(clone);
        });
    }

}