AngularJS : How to pass an object from the directive to transcluded template

3.7k views Asked by At

I have a directive that creates a UI that allows the user to perform a search. The directive wraps content which will transclude and become the template for each individual search result. Something like this:

<search>
  <div class="someStyle" ng-click="selectResult(result)">{{result.Name}}</div>
</search>

I'd like the ng-click to call the selectResult function on the controller's scope, but have the result object come from the directive. How can I accomplish this with an isolated scope in the directive?

3

There are 3 answers

10
Patrick On BEST ANSWER

Instead of using ng-transclude, you can build your own search transclude directive that can be used to put result onto the transcluded scope. For example, your search directive might look something like this with ng-repeat and the search-transclude directive where you want the transcluded content:

.directive("search", function (SearchResults) {
    return {
        restrict: "AE",
        transclude: true,
        scope: {},
        template: '<div ng-repeat="result in results">Search Relevance:' +
        '{{result.relevance}}' +
        //the most important part search-transclude that receives the current
        //result of ng-repeat
        '<div search-transclude result="result"></div></div>',
        link: function (scope, elem, attrs) {
            //get search results
            scope.results = SearchResults.results;
        }
    }
})

Build search transclude directive as follows:

.directive("searchTransclude", function () {
    return {
        restrict: "A",
        link: function (scope, elem, attrs, ctrl, $transclude) {
            //create a new scope that inherits from the parent of the
            //search directive ($parent.$parent) so that result can be used with other
            //items within that scope (e.g. selectResult)
            var newScope = scope.$parent.$parent.$new();
            //put result from isolate to be available to transcluded content
            newScope.result = scope.$eval(attrs.result);
            $transclude(newScope, function (clone) {
                elem.append(clone);
            });
        }
    }
})

The transcluded content will now be able to see selectResult function if it exists in the scope where the search directive was created. Example here.

5
Suren Aznauryan On

Transcluded content will always use the scope in which the directive element resides, i.e. your controller scope. That's why if you want the result argument of selectResult function to get it's value from isolated scope, then you need to establish two way binding between isolated scope's and controller scope's result properties. After setting the result property to desired value in isolated scope the controller's scope result property will be updated to the same value. So , transcluded content will use controller's result which is in sync with isolated scope's result.

1) add resultAttr='result' attribute to directive element.

<search resultAttr='result'> <div class="someStyle" ng-click="selectResult(result)">{{result.Name}}</div> </search>

2) establish two-way binding for result property when you define isolated scope in directive:

scope: { result: "=resultAttr" }

3) set result to some value in directive

5
7stud On

I'd like the ng-click [in the directive] to call the selectResult function on the controller's scope...

  1. To pass functions (or properties) into an isolate scope, you use attributes on the directive tag.

...but have the result object come from the directive [scope].

  1. If you want the contents of a directive tag to have access to the directive's scope, you DON'T use transclude. Specifying transclude: true tells angular NOT to allow the contents of a directive tag to have access to the directive's scope--the opposite of what you want.

To accomplish #1, you could make the user specify the template like this:

  <div ng-controller="MainCtrl">

    <search external-func='selectResult'>
      <div class="someStyle" ng-click="selectResult(result)">{{result.Name}}</div>
    </search>

  </div>

Note, the user needs to add an extra attribute to the <search> tag. Yet, that html may comport better with angular's philosophy that the html should give hints to the developer about what javascript will operate on the elements.

Then you specify the isolate scope like this:

      scope: {
        selectResult: '=externalFunc'
      },

To accomplish #2, don't specify transclude: true in the directive:

var app = angular.module('myApp',[]);

app.controller('MainCtrl', ['$scope', function($scope) {

  $scope.selectResult = function(result) {
    console.log("In MainCtrl: " + result.Name);
  };

}]);

app.controller('DirectiveCtrl', ['$scope', function($scope) {

  $scope.results = [ 
    {Name: "Mr. Result"},
    {Name: "Mrs. Result"}
  ]

}]);

app.directive('search', function() {

  return {
      restrict: 'E',

      scope: {
        selectResult: '=externalFunc'
      },

      template: function(element, attrs) {
      //                    ^       ^
      //                    |       |
      //    directive tag --+       +-- directive tag's attributes

        var inner_div = element.children();
        inner_div.attr('ng-repeat', 'result in results')

        //console.log("Inside template func: " + element.html());

        return element.html();  //Must return a string.  The return value replaces the innerHTML of the directive tag.
      },

      controller: 'DirectiveCtrl'
  }

}]);

The html could provide an even better record of what the javascript does if you make the user specify their template in more detail:

<search external-func='selectResult'>
  <div class="someStyle" 
    ng-click="selectResult(result)"
    ng-repeat="result in results">{{result.Name}}
  </div>
</search>

But if you insist on the minimalist html:

<search>
  <div class="someStyle" ng-click="selectResult(result)">{{result.Name}}</div>
</search>

...then you can dynamically add the ng-repeat attribute(as shown above), and it's also possible to dynamically map an external function to the isolate scope:

var app = angular.module('myApp',[]);

app.controller('MainCtrl', ['$scope', function($scope) {

  $scope.selectDog = function(result) {
    console.log("In MainCtrl: you clicked " + result.Name);
  };

  $scope.greet = function(result) {
    console.log('MainCtrl: ' + result.Name);
  };

}]);

app.controller('DirectiveCtrl', ['$scope', function($scope) {

  $scope.results = [ 
    {Name: "Mr. Result"},
    {Name: "Mrs. Result"}
  ]

}]);

app.directive('search', function() {

  return {
    restrict: 'E',

    scope: {
      externalFunc: '&externalFunc'  //Cannot write => externalFunc: '&'
    },                               //because the attribute name is
                                     //'external-func', which means
                                     //the left hand side would have to be external-func.
    template: function(element, attrs) {
      //Retrieve function specified by ng-click:
      var inner_div = element.children();
      var ng_click_val = inner_div.attr('ng-click'); //==>"selectResult(result)"

      //Add the outer_scope<==>inner_scope mapping to the directive tag:
      //element.attr('external', ng_click_val); //=> No worky! Angular does not create the mapping.
      //But this works:
      attrs.$set('externalFunc', ng_click_val) //=> external-func="selectResult(result)"
      //attrs.$set('external-func', ng_click_val); //=> No worky!

      //Change ng-click val to use the correct call format:
      var func_args = ng_click_val.substring(ng_click_val.indexOf('(')); //=> (result)
      func_args =  func_args.replace(/[\(]([^\)]*)[\)]/, "({$1: $1})"); //=> ({result: result})
      inner_div.attr('ng-click', 'externalFunc' + func_args); //=> ng-click="externalFunc({result: result})"

      //Dynamically add an ng-repeat attribute:
      inner_div.attr('ng-repeat', 'result in results')

      console.log("Template: " + element[0].outerHTML);
      return element.html();
    },

    controller: 'DirectiveCtrl'
  }
})

If you want to call the external function with more than one argument, you can do this:

var app = angular.module('myApp',[]);

app.controller('MainCtrl', ['$scope', function($scope) {

  $scope.selectResult = function(result, index) {
    console.log("In MainCtrl: you clicked " 
                 +  result.Name 
                 + " " 
                 + index);
  };

}]);

app.controller('DirectiveCtrl', ['$scope', function($scope) {

  $scope.results = [ 
    {Name: "Mr. Result"},
    {Name: "Mrs. Result"}
  ]

}]);

app.directive('search', function() {
  return {
    restrict: 'E',

    scope: {
      external: '='
    },

    template: function(element, attrs) {
      //Extract function name specified by ng-click:
      var inner_div = element.children();
      var ng_click_val = inner_div.attr('ng-click'); //=>"selectResult(result, $index)"
      var external_func_name =  ng_click_val.substring(0, ng_click_val.indexOf('(') ); //=> selectResult
      external_func_name = external_func_name.trim();

      //Add the outer_scope<==>inner_scope mapping to the directive tag:
      //element.attr('externalFunc', ng_click_val); => No worky!
      attrs.$set('external', external_func_name);  //=> external="selectResult"

      //Change name of ng-click function to 'external':
      ng_click_val = ng_click_val.replace(/[^(]+/, 'external');
      inner_div.attr('ng-click', ng_click_val);

      //Dynamically add ng-repeat to div:
      inner_div.attr('ng-repeat', 'result in results');

      console.log("Template: " + element[0].outerHTML);
      return element.html();
    },

    controller: 'DirectiveCtrl'
  }
});