Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to reconcile Angular's "always use dots with ngModel" rule with isolate scopes?

I am working on an Angular app using Bootstrap.

In order to minimize the Bootstrap footprint on my HTML, I've introduced two directives for forms:

form-control.js

module.directive('formControl', function() {
  return {
    restrict : 'E',
    templateUrl : 'form-control.tmpl.html',
    scope: {
      label: '@'
    },
    transclude : true
  };
});

form-control.tmpl.html

<div class="form-group">
  <label class="control-label col-sm-2">
    {{ label }}
  </label>
  <div class="col-sm-10"
       ng-transclude>
  </div>
</div>

I also have a handful of "extensions" to this directive, for various form input fields. e.g.:

form-input-text.js

module.directive('formInputText', function() {
  return {
    restrict : 'E',
    templateUrl : 'form-input-text.tmpl.html',
    scope: {
      label: '@',
      value: '=ngModel'
    }
  };
});

form-input-text.tmpl.html

<form-control label="{{label}}">
  <input type="text"
         class="form-control"
         ng-model="value">
</form-control>

app.html

<form-input-text label="Name"
                 ng-model="person.name">
</form-input-text>

Here I run into a problem. There are a number of scopes in play in this example:

appScope = { person : { name : "John" } };
isolateScope = {
  label: "Name",
  value: "John" // bound two-way with appScope.person.name
};
transcludeScope = {
  __proto__: isolateScope,
  label: "Name", // inherited from isolateScope
  value: "John" // inherited from isolateScope
};

If I change the text in the input text box, then only transcludeScope is modified:

appScope = { person : { name : "John" } };
isolateScope = {
  label: "Name",
  value: "John" // bound two-way with appScope.person.name
};
transcludeScope = {
  __proto__: isolateScope,
  label: "Name", // inherited from isolateScope
  value: "Alice" // overrides value from isolateScope
};

This is because the <input> is bound directly to a property of transcludeScope. transcludeScope.value is changed directly, and the parent scope isolateScope is unaffected. Thus any model changes in the input never make it back to appScope.

What I'd like to do is create a two-way binding between appScope.person.name and a nested property of isolateScope, e.g. isolateScope.model.value.

Ideally I'd like to declare my directive like this:

form-input-text.js

module.directive('formInputText', function() {
  return {
    restrict : 'E',
    templateUrl : 'form-input-text.tmpl.html',
    scope: {
      model: {
        label: '@',
        value: '=ngModel'
      }
    }
  };
});

This would allow the transcluded portion to bind to model.value, which would make changes visible to isolateScope, which would in turn propagate changes in isolateScope back to appScope.

This usage does not seem to be directly supported by Angular.

Can anyone point me to a feature of Angular that supports this use case, or if not, provide a workaround?

Edit:

For now, my solution is to inline the form-control template into the form-input-text template.

form-input-text.tmpl.html

<div class="form-group">
  <label class="control-label col-sm-2">
    {{ label }}
  </label>
  <div class="col-sm-10">
    <input type="text"
           class="form-control"
           ng-model="value">
  </div>
</div>

This eliminates the child scope introduced ng-transclude, but it also duplicates markup, which is what I was hoping to refactor into a single place.

like image 701
qualidafial Avatar asked Dec 19 '25 19:12

qualidafial


2 Answers

Thinking about scopes is actually going slightly on the wrong track, and I don't think the transclusion has much to do with it. To do it "properly", you should integrate with ngModelController. This allows any later integrated parsers and formatters (which could contain validation logic) to run at the appropriate times. It's a bit complicated as you have 2 of them: the parent one in the application, and the one in the template of the directive, and each one has 2 "pipelines" to be integrated with:

  • model value -> view value
  • view value -> model value

The view value of the parent ngModelController is then used as the model value of the inner ngModelController. So the overall pipelines look like

  • parent model value -> parent view value -> inner model value -> inner view value
  • inner view value -> inner model value -> parent view value -> parent model value

To do this:

  • Make sure you require: 'ngModel' in the directive definition, to have access to the parent ngModelController

  • Changes from the parent ngModelController to the inner, are done using the $render method of the parent ngModelController, using its $viewValue. This ensures that any functions in the parent $formatters have run.

  • User initiated changes from the inner directive are done by adding a function to its $viewChangeListeners array, which calls $setViewValue on the parent ngModelController. To access this from the linking function scope, you need a named form and input elements. A slight annoyance is that the form is only registered on the scope of the directive after its linking function of the directive has run, so you need a watcher to access it.

  • Just in case of any weirdnesses, make sure that the model in formInputText is in an object. (I'm not sure this is technically necessary)

  • You then don't need to have the model in the scope object of the inner directive.

Putting this together,

app.directive('formInputText', function() {
  return {
    restrict : 'E',
    templateUrl : 'form-input-text.tmpl.html',
    scope: {
      label: '@'
    },
    require: 'ngModel',
    link: function(scope, element, attrs, ngModelController) {
      scope.model = {};

      // Propagate changes from parent model to local
      ngModelController.$render = function() {
        scope.model.value = ngModelController.$viewValue;
      };

      // Propagate local user-initiated changes to parent model
      scope.$watch('form', function(form) {
        if (!form) return;
        form.input.$viewChangeListeners.push(function() {
          ngModelController.$setViewValue(form.input.$modelValue);
        });       
      });
    }
  };
});

And its template looks like

<form-control label="{{label}}" ng-form name="form">
  <input type="text"
         class="form-control"
         name="input"
         ng-model="model.value">
</form-control>

This can be seen working at http://plnkr.co/edit/vLGa6c55Ll4wV46a9HRi?p=preview

like image 65
Michal Charemza Avatar answered Dec 21 '25 09:12

Michal Charemza


I would use a custom control for your case, as described here, making the custom <form-input-*> directives true controls. It does need some additional work. To outline a simplistic version of a solution:

form-input-text.js

app.directive('formInputText', function() {
    return {
        restrict : 'E',
        template : '<form-control label="{{label}}"><input type="text" class="form-control" /></form-control>',
        scope: {
            label: '@'
        },
        require: 'ngModel',
        link: function(scope, elem, attrs, ngModel) {
            var input = angular.element(elem[0].querySelectorAll("input")[0]);

            ngModel.$render = function() {
                input.val(ngModel.$viewValue || '');
            };

            input.on('blur keyup change', function() {
                scope.$apply(read);
            });

            function read() {
                ngModel.$setViewValue(input.val());
            }
        }
    };
});

In short, you require the ngModel and implement its methods, as per the documentation. The ngModel is just another directive applied to your control and other things will work, e.g. custom validators, ng-required and so on.

A working fiddle: http://jsfiddle.net/1n53q59z/

Bear in mind that you may need to do some tweaking depending on your use case.

like image 39
Nikos Paraskevopoulos Avatar answered Dec 21 '25 09:12

Nikos Paraskevopoulos



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!