Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

feat(ngAsDirective): new as directive to publish component controllers into current scope #14080

Closed
wants to merge 5 commits into from

Conversation

drpicox
Copy link
Contributor

@drpicox drpicox commented Feb 18, 2016

What kind of change does this PR introduce? (Bug fix, feature, docs update, ...) Feature.

What is the current behavior? (You can also link to an open issue here):
With restrict you can reference parent controllers from a children, but if you want to reference children components you need to find them with jqLike and then get the controller.

What is the new behavior (if this is a feature change)?:
It adds a new directive that assigns the controller of the current DOM element component to any expression. If the element is destroyed (ex: it was inside ngIf or ngRepeat) it assigns a null.

<toggle ng-as="burger"></toggle>
<menu ng-if="burger.isVisible"></menu>
...
<countdown-timer ng-as="$ctrl.countdownTimer"></countdown-timer>
<button ng-click="$ctrl.relaunch()"></button>

Does this PR introduce a breaking change?
No.

Please check if the PR fulfills these requirements

Other information:

I present in this PR the implementation of a directive that I use in almost all my projects (and other projects in which I'm consultant) since the last year.

Basic use

It is really simple: it publishes the controller of component in the current scope. Ex:

<toggle ng-as="myToggle"></toggle>
<button ng-click="myToggle.toggle();">Toggle</button>
<p ng-show="myToggle.isOpened()">The toggle is active</p>

so it can be accessed from enclosing components.
Until this point, it is a kind of custom controllerAs property for parent scope.

Using with parent components

In addition, as following the recommendation did by @petebacondarwin in #10007 (comment) it is able to assign it to any value, event enclosing controller. Ex:

<toggle ng-as="$ctrl.myToggle"></toggle>

Keep track of DOM elements

It also serves to look for exact DOM elements instead of relaying in jqLite searches. In case that there is no a controller, the as directive assigns the current DOM element to the expression set.

    <br>From <a href="https://developer.mozilla.org/en/docs/Web/HTML/Element/audio">here</a>.<br>
    <player></player>
    <script>
        angular.module('demo').component('player', {
            template: '' +
                '<audio as="$ctrl.audio" src="http://developer.mozilla.org/@api/deki/files/2926/=AudioTest_(1).ogg"></audio>' +
                '<button ng-click="$ctrl.play()">Play</button>' +
                '',
            controller: function() {
                this.play = function() {
                    this.audio.play();
                };
            },
        });
    </script>

Deprecating ngController

Why?

ngController is great for SEO webpages, but it has the inconvenience that it does not integrates well with bindings and many things have to be done "manually". For example:

    <script>
        angular
            .module('demo', [])
            .service('booksService', function() {
                this.getBook = function(id) {
                    return { name: 'name'+id, abstract: 'abstract...'+id };
                };
            });
    </script>

    <div ng-controller="BookDetailController as bookDetailCtrl" book-id="1">
        <h1>{{bookDetailCtrl.book.name}}</h1>
        <p>{{bookDetailCtrl.book.abstract}}</p>
    </div>
    <script>
        angular.module('demo').controller('BookDetailController', function(booksService,$attrs) {
            this.book = booksService.getBook($attrs.bookId);
        });
    </script>

In this example it needs to access to $attrs manually to find the bookId. You can imagine more complex functionalities, but I think that this is just enough to expose.

Now, imagine that we do a directive to handle the same behaviour:

    <static-book-detail book-id="2">
        <h1>{{bookDetailCtrl.book.name}}</h1>
        <p>{{bookDetailCtrl.book.abstract}}</p>
    </static-book-detail>
    <script>
        angular.module('demo').directive('staticBookDetail', function() {
            return {
                scope: true,
                controller: function(booksService) {
                    this.book = booksService.getBook(this.bookId);
                },
                controllerAs: 'bookDetailCtrl',
                bindToController: {
                    bookId: '@',
                },
            };
            this.book = booksService.getBook($attrs.bookId);
        });
    </script>

It has a conceptual problem: you must know by heart that static-book-detail publishes the bookDetailCtrl.

Alternatively you can use a component, which always use $ctrl like:

    <p>Does not works as expected:</p>
    <static-book-component book-id="3">
        <h1>{{$ctrl.book.name}}</h1>
        <p>{{$ctrl.book.abstract}}</p>
    </static-book-component>
    <script>
        angular.module('demo').component('staticBookComponent', {
            bindings: {
                bookId: '@',
            },
            controller: function(booksService) {
                this.book = booksService.getBook(this.bookId);
            },
        });
    </script>

but this naive solution does not works because $ctrl is not defined in the same scope that code relies.

Popossal

Finally, using as you can have this solution that seems cleaner and more easy to understand:

    <p>It dows works (and no implicit):</p>
    <static-book-component ng-as="$ctrl" book-id="4">
        <h1>{{$ctrl.book.name}}</h1>
        <p>{{$ctrl.book.abstract}}</p>
    </static-book-component>

    <script>
        angular.module('demo').component('staticBookComponent', {
            bindings: {
                bookId: '@',
            },
            controller: function(booksService) {
                this.book = booksService.getBook(this.bookId);
            },
        });
    </script>

Additional Notes: ngAs directive behaviour is very close to Angular2 # template variables. It also copies concepts from RiotJS and Polymer (at least first versions).

@drpicox drpicox changed the title New feature request: as directive feat(asDirective): new as directive to publish component controllers into current scope Feb 18, 2016
expect(scope.$ctrl.undamaged).toBe(true);
expect($ctrl.myComponent).not.toBeUndefined();
expect($ctrl.myComponent && $ctrl.myComponent.property).toBe(expected);
scope.$destroy();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to destroy the scope (or dealoc the element, I believe).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ops! Right! Each test have a new shiny $rootScope, so not even new required.

About dealoc a I do not have clear if I must add or remove it, I found the following:

And tests from:

  • angular.js/test/ng/compileSpec.js

    Lines 10001 to 10015 in 1061c56

    it('should register a directive', function() {
    angular.module('my', []).component('myComponent', {
    template: '<div>SUCCESS</div>',
    controller: function(log) {
    log('OK');
    }
    });
    module('my');
    inject(function($compile, $rootScope, log) {
    element = $compile('<my-component></my-component>')($rootScope);
    expect(element.find('div').text()).toEqual('SUCCESS');
    expect(log).toEqual('OK');
    });
    });

    are using the global "element" variable cleaned by:
  • dealoc(element);

But I'm not sure if the main afterEach is executed after the whole describe, or after each piece. Or may be it is a bug.

But if you are confident that it is not necessary I'll remove it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I'm not confident, that's why I wrote "(or dealoc the element, I believe)". TBH, when dealoc is needed and when it isn't is still a big mystery for me :).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some investigation it seems that testabilityPatch applies an afterEach to all tests, that automatically cleans up $rootElement and $rootScope. This means that you don't have to manually call dealoc if at least one of the following conditions holds:

  1. Any elements you create are in the DOM tree of the $rootElement (e.g. you call $rootElement.append(yourCreatedElement)).
  2. No other scope than $rootScope is sued for linking.

E.g. in this test, if you keep using $rootScope.$new(), then you need to dealoc (either the element or the scope) - yup, scopes can be dealoc'd too 😃
If you switch to using $rootScope, then I think you don't need dealocing.

The good thing is that if you don't clean up properly, you'll get some warnings in the console, so you know something's wrong.

@gkalpak
Copy link
Member

gkalpak commented Feb 19, 2016

This is interesting, but still needs some work imo.

A few thoughts:

  1. It seems quite similar to ng2's var/# for defining template-local variables.
    Maybe it's a good idea to rename it to var, maybe it isn't (because it's not exactly the same).
  2. It's not a good idea to expose DOM elements in the template.
    (It's different in ng2, because there is better isolation there - no scope hierarchy).
  3. It doesn't clean up the scope when the component is destroyed/removed.

@drpicox
Copy link
Contributor Author

drpicox commented Feb 19, 2016

Thanks for aaaaalllll the feedback!

About thoughts:

  1. Naming:
    • I originally named it id (like old polymer versions or riotjs), but I found that some of my colleagues found it confusing (in addition to any problems with the actual meaning of id attribute)
    • I tried var for a while, as you suggested, but it semantically seems to mean a new variable inside the scope, not accessible from controller. I tried variants prop , let , ... and at the end it was more confusing. What happens when I want to use it inside a ngRepeat, what happens when I want to cross a scope and register it into the controller or "main template scope", ...
    • Finally I started to use the as notation, my colleagues found it very straightforward, and it seems to go in the same direction that controllerAs. In addition, as explained before, thanks to Feature Request: angular.component helper #10007 (comment) I created a explicit version which fits to all cases: set into controller, local variable inside ngRepeat, and so on.
  2. DOMElements: removed, you're right endeed.
  3. Destroyed/Removed: Ops! Thanks again! Never found the case before, but you're completely right. ¿What happens when the element is destroyed? (for example inside an ngIf) If it is recreated again it is all right, but if it is removed, it stops working properly. I'll add the test case and then nullify the controller assignation.

Thanks a lot for your feedback! 😄

@petebacondarwin
Copy link
Contributor

Although I really quite like the idea, and I can see a number of potentially nice uses for it, such as comparison validators, such as password confirmation, I am really nervous about how easy it would be to cause memory leaks, if one of these references was held on to after the DOM element containing the original value was destroyed.

I think that Angular 2 can get away with this because its vars are only available to the containing template. Is that right or do they have additional mechanisms in place?

@petebacondarwin petebacondarwin modified the milestones: Backlog, Purgatory Feb 19, 2016
@drpicox
Copy link
Contributor Author

drpicox commented Feb 19, 2016

Nullification

I have already applied the nullification of the controller saved value when its component is destroyed (it overwrites the previously published controller object by null). So it does not matter if any parent scope holds the object where it is assigned, it is nullified. Beyond that point, I believe that if it is misused it should be a programmers fault. From my point of view, it is like using require, you can register the child controller into the parent directive: and you can mess it up if you want.

An excerpt from the test (notice that it crosses ng-if scope):

it('should nullify the variable once the component is destroyed', function() {
  $rootScope.$ctrl = {};
  $compile('<div ng-if="!nullified"><my-component as="$ctrl.myComponent"></my-component></div>')($rootScope);
  $rootScope.$apply('nullified = false');
  expect($rootScope.$ctrl.myComponent).toBe(myComponentController);

  $rootScope.$apply('nullified = true');
  expect($rootScope.$ctrl.myComponent).toBe(null);
});

Anyway it is a good idea to think a little bit more about possible memory leaks.

as a new relationship direction

The idea of the as directive is to be the complement of require. It gives the opposite direction of creating relationships between directives. Without it, some implementations are really weird.

One typical use of the as directive is for graphic designers, they can add the logic for toggles, accordions, and so one directly in the template.

<toggle as="myToggle"></toggle>
<button ng-click="myToggle.toggle();">Toggle</button>
<p ng-show="myToggle.isOpened()">The toggle is active</p>

The other use, which makes more sense that just a helper for graphic designers, is to enable interaction with children directives, in short: call children methods.
Interactions from parent directives to children directives are through bindings and changing binding values, and children directives can interact with parent directives are:

  • modifying '=' binding values,
  • invoking '&' methods,
  • through require which allows to call to parent directive methods.

So the overall idea is enable parent controllers to call children directive methods, and use existing directives from the template itself.

"Deprecation" of ngController and non-template directives & transclusion

One more strong point is the possibility to express in a easy to understand way when the controller of a directive is exposing its interface. Note: I'm not proposing deprecate ngController, but directives are more flexible some times.

In this case you know that staticBookDetailCtrl is referring to static-book-detail because the name matches (it is a kind of implicit value). But beyond that, if no code is available, you should not notice that there is a staticBookDetailCtrl available.

    <static-book-detail book-id="2">
        <h1>{{staticBookDetailCtrl.book.name}}</h1>
        <p>{{staticBookDetailCtrl.book.abstract}}</p>
    </static-book-detail>

But, in this other example:

    <static-book-component as="bookCtrl" book-id="4">
        <h1>{{bookCtrl.book.name}}</h1>
        <p>{{bookCtrl.book.abstract}}</p>
    </static-book-component>

It is just straight forward, you know for sure, without doubt, that you have a bookCtrl which is the actual controller of the staticBookComponent.

In addition, it also has the good point that it does not matter if you change your directive internal semantics about scopes. I explain it:

  • the first example (the implicit version) it only works with scope:true
  • if you change the scope to {} it stops working in all places where the directive is being used
  • if you decide to use transclusion in the first example (in order to use a template), it will stop working once again.

So, what about static(landing) pages matters using as seems a real good strategy.

Other frameworks

If you take a look to Polymer it uses id="foo" + this.$.foo to access to child elements. Example:

<iron-request id="xhr"></iron-request>
...
this.$.xhr.send({url: url, params: params});

Some time ago I've seen that riot.js have been doing the same, but I cannot find it now.

@VictorDeBlas
Copy link

+1

Good work!

@petebacondarwin petebacondarwin modified the milestones: 1.5.x, Backlog Mar 2, 2016
@petebacondarwin
Copy link
Contributor

OK I think we are going the right direction here... Let me review properly later this week

@dcherman
Copy link
Contributor

dcherman commented Mar 4, 2016

As a slight variation on this, what if we were to allow an expression rather than a simple property binding?

I find that a registration/de-registration pattern is very common in components that I wrote in order to work around ngRepeat/ngIf/other asynchronous behaviors where child directives come in at a later date

As a result, having this be an expression could simplify those use cases while still allowing this one:

as="myToggle = $child"

However a registration function could also be invoked

as="toggleDidArrive($child)"

The latter would not be possible without watching the property.

When we figure out the logic for the $onDestroy functionality in controllers, we could implement the inverse and get the full pattern.

@gkalpak
Copy link
Member

gkalpak commented Mar 4, 2016

I find that a registration/de-registration pattern is very common in components that I wrote in order to work around ngRepeat/ngIf/other asynchronous behaviors where child directives come in at a later date

I don't see why this will be an issue with the as directive. as will only expose the controller of the element it is on, so we don't care about children being initialized. And if it's on a transclude: 'element' directive, it will only get compiled when the actual element (not the comment) is inserted into the DOM.

Do I miss something ?

@drpicox
Copy link
Contributor Author

drpicox commented Feb 2, 2017

A small clarification, when I said "Any of them can be added in the future without breaking changes", I also mean that adding them now is like open the box of pandora, go back is a breaking change.

So, if it is unclear how to proceed, I can go for the basic one with illegal variable name checking (if not checked, adding new features would be breaking changes).

But to make things easier:

<audio ng-ref="$ctrl.audioPlayer">
	<source src="pop.mp3" type="audio/mpeg">
</audio>
<toggle ng-ref="toggle">...</toggle>
<div ng-init="$ctrl.toggle = toggle"></div>
function MyCompCtrl($scope) {
  this.proceed = function() {
    $scope.toggle.close();
  };
}

@Narretz Narretz modified the milestones: 1.5.x, 1.6.x Mar 8, 2017
@drpicox
Copy link
Contributor Author

drpicox commented Mar 13, 2017

I have added three different commits, each one for one part of the implementation.
I have also updated the branch to the current master for better control of errors in 1. and 2.

@Narretz
Copy link
Contributor

Narretz commented Apr 12, 2017

Joining the discussion ...

regarding what can be referenced or what not in response to #14080 (comment)

  1. Attribute directives
    It should imo be possible to publish any directive's controller via ng-ref, since components expand into directives anyway. This is obviously complicated by the fact that an element can have more than one controller in this case. It would for example allow you to publish an ngModel controller without having to wrap it into a form.

  2. Referencing elements when no controller is available
    I don't support this. Element access should be abstracted via the controller - it's basically as easy as putting the element on a property if you need it

  3. See 2.

  4. Collections (new )
    support for referencing multiple children. For example, at the moment, it's possible to create a collection of ng-repeated children like this:

<child ng-repeat="i in $ctrl.range" ng-ref="$ctrl.child[$index]"></child>

This seems to work fine, but is it good enough? What happens when you have multiple components of the same kind, but without ng-repeat (which means you cannot use index)? Should there be an argument to ngRef that will always create an array of references?

@gkalpak
Copy link
Member

gkalpak commented Apr 12, 2017

My thoughts:

  1. So, what do you suggest for cases where multiple directives have controllers? 😕 Note that even collecting all available directive controllers is complicated (unless this is somehow tied into the compiler). I think I am more keen on only considering the controller attached to the node-name.

  2. But Angular does it 😁 I think the closer we can get to Angular, the better for the users.

  3. I agree. I don't like this either. I mean, ideally, I would like it to be assigned on the controller that corresponds to the template in which ng-ref appears, but given this is currently not possible, doing the simplest thing is the best alternative imo (see also feat(ngAsDirective): new as directive to publish component controllers into current scope  #14080 (comment)).

  4. Creating multiple refs of elements of the same kind shouldn't be an issue. You choose the ref/property name, so you can give each a different one. Again, I believe we should stick as close as possible to the Angular behavior.

@Narretz
Copy link
Contributor

Narretz commented Apr 12, 2017

My thoughts on your thoughts :)

  1. For multiple directives, we could use an object where the keys are the directives and the values are the reference expressions. This would need to be decided before we merge it, because it could collide with the ref expression (i.e we need a way to decide if the value of ngRef is the map or the reference).

  2. Didn't know Angular publishes the element, so I'm fine with keeping it Angular

  3. So binding to any expression?

@Narretz Narretz self-assigned this Mar 9, 2018
Narretz pushed a commit to Narretz/angular.js that referenced this pull request Mar 26, 2018
@Narretz Narretz modified the milestones: 1.6.x, 1.7.x Apr 12, 2018
@jbedard jbedard self-assigned this May 2, 2018
Narretz pushed a commit to Narretz/angular.js that referenced this pull request May 22, 2018
Narretz pushed a commit to Narretz/angular.js that referenced this pull request May 24, 2018
@Narretz
Copy link
Contributor

Narretz commented Jun 1, 2018

Close to landing as #16511

@Narretz Narretz closed this Jun 1, 2018
Narretz pushed a commit to Narretz/angular.js that referenced this pull request Jun 4, 2018
Narretz added a commit to Narretz/angular.js that referenced this pull request Jun 4, 2018
Narretz added a commit that referenced this pull request Jun 5, 2018
Narretz added a commit that referenced this pull request Jun 6, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants