5

Can anybody tell me if it's possible to require and use ngModel inside the controller of a custom Angular directive. I'm trying to stay away from the link function. I see most examples use the link function but I'm thinking there must be some way to use it inside a directive controller? Or is it only accessible in a link function? The one way I have seen to do it, as show below, gives me undefined. I'm not sure if there is another way?? I'm trying to validate the component and have the invalid class be set on the error object.

//directive
angular.module('myApp', [])
  .directive('validator', function (){
    return {
      restrict: 'E',
      require: {
           ngModelCtrl: 'ngModel',
           formCtrl: '?^form'
      },
      replace: true,
      templateUrl: 'view.html',
      scope: {},
      controllerAs: 'ctrl',
      bindToController: {
         rows: '=',
         onSelected: '&?' //passsed selected row outside component
         typedText: '&?' //text typed into input passed outside so developer can create a custom filter, overriding the auto
         textFiltered: '@?' //text return from the custom filter
         ngRequired: "=?" //default false, when set to true the component needs to validate that something was selected on blur. The selection is not put into the input element all the time so it can't validate based on whether or not something is in the input element itself. I need to validate inside the controller where I can see if 'this.ngModel' (selectedRow - not passed through scope) is undefined or not.
      },
      controller: ["$scope", "$element", function ($scope, $element){
         var ctrl = this;
         ctrl.rowWasSelected;

         //called when a user clicks the dropdown to select an item
          ctrl.rowSelected = function (row){
               ctrl.rowWasSelected = true;
               ctrl.searchText = row.name; //place the name property of the dropdown data into ng-model in the input element
          }

         ctrl.$onInit = $onInit;
         function $onInit (){
             ctrl.ngModelCtrl.$validators.invalidInput = validate;            
          }

        function validate (modelValue, viewValue) {
             var inputField = ctrl.formCtrl.name;
             var ddField = ctrl.formCtrl.listData;

             inputField.$setValidity('invalidInput', ddField.$touched && ctrl.rowWasSelected);

            return true;
          }          
       }];
   }
});

//template
<form name="validatorForm" novalidate>
  <div class="form-group" ng-class="{ng-invalid:validatorForm.name.$error.invalid}">
     <label for="name">Names</label>
     <input type="name" class="form-control" name="name" placeholder="Your name" ng-change="typedText(text)" ng-model="ctrl.textFiltered" ng-blur="ctrl.validate()" ng-required="ctrl.ngRequired">
  </div>
  <ul ng-show="show list as toggled on and off" name="listData" required>
    <li ng-repeat="row in ctrl.rows" ng-click="ctrl.rowSelected({selected: row}) filterBy:'ctrl.textFiltered' ng-class="{'active':row === ctrl.ngModel}">{{row}}<li>
  </ul>
</form>

//html
<validator
   rows="[{name:'tim', city:'town', state:'state', zip: 34343}]"
   on-selected="ctrl.doSomethingWithSelectedRow(selected)"
   typed-text="ctrl.manualFilter(text)"
   text-filtered="ctrl.textReturnedFromManualFilter"
   ng-required="true">
</validator>
techer
  • 181
  • 2
  • 16
  • Use bindToController in your directive definition object, if you pass an object into require, it's keys will bound to your controller. – pQuestions123 Feb 21 '16 at 04:32
  • Thanks, I'm not sure I'm following completely what you're saying. I have bindToController set to true. Are you saying pass isolate data into bindToController? I've updated my post a little bit. – techer Feb 21 '16 at 05:37

2 Answers2

1

Here is the code refactored a bit (Note: you need to be using the latest Angular for some of this). After rereading your question I am not sure what exactly you are having trouble with (whether it is how to use required in the directive definition object or how to use ngRequired attribute or something else). Note that with the code below you do not need $scope:

angular.module('myApp', []);
angular.module('myApp').directive('validator', validator);

function validator (){
    return {
        restrict: 'E',
        require: {
            ngModelCtrl: 'ngModel'
        },
        replace: true,
        templateUrl: 'view.html',
        scope: {}, //this controls the kind of scope. Only use {} if you want an isolated scope.
        controllerAs: 'ctrl',
        bindToController: {
            rows: '=',
            onSelected: '&?', //passsed selected row outside component
            typedText: '&?', //text typed into input passed outside so developer can create a custom filter, overriding the auto
            textFiltered: '@?', //text return from the custom filter
            ngRequired: "=?" //default false, when set to true the component needs to validate that something was selected on blur. The selection is not put into the input element all the time so it can't validate based on whether or not something is in the input element itself. I need to validate inside the controller where I can see if 'this.ngModel' (selectedRow - not passed through scope) is undefined or not.
        },
        controller: 'validatorController'
    }
}

//usually do this in a new file

angular.module('myApp').controller('validatorController', validatorController);
validatorController.$inject = ['$element'];

function validatorController($element){
    var ctrl = this;

    //controller methods
    ctrl.validate = validate;

    ctrl.$onInit = $onInit; //angular will execute this after all conrollers have been initialized, only safe to use bound values (through bindToController) in the $onInit function.

    function $onInit() {
        if(ctrl.ngRequired)
            ctrl.ngModelCtrl.$validators.myCustomRequiredValidator = validate;
    }



    //don't worry about setting the invalid class etc. Angular will do that for you if one if the functions on its $validators object fails
    function validate (modelValue, viewValue){
        //validate the input element, if invalid add the class ng-invalid to the .form-group in the template
        //return true or false depending on if row was selected from dropdown
        return rowWasSelected !== undefined
    }
}   

Here are a couple of snippets from Angular's docs on $compile:

If the require property is an object and bindToController is truthy, then the required controllers are bound to the controller using the keys of the require property. This binding occurs after all the controllers have been constructed but before $onInit is called.

and

Deprecation warning: although bindings for non-ES6 class controllers are currently bound to this before the controller constructor is called, this use is now deprecated. Please place initialization code that relies upon bindings inside a $onInit method on the controller, instead.

Again, make sure you are using the latest version of Angular or the above won't work. I can't remember exactly which part (I feel like it might be getting the require object keys auto-bound to the controller object), but I have definitely run into a nasty bug where the above wasn't working and I was using 1.4.6.

Second Edit: Just want to clear up a few things:

1) the .ng-invalid class will be applied to any input in an angular validated form that is invalid. For example, if there is a required attribute on an input and the input is empty, then the input will have an ng-invalid class. Additionally, it will have a class .ng-invalid-required. Every validation rule on the input gets its own ng-invalid class. You say you want to add a red border to an input after it has been blurred for the first time. The standard way to do this is to have a css rule like this:

.ng-invalid.ng-touched {
   border: 1px #f00 solid;
}

If you inspect a validated input you will see all kinds of angular classes. One of them is .ng-touched. A touched element is one that has been blurred at least once. If you wanted to ensure that validation is only applied on blur you could use ng-model-options directive.

2) $formatters are used to format a model value. Angular has two way data binding. That means that angular is $watching a model value and view value. If one of them changes angular executes a workflow to update the other one. The workflows are as follows:

view value changes -> $parsers -> $validators -> update model value model value changes -> $formatters -> update view value

The result of the work flow is populated into the other value. This means that if you want to change model value before showing it in the view (maybe you want to format a date) then you could do it in the $formatter. Then, you could do the opposite operation in a $parser as it travels back to the model. Of course, you should be cognizant of what is happening in the $parsers when you write your $validators because it is the parsed view value that gets validated before getting sent to the model.

3) Per the quote I added from the angular docs, it is clear that you should not use any logic that contains a value that has been bound to the controller by bindToController outside of $onInit. This includes ngModelCtrl. Note that you could place the logic in another function as long as you are sure that the other function will execute AFTER $onInit.

4) There are two things to consider here: Which control is having the error show up and where are you triggering the validation from. It sounds like you want to trigger it from the dropdown's workflow (i.e. after it has been blurred once). So, I suggest adding a validator to the dropdown. Now, you say you want to validate the input and not the dropdown. So, you can use $setValidity inside the validator. To ensure that the dropdown is always "valid" you can just return true from the validator. You say you want to only validate after blur. There are two ways to do that (off the top of my head). One is to use the ng-model-options that I mentioned above, the other is to test if the dropdown has been $touched in the validator. Here is some code using the second method:

function validate (modelValue, viewValue) {
    var inputField = ctrl.formCtrl.inputName, ddField = ctrl.formCtrl.ddName;

    inputField.$setValidity('validationName', ddField.$touched && rowSelectedCondition);
    return true;
}

You see, I am testing to see if the dropdown has been $touched (i.e. blurred) before I set the validity. There is a fundemental difference between these two approaches. Using ng-model-options basically defers the whole update workflow until blur. This means your model value will only get updated to match the view value after the input has been blurred. The second way (with $touched) will validate every time the viewValue changes but will only render the input invalid after the first blur.

The 'validationName' argument will just specify the class that is added if the input is invalid so in this case it will add two classes .ng-invalid (added to any invalid control) and .ng-invalid-validation-name.

In order to get access to the formCtrl you need to add another property to your require object (formCtrl: '^form')

pQuestions123
  • 4,471
  • 6
  • 28
  • 59
  • Great, thanks a lot. So what I am really trying to do is validate the input element inside the template once a user has clicked into it to open the dropdown. If the user opens the dropdown and then clicks elsewhere (i.e. the document) and does not select an item from the dropdown. I want to trigger the invalid class and add the 'ng-invalid' to the input so the border is red? So I'll use something like: ng-blur="ctrl.validateInput()" inside the input, which will call that function in the controller on blur, if ng-required is true (validation is active) and no row selected. Add ng-invalid class – techer Feb 21 '16 at 20:40
  • Can I remove the $onInit call and just wrap the ctrl.ngModelCtrl.$formatters.invalid = function (model, view) {if no row and false is returned then add the inlvaid class to the input} inside the ctrl.validate function? I'm not activating that ng-invalid on the input so I must be missing something small. What are you referring to in regards to the 'myCustomRequiredValidator'? Could I just use 'invalid' instead? Thanks – techer Feb 21 '16 at 20:44
  • @techer If all you are trying to do is validate required onblur then you should check out https://docs.angularjs.org/api/ng/directive/ngModelOptions. sepecify updateOn: 'blur' like the first example. – pQuestions123 Feb 21 '16 at 21:53
  • @techer I think you are confusing a few things. I will add a new edit to my answer to try and clear up some things you may be misunderstanding. – pQuestions123 Feb 21 '16 at 21:55
  • that all makes sense. The challenge with this situation is I can't validate the input itself because there is no data there. The user is only typing in the input to filter the dropdown list when it is open. So I can't add ng-required to the input and then use ng-messages to show an error message "if form.input.$invalid && !form.input.$pristine". I need to do it all in the controller. I need the input to be in a valid or invalid state so if it is placed in a form by the developer using the component, the form is invalid as a whole if the component is invalid. – techer Feb 21 '16 at 22:58
  • Is it even possible to add the invalid state to the component (input) if no selection is made in the dropdown? I was thinking I could just check to see if there was a row selected in the controller and if there was not once the blur event was triggered, then add the ng-invalid class to the component and show a red error. But I can't do it in the template of the directive. Is there a better way then $validators? Thanks for all the info – techer Feb 21 '16 at 23:04
  • @techer I am not really following the whole situation specifically but I will say that if you add a validator to the the dropdown you can require the form itself and then validate the input (search for the input's name in the formController and then use $setValidity) – pQuestions123 Feb 21 '16 at 23:15
  • Yeah it's somewhat hard to explain and I'm not exactly following what you're saying there but one other scenario, why can't I just do an if statement like I just added to my initial post? Just use setValidity and set it to true 'if (row was selected)' or 'else {setValidity to false}'. Is there a way to make that work with '$onInit', but not running that if/else until the input has been blurred. It easy enough to add the '.ng-invalid.ng-touched' class to the input to give it some effect. I'm more concerned with making sure input element is in the invalid state if a dropdown item wasn't chosen – techer Feb 22 '16 at 04:39
  • My ultimate goal is to be able to add the .ng-invalid class from the controller based on if $validators or $setValidity returns true or false. If invalid, trigger the .ng-invalid, otherwise don't show that class. I had the understanding that ctrl.ngModelCtrl.$validators.invalid and ctrl.setValidity('invalid', false) did the same thing... – techer Feb 22 '16 at 05:06
  • @techer I will add answers in my answer. – pQuestions123 Feb 22 '16 at 13:35
  • Ok so I went ahead and added a 'name' and 'required' attribute to the ul of the dropdown element. I'm thinking it needs to be there rather then each individual li element. I then reference both the input and dd in my validator function and add the necessary $setValidity requirements. I'm thinking were still calling the validate function from the $onInit call and the ngModelCtrl.$validators? Then on page load the validate will run and check the dd to see if it is valid and it will be because we are returning true to the $validators object in the $onInit function. – techer Feb 23 '16 at 05:28
  • Then once the dropdown has been opened and then the user blurs the dropdown (i.e. either clicks the dd to select a row or clicks somewhere else on the document to close the dd) the setValidity will run and check to see if the dd was touched and a row was selected. Will this throw the invalid class if the user clicks somewhere else on the document rather then the dd? if they click in the dd then they are selecting a row for sure and the input is valid, if they open the dropdown and click somewhere other then the dd to close the dd, then it is invalid. Thanks again, this is helpful. – techer Feb 23 '16 at 05:35
  • One last thing - when you say add a validator to the dropdown, you are meaning something like the 'required' attribute right? The component itself is restricted to elements. – techer Feb 23 '16 at 05:41
  • @techer I mean add a required attribute to the select element – pQuestions123 Feb 23 '16 at 14:58
  • there is no select element - it is just a input element and then a ul element with 'ng-repeat' used in the li to show the data. The ul is absolutely positioned underneath the input that is shown or hidden using 'ng-show' - ctrl.isOpen is either true or false and toggles the ul element open and closed. The input has to be there so it can filter the dropdown list with 'ng-change' that calls a function in the controller that is filtering the data passed in. The ul dropdown is essentially a bootstrap dropdown with 'list-group' and 'list-group-item'. Can you use required in a ul?? – techer Feb 24 '16 at 04:34
0

The easiest way to get access to the information provided by ngModel in a custom directive is to set the scope to false. This should happen by default, but if you are working with multiple directive it can be helpful to set it expressly. This way, the directive will inherit the controller and controller alias as if it were completely native to the rest of the view.

The directive:

.directive('myValidator', function (){
return {
  restrict: 'E',
  replace: true,
  templateUrl: 'view.html',
  scope: false
  };
}

You don't have to change the template very much. Just make sure the ng-model="ctrl.name" is binding to something on your main controller, or whatever controller you are using for the rest of the view. You can move the validation function to the main controller, too. Or, to a service and inject into the controller, etc.

Using compile or link in a custom directive can make it much more versatile. But you are basically passing values for the directives, attributes, or html tags. ngModel is available, but you may not be using ctrl.user every time you use the custom directive. Compile or link let you set the value of ngModel each time you use the directive.

Chris
  • 161
  • 1
  • 10
  • Thanks Chris, I added some more information to my post. I was trying to keep it short initially but I probably should have included initially. I am using isolate scope to pass various data into the component as defined by the developer using the component. I am already using ngModel inside my template to bind the selected row in the dropdown to add the active class to show that is the currently selected row. I need the ngModel Controller so Ic an access the $validators method and it needs to come inside the component. Developer will the have the option to set ng-required to true from the html – techer Feb 21 '16 at 05:29
  • Is there another way to access $validators if you are not using require to get the ngModel Controller? Basically, I'm just looking to know if, based on how I have the component at this point, if I can access the ngModel $validators method from inside my controller function. Thanks again – techer Feb 21 '16 at 05:36
  • In that case, I think you should reconsider using compile. It gives you the most versatile directives. If you have to use a isolated scope, pQuestions123's answer looks really good. However, I think isolated scopes are overused, when you can instead set the directive's scope to true, have access to it's own controller, and also fall back on data binding in the mainCtrl. Angular will look up the scope for variables it doesn't have in the current scope. You can also set up the formCtrl more traditionally and write more robust validations, instead of squeezing it into the custom directive. – Chris Feb 21 '16 at 17:50
  • Thanks Chris, I agree Link function is easiest but the circumstances currently require it to be done inside a controller – techer Feb 22 '16 at 04:13