Stephan Samuel
Stephan Samuel

Reputation: 697

Angular 1.6 component transclusion scope

I'm trying to figure out how to get data into a component transclusion in Angular 1.6.4. The scenario has a component, a directive (not re-written as a component yet) and a service for inter-component communication.

angular.module('app')

  .service('svc', function() {
    this.connector = {};
  })

  .directive('first', ['svc', function($svc) { return {
    restrict: 'E',
    scope: { 'id': '@' },
    template: '<button ng-click="GetData()">get data</button>',
    controller: ['$scope', 'svc', function($scope, $svc) {
      $scope.connector = { data: [] };
      $svc.connector[$scope.id] = $scope.connector;
      $scope.GetData = function() {
        // This is a mock-up; I'm really doing a REST call.
        $scope.connector.data = [
          {id: 0, name: 'one'},
          {id: 1, name: 'two'}
        ];
      };
    }]    
  }; }])

  .component('second', {
    bindings: { parent: '@firstid' },
    transclude: true,
    template: '<ng-transclude></ng-transclude>',
    controller: ['svc', function($svc) {
      this.data = $svc.connector[this.parent];
      // Not sure what to do here
    }]
  })

;

My HTML looks something like this:

<first id="first-thing"></first>
<second firstid="first-thing">
  Where I expect my data to be: {{$ctrl | json}}<br/>
  ... but maybe here: {{$ctrl.$parent | json}}<br/>
  ... or even here: {{$parent | json}}<br/>
  <div ng-repeat="item in $ctrl.data">
    <p>Output: {{item.id}}/{{item.name}}</p>
  </div>
</second>

These may not be nested with a require, which is why I'm using a service to store my data; <first><second></second></first> is not an option. I can manage getting data from the service inside the component controller using some $onInit workarounds where necessary. I've checked and the service contains the correct data at the correct times. In the interest of component reuse, I need the controller to transclude content.

Batarang lists all my scopes. The directive has a scope, $id 6 (there are other things on the page), as expected. The component has a scope, $id 7, as expected. These scopes contain the correct data based on what I've put in them and what I'd expect.

My problem is that I have an additional scope, $id 8. It appears to be the transcluded scope and it is a sibling of 6 and 7 (these are peers on $id 5, my page controller). As noted in my HTML snark, I expected the component transclusion to live in 7. I would be fine if 8 was a child scope of 7, but it's a disconnected sibling. I tried additional bindings but I can't get them to populate so they just throw. I'm clearly doing something wrong because what I'm getting is the model that pre-1.3 used for transclusion scope inheritance.

Can someone tell me where I've gone astray or at least point me towards the correct solution?

Upvotes: 0

Views: 612

Answers (1)

Stephan Samuel
Stephan Samuel

Reputation: 697

I've figured it out. In passing, I should note that according to the literature on the Internet, I'm doing something that I probably shouldn't do. I understand where the authors of Angular are coming from with trying to isolate scopes down the chain but I don't agree with that model, at least for transclusion.

angular.module('app')

  .service('svc', function() {
    this.connector = {};
  })

  .directive('first', ['svc', function($svc) { return {
    restrict: 'E',
    scope: { 'id': '@' },
    template: '<button ng-click="GetData()">get data</button>',
    controller: ['$scope', 'svc', function($scope, $svc) {
      $scope.connector = { data: [] };
      $svc.connector[$scope.id] = $scope.connector;
      $scope.GetData = function() {
        // This is a mock-up; I'm really doing a REST call.
        $scope.connector.data = [
          {id: 0, name: 'one'},
          {id: 1, name: 'two'}
        ];
        $scope.connector.data.Update($scope.connector.data);
      };
    }]    
  }; }])

  .component('second', {
    bindings: { parent: '@firstid' },
    transclude: true,
    template: '<ng-transclude></ng-transclude>',
    controller: ['$element', '$transclude', '$compile', 'svc', function($element, $transclude, $compile, $svc) {
      this.$onInit = () => { angular.extend(this, $svc.connector[this.parent]; };
      var parentid = $element.attr('firstid');
      $transclude((clone, scope) => {
        $svc.connector[parentid].Update = (data) => {
          angular.extend(scope, data);
          $element.append($compile(clone)(scope));
        };
      });
    }]
  })

;

How it works

This is essentially manual transclusion. There are too many examples on the Internet about manual transclusion where people modify the DOM manually. I don't completely understand why some people think this is a good idea. We jump through so many hoops to separate our markup (HTML) from our formatting (CSS) from our code (Angular directives/components) from our business logic (Angular services/factories/providers), so I'm not going to go back to putting markup inside my code.

I found this article and a comment on an Angular issue by Gustavo Henke that used the scope inside $transclude to register a callback. With that key bit of information, I figured I could do much more scope manipulation.

The code in $transclude seems to be outside the digest cycle. This means that anything touched inside it will not receive automatic updates. Luckily, I have control of my data's change events so I pushed through this callback. On the callback, the data are changed and the element is recompiled. The key to locate the callback in the service hasn't been bound from the controller tag yet so it has to be retrieved from the attributes manually.

Why this is bad

Components are not supposed to modify data outside their own scope. I am specifically doing exactly not-that. Angular doesn't seem to have a more appropriate primitive for doing this without breaking some other concern that's more important to leave intact, in my mind.

I think there's a, "memory leak," in this, which is to say that my element and scope aren't being disposed of correctly with each update cycle. Mine uses fairly little data, it is updated only directly by the user with a throttle and it's on an administration interface; I'm okay with leaking a little memory and I don't expect the user will stay on the page long enough for it to make a difference.

My code all expects things to be in the right place and named the right things in the markup. My real code has about four times as many lines as this and I'm checking for errors or omissions. This is not the Angular way which means I'm probably doing something wrong.

Credits

Without the Telerik article, I would have been sitting next to an even bloodier mark on my wall right now.

Thanks to Ben Lesh for his comprehensive post about $compile with appropriate disclaimers about how one shouldn't use it.

Todd Motto helped a bunch with how to write a decent Angular 1.x component in his post on upgrading to 1.6. As one may expect, the Angular documentation on components doesn't offer much more than specific pointers to exactly what things are called.

There's a little information at the bottom of AngularJS issue 7842 that does something similar and may even have a better method for managing scoped data more appropriately than I did.

Upvotes: 1

Related Questions