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

Expose isolated scope binding API #8338

Closed
Izhaki opened this issue Jul 25, 2014 · 5 comments
Closed

Expose isolated scope binding API #8338

Izhaki opened this issue Jul 25, 2014 · 5 comments

Comments

@Izhaki
Copy link
Contributor

Izhaki commented Jul 25, 2014

This is a feature request.

The issue

There are various scenarios where one would like to bind models on a child scope ( scope: true) like on an isolated scope (scope: {}).

For instance consider the following:

<input ng-model="selected" typeahead />

The typeahead directive would normally use ngModelController to deal with the model. But sometimes it would like to bound the model to its scope, so to delegate it to sub-directives (in its template).

If the typeahead directive uses an isolated scope, it can easily bind to the model using:

scope: {
    selected: '=ngModel'
}

But the directive may wish to have a child scope so to see many related models on the parent directive, and there isn't a straight-forward way to bind to the model provided by ngModel.

Ways to achieve this

There are various ways to achieve this:

Via ngModelController

One can use the input formatters with $parse to get and set the model provided. But this will only work for ngModel.

Like ngModelController

One can take the same route as ngModelController:

var ngModelGet = $parse($attr.ngModel),
    ngModelSet = ngModelGet.assign,

Double watching

As anagelc proposes in this issue one can do something like this:

// Bring in changes from outside: 
scope.$watch('model', function() {
    scope.$eval(attrs.ngModel + ' = model');
});

// Send out changes from inside: 
scope.$watch(attrs.ngModel, function(val) {
    scope.model = val;
});

But depending on the model, this may fail.

Using a custom service

Another solution would be similar to that proposed by basarat on the same issue as above - a custom service, which essentially exposes code currently in $compile.

Exposing the bind API.

The question is, why not just expose the same methods in $compile used for isolated scope binding to all, essentially allowing us to write something like this:

$scope.selected = $scope.$bind('=ngModel');
@petebacondarwin
Copy link
Member

@Izhaki - I would like to know what scenarios other than ngModel are common? With ngModel the approach is to require NgModelController as you suggest. This is well defined and works well. It is definitely the best way for the typeahead to do its work.

An alternative approach for non-ngModel scenarios, which is a hybrid of two above is:

var myValGet = $parse($attr.myVal),
    myValSet = myValGet.assign;
$scope.$watch(myValGet, function(myVal) { $scope.myVal = myVal; });
$scope.$watch('myVal', function(myVal) { myValSet($scope, myVal); });

@petebacondarwin petebacondarwin added this to the Backlog milestone Jul 25, 2014
@Izhaki
Copy link
Contributor Author

Izhaki commented Jul 25, 2014

Well... the obvious other common scenario is when you which to pass parameters in one config object, rather than many attributes, like so:

<div typeahead-options="{ selected: "selectedModel", ... }">

Note that this will also affect isolated scopes (that wish to have binding to models provided in a config object), not just child scopes.

Anyhow, the proposed solution seems right, but it doesn't work if the parent and child scope both call the model in the same way (selected and selected, for instance) (plunk - no console output other than the initial ones).

This, however, seems to work fine:

link: function( scope, element, attrs ) {
    scope.selected = null;

    var iParentScope = scope.$parent;

    var ngModelGet = $parse( attrs.ngModel ),
        ngModelSet = ngModelGet.assign; 

    iParentScope.$watch( ngModelGet, function ( aNewValue ) {
        scope.selected = aNewValue;     
    });

    scope.$watch( 'selected', function( aNewValue ) {
        ngModelSet( iParentScope, aNewValue );
    }); 
}

@Izhaki Izhaki closed this as completed Jul 25, 2014
@Izhaki Izhaki reopened this Jul 25, 2014
@Izhaki
Copy link
Contributor Author

Izhaki commented Jul 28, 2014

I had further look into this, and the feature requested cannot be that easily implemented. Reasons are:

  • The isolated scope bindings (done in nodeLinkFn) are heavily attributes based. So at least in the case where we wish to bind from a config object, this cannot be done.
  • The '@' (expression) binding is implemented using $observe, rather than $parse of a parent model, so it seems that in all cases it makes no sense to expose it in an API.

Anyhow, for two-way binding (=), I believe the proper way of doing it would be like so (an altered versions of the code in nodeLinkFn):

angular.module('scopeBindExtention', [])

.run( [ '$rootScope', '$parse', function( $rootScope, $parse ) {

    var extended = angular.extend( $rootScope, {} );

    extended.$bindTwoWay = function( scopeName, parentName ) {
        var scope       = this,
            parentScope = scope.$parent;
            lastValue,
            parentGet,
            parentSet,
            compare;

        parentGet = $parse(parentName);
        if (parentGet.literal) {
          compare = angular.equals;
        } else {
          compare = function(a,b) { return a === b; };
        }
        parentSet = parentGet.assign || function() {
          // reset the change, or we will throw this exception on every $digest
          lastValue = scope[scopeName] = parentGet(parentScope);
          throw new Error( "Expression '" + parentName + "' is non-assignable!" );
          /*
          throw $compileMinErr('nonassign',
              "Expression '{0}' is non-assignable!",
              parentName);
          */
        };
        lastValue = scope[scopeName] = parentGet(parentScope);
        var unwatch = parentScope.$watch($parse(parentName, function parentValueWatch(parentValue) {
          if (!compare(parentValue, scope[scopeName])) {
            // we are out of sync and need to copy
            if (!compare(parentValue, lastValue)) {
              // parent changed and it has precedence
              scope[scopeName] = parentValue;
            } else {
              // if the parent can be assigned then do so
              parentSet(parentScope, parentValue = scope[scopeName]);
            }
          }
          return lastValue = parentValue;
        }), null, parentGet.literal);
        scope.$on('$destroy', unwatch);    
    }

}]);

and clients just call:

scope.$bindTwoWay( 'selected', 'selected' );

But this will only work if the scope in question is a child or isolated scope.

As I've seen similar requests/questions to mine on the net, perhaps it is worth considering moving the two-way-binding code to Scope, and using it from the nodeLinkFn?

Or, what I believe in terms of software-architecture is the proper solution, is Scope to offer bindToAttributes, which will get the attrs and the scope config object (this method will handle the @ binding); and within it it will use bindToParent, which will get a scope config object with parentName replacing attrs[attrName]. After all - I believe it is the Scope who should know how to bind, not the nodeLinkFn.

I can provide more details and even a provisional PR to show exactly how I envision this to work.

@Izhaki
Copy link
Contributor Author

Izhaki commented Jul 30, 2014

Although not in context, a minor change to the code above will also allow a client to do this:

scope.$bindTwoWay( 'selected', attrs.ngModel, function( isParentChanged ) {
    console.log( isParentChanged ? 'Parent changed' : 'Local changed' );
    if ( isParentChanged ) {
        scope.query = '';
    }
});

Might be handy?

@Narretz
Copy link
Contributor

Narretz commented Apr 27, 2017

I think the conclusion here is that this is not really possible / wanted in the AngularJS scope system, as one element can only have type of scope. Exposing the isolate scope api would muddle this distinction. However, with bindToController, you can achieve this at least when not using scope: true. So basically, for the same scope, you can bind some variables of the parent scope explicitly to the controller of the directive.

In, the ngModel example, you can also bind ngModel to an object, and you'll get binding updates via $onChange and two way binding via object reference at the same time: http://plnkr.co/edit/JKbOa2ieZEeRFxg1sL5t?p=preview

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.