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

Feat: ngRef directive to publish controllers, or elements into current scope #16511

Merged
merged 3 commits into from
Jun 5, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions angularFiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ var angularFiles = {
'src/ng/directive/ngNonBindable.js',
'src/ng/directive/ngOptions.js',
'src/ng/directive/ngPluralize.js',
'src/ng/directive/ngRef.js',
'src/ng/directive/ngRepeat.js',
'src/ng/directive/ngShowHide.js',
'src/ng/directive/ngStyle.js',
Expand Down
17 changes: 17 additions & 0 deletions docs/content/error/ngRef/noctrl.ngdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@ngdoc error
@name ngRef:noctrl
@fullName A controller for the value of `ngRefRead` could not be found on the element.
@description

This error occurs when the {@link ng.ngRef ngRef directive} specifies
a value in `ngRefRead` that cannot be resolved to a directive / component controller.

Causes for this error can be:

1. Your `ngRefRead` value has a typo.
2. You have a typo in the *registered* directive / component name.
3. The directive / component does not have a controller.

Note that `ngRefRead` takes the name of the component / directive, not the name of controller, and
also not the combination of directive and 'Controller'. For example, for a directive called 'myDirective',
the correct declaration is `<div ng-ref="$ctrl.ref" ng-ref-read="myDirective">`.
27 changes: 27 additions & 0 deletions docs/content/error/ngRef/nonassign.ngdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@ngdoc error
@name ngRef:nonassign
@fullName Non-Assignable Expression
@description

This error occurs when ngRef defines an expression that is not-assignable.

In order for ngRef to work, it must be possible to write the reference into the path defined with the expression.

For example, the following expressions are non-assignable:

```
<my-directive ng-ref="{}"></my-directive>

<my-directive ng-ref="myFn()"></my-directive>

<!-- missing attribute value is also invalid -->
<my-directive ng-ref></my-directive>

```

To resolve this error, use a path expression that is assignable:

```
<my-directive ng-ref="$ctrl.reference"></my-directive>

```
2 changes: 2 additions & 0 deletions src/AngularPublic.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
ngInitDirective,
ngNonBindableDirective,
ngPluralizeDirective,
ngRefDirective,
ngRepeatDirective,
ngShowDirective,
ngStyleDirective,
Expand Down Expand Up @@ -194,6 +195,7 @@ function publishExternalAPI(angular) {
ngInit: ngInitDirective,
ngNonBindable: ngNonBindableDirective,
ngPluralize: ngPluralizeDirective,
ngRef: ngRefDirective,
ngRepeat: ngRepeatDirective,
ngShow: ngShowDirective,
ngStyle: ngStyleDirective,
Expand Down
296 changes: 296 additions & 0 deletions src/ng/directive/ngRef.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
'use strict';

/**
* @ngdoc directive
* @name ngRef
* @restrict A
*
* @description
* The `ngRef` attribute tells AngularJS to assign the controller of a component (or a directive)
* to the given property in the current scope. It is also possible to add the jqlite-wrapped DOM
* element to the scope.
*
* If the element with `ngRef` is destroyed `null` is assigned to the property.
*
* Note that if you want to assign from a child into the parent scope, you must initialize the
* target property on the parent scope, otherwise `ngRef` will assign on the child scope.
* This commonly happens when assigning elements or components wrapped in {@link ngIf} or
* {@link ngRepeat}. See the second example below.
*
*
* @element ANY
* @param {string} ngRef property name - A valid AngularJS expression identifier to which the
* controller or jqlite-wrapped DOM element will be bound.
* @param {string=} ngRefRead read value - The name of a directive (or component) on this element,
* or the special string `$element`. If a name is provided, `ngRef` will
* assign the matching controller. If `$element` is provided, the element
* itself is assigned (even if a controller is available).
*
*
* @example
* ### Simple toggle
* This example shows how the controller of the component toggle
* is reused in the template through the scope to use its logic.
* <example name="ng-ref-component" module="myApp">
* <file name="index.html">
* <my-toggle ng-ref="myToggle"></my-toggle>
* <button ng-click="myToggle.toggle()">Toggle</button>
* <div ng-show="myToggle.isOpen()">
* You are using a component in the same template to show it.
* </div>
* </file>
* <file name="index.js">
* angular.module('myApp', [])
* .component('myToggle', {
* controller: function ToggleController() {
* var opened = false;
* this.isOpen = function() { return opened; };
* this.toggle = function() { opened = !opened; };
* }
* });
* </file>
* <file name="protractor.js" type="protractor">
* it('should publish the toggle into the scope', function() {
* var toggle = element(by.buttonText('Toggle'));
* expect(toggle.evaluate('myToggle.isOpen()')).toEqual(false);
* toggle.click();
* expect(toggle.evaluate('myToggle.isOpen()')).toEqual(true);
Copy link
Member

Choose a reason for hiding this comment

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

Wow, I didn't know about the evaluate method. Nice 😃

* });
* </file>
* </example>
*
* @example
* ### ngRef inside scopes
* This example shows how `ngRef` works with child scopes. The `ngRepeat`-ed `myWrapper` components
* are assigned to the scope of `myRoot`, because the `toggles` property has been initialized.
* The repeated `myToggle` components are published to the child scopes created by `ngRepeat`.
* `ngIf` behaves similarly - the assignment of `myToggle` happens in the `ngIf` child scope,
* because the target property has not been initialized on the `myRoot` component controller.
*
* <example name="ng-ref-scopes" module="myApp">
* <file name="index.html">
* <my-root></my-root>
* </file>
* <file name="index.js">
* angular.module('myApp', [])
* .component('myRoot', {
* templateUrl: 'root.html',
* controller: function() {
* this.wrappers = []; // initialize the array so that the wrappers are assigned into the parent scope
* }
* })
* .component('myToggle', {
* template: '<strong>myToggle</strong><button ng-click="$ctrl.toggle()" ng-transclude></button>',
* transclude: true,
* controller: function ToggleController() {
* var opened = false;
* this.isOpen = function() { return opened; };
* this.toggle = function() { opened = !opened; };
* }
* })
* .component('myWrapper', {
* transclude: true,
* template: '<strong>myWrapper</strong>' +
* '<div>ngRepeatToggle.isOpen(): {{$ctrl.ngRepeatToggle.isOpen() | json}}</div>' +
* '<my-toggle ng-ref="$ctrl.ngRepeatToggle"><ng-transclude></ng-transclude></my-toggle>'
* });
* </file>
* <file name="root.html">
* <strong>myRoot</strong>
* <my-toggle ng-ref="$ctrl.outerToggle">Outer Toggle</my-toggle>
* <div>outerToggle.isOpen(): {{$ctrl.outerToggle.isOpen() | json}}</div>
* <div><em>wrappers assigned to root</em><br>
* <div ng-repeat="wrapper in $ctrl.wrappers">
* wrapper.ngRepeatToggle.isOpen(): {{wrapper.ngRepeatToggle.isOpen() | json}}
* </div>
*
* <ul>
* <li ng-repeat="(index, value) in [1,2,3]">
* <strong>ngRepeat</strong>
* <div>outerToggle.isOpen(): {{$ctrl.outerToggle.isOpen() | json}}</div>
* <my-wrapper ng-ref="$ctrl.wrappers[index]">ngRepeat Toggle {{$index + 1}}</my-wrapper>
* </li>
* </ul>
*
* <div>ngIfToggle.isOpen(): {{ngIfToggle.isOpen()}} // This is always undefined because it's
* assigned to the child scope created by ngIf.
* </div>
* <div ng-if="true">
<strong>ngIf</strong>
* <my-toggle ng-ref="ngIfToggle">ngIf Toggle</my-toggle>
* <div>ngIfToggle.isOpen(): {{ngIfToggle.isOpen() | json}}</div>
* <div>outerToggle.isOpen(): {{$ctrl.outerToggle.isOpen() | json}}</div>
* </div>
* </file>
* <file name="styles.css">
* ul {
* list-style: none;
* padding-left: 0;
* }
*
* li[ng-repeat] {
* background: lightgreen;
* padding: 8px;
* margin: 8px;
* }
*
* [ng-if] {
* background: lightgrey;
* padding: 8px;
* }
*
* my-root {
* background: lightgoldenrodyellow;
* padding: 8px;
* display: block;
* }
*
* my-wrapper {
* background: lightsalmon;
* padding: 8px;
* display: block;
* }
*
* my-toggle {
* background: lightblue;
* padding: 8px;
* display: block;
* }
* </file>
* <file name="protractor.js" type="protractor">
* var OuterToggle = function() {
* this.toggle = function() {
* element(by.buttonText('Outer Toggle')).click();
* };
* this.isOpen = function() {
* return element.all(by.binding('outerToggle.isOpen()')).first().getText();
* };
* };
* var NgRepeatToggle = function(i) {
* var parent = element.all(by.repeater('(index, value) in [1,2,3]')).get(i - 1);
* this.toggle = function() {
* element(by.buttonText('ngRepeat Toggle ' + i)).click();
* };
* this.isOpen = function() {
* return parent.element(by.binding('ngRepeatToggle.isOpen() | json')).getText();
* };
* this.isOuterOpen = function() {
* return parent.element(by.binding('outerToggle.isOpen() | json')).getText();
* };
* };
* var NgRepeatToggles = function() {
* var toggles = [1,2,3].map(function(i) { return new NgRepeatToggle(i); });
* this.forEach = function(fn) {
* toggles.forEach(fn);
* };
* this.isOuterOpen = function(i) {
* return toggles[i - 1].isOuterOpen();
* };
* };
* var NgIfToggle = function() {
* var parent = element(by.css('[ng-if]'));
* this.toggle = function() {
* element(by.buttonText('ngIf Toggle')).click();
* };
* this.isOpen = function() {
* return by.binding('ngIfToggle.isOpen() | json').getText();
* };
* this.isOuterOpen = function() {
* return parent.element(by.binding('outerToggle.isOpen() | json')).getText();
* };
* };
*
* it('should toggle the outer toggle', function() {
* var outerToggle = new OuterToggle();
* expect(outerToggle.isOpen()).toEqual('outerToggle.isOpen(): false');
* outerToggle.toggle();
* expect(outerToggle.isOpen()).toEqual('outerToggle.isOpen(): true');
* });
*
* it('should toggle all outer toggles', function() {
* var outerToggle = new OuterToggle();
* var repeatToggles = new NgRepeatToggles();
* var ifToggle = new NgIfToggle();
* expect(outerToggle.isOpen()).toEqual('outerToggle.isOpen(): false');
* expect(repeatToggles.isOuterOpen(1)).toEqual('outerToggle.isOpen(): false');
* expect(repeatToggles.isOuterOpen(2)).toEqual('outerToggle.isOpen(): false');
* expect(repeatToggles.isOuterOpen(3)).toEqual('outerToggle.isOpen(): false');
* expect(ifToggle.isOuterOpen()).toEqual('outerToggle.isOpen(): false');
* outerToggle.toggle();
* expect(outerToggle.isOpen()).toEqual('outerToggle.isOpen(): true');
* expect(repeatToggles.isOuterOpen(1)).toEqual('outerToggle.isOpen(): true');
* expect(repeatToggles.isOuterOpen(2)).toEqual('outerToggle.isOpen(): true');
* expect(repeatToggles.isOuterOpen(3)).toEqual('outerToggle.isOpen(): true');
* expect(ifToggle.isOuterOpen()).toEqual('outerToggle.isOpen(): true');
* });
*
* it('should toggle each repeat iteration separately', function() {
* var repeatToggles = new NgRepeatToggles();
*
* repeatToggles.forEach(function(repeatToggle) {
* expect(repeatToggle.isOpen()).toEqual('ngRepeatToggle.isOpen(): false');
* expect(repeatToggle.isOuterOpen()).toEqual('outerToggle.isOpen(): false');
* repeatToggle.toggle();
* expect(repeatToggle.isOpen()).toEqual('ngRepeatToggle.isOpen(): true');
* expect(repeatToggle.isOuterOpen()).toEqual('outerToggle.isOpen(): false');
* });
* });
* </file>
* </example>
Copy link
Member

Choose a reason for hiding this comment

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

From the examples, it looks like we suggest putting these stuff on the scope (I hope we don't).
It would be better if showcased how to assign the values into controllers, instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure. I didn't write the examples, but can fix them.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think that in the simple case where you just want to use the value inside the HTML only it makes sense to assign the ref to the scope.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've changed the 2nd example to use $ctrl, but not the first. I think we can keep it like this - the whole docs are riddled with "old style" examples

*
*/

var ngRefMinErr = minErr('ngRef');

var ngRefDirective = ['$parse', function($parse) {
return {
priority: -1, // Needed for compatibility with element transclusion on the same element
restrict: 'A',
compile: function(tElement, tAttrs) {
// Get the expected controller name, converts <data-some-thing> into "someThing"
var controllerName = directiveNormalize(nodeName_(tElement));

// Get the expression for value binding
var getter = $parse(tAttrs.ngRef);
var setter = getter.assign || function() {
throw ngRefMinErr('nonassign', 'Expression in ngRef="{0}" is non-assignable!', tAttrs.ngRef);
};

return function(scope, element, attrs) {
var refValue;

if (attrs.hasOwnProperty('ngRefRead')) {
if (attrs.ngRefRead === '$element') {
refValue = element;
} else {
refValue = element.data('$' + attrs.ngRefRead + 'Controller');

if (!refValue) {
throw ngRefMinErr(
'noctrl',
'The controller for ngRefRead="{0}" could not be found on ngRef="{1}"',
attrs.ngRefRead,
tAttrs.ngRef
);
}
}
} else {
refValue = element.data('$' + controllerName + 'Controller');
}

refValue = refValue || element;

setter(scope, refValue);

// when the element is removed, remove it (nullify it)
element.on('$destroy', function() {
// only remove it if value has not changed,
// because animations (and other procedures) may duplicate elements
if (getter(scope) === refValue) {
setter(scope, null);
}
});
};
}
};
}];
1 change: 1 addition & 0 deletions test/helpers/matchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ beforeEach(function() {

function generateCompare(isNot) {
return function(actual, namespace, code, content) {

Copy link
Member

Choose a reason for hiding this comment

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

Hm... 🤔

var matcher = new MinErrMatcher(isNot, namespace, code, content, {
inputType: 'error',
expectedAction: 'equal',
Expand Down
Loading