9

I'm trying to build custom directive that will allow me to display questions in survey. Because I have multiple types of questions I thought about creating single directive and change it's template based on question type.

my directive:

directive('question', function($compile) {
  var combo = '<div>COMBO - {{content.text}}</div>';
  var radio = [
    '<div>RADIO - {{content.text}}<br/>',
    '<md-radio-group layout="row" ng-model="content.answer">',
    '<md-radio-button ng-repeat="a in content.answers track by $index" ng-value="a.text" class="md-primary">{{a.text}}</md-radio-button>',
    '</md-radio-group>',
    '</div>'
  ].join('');
  var input = [
    '<div>INPUT - {{content.text}}<br/>',
    '<md-input-container>',
    '<input type="text" ng-model="content.answer" aria-label="{{content.text}}" required md-maxlength="10">',
    '</md-input-container>',
    '</div>'
  ].join('');

  var getTemplate = function(contentType) {
    var template = '';

    switch (contentType) {
      case 'combo':
        template = combo;
        break;
      case 'radio':
        template = radio;
        break;
      case 'input':
        template = input;
        break;
    }

    return template;
  }

  var linker = function(scope, element, attrs) {

    scope.$watch('content', function() {
      element.html(getTemplate(scope.content.type))
      $compile(element.contents())(scope);

    });
  }

  return {
    //require: ['^^?mdRadioGroup','^^?mdRadioButton'],
    restrict: "E",
    link: linker,
    scope: {
      content: '='
    }
  };
})

Inside my main controller I have list of questions and after clicking button I'm setting current question that is assign to my directive.

Everything works fine for first questions, but after I set current question to radio type I get this error:

Error: [$compile:ctreq] Controller 'mdRadioGroup', required by directive 'mdRadioButton', can't be found!

I've tried adding required to my directive as below, but it didn't helped.

require: ['^mdRadioGroup'],

I can't figure out whats going on, because I'm still new to angular.

I've created Plunker to show my issue: http://plnkr.co/edit/t0HJY51Mxg3wvvWrBQgv?p=preview

Steps to reproduce this error:

  1. Open Plunker
  2. Click Next button two times (to navigate to question 3)
  3. See error in console

EDIT:
I've edited my Plunker so my questions model is visible. I'm able to select answers, even in questions that throw error-questions model is updating. But still I get error when going to question 3.

Splaktar
  • 5,506
  • 5
  • 43
  • 74
Misiu
  • 4,738
  • 21
  • 94
  • 198

4 Answers4

3

I'd just simply extend a base directive, and then have a specialized ones with different directive names too.

// <div b></div>
ui.directive('a', ... )
myApp.directive('b', function(aDirective){
   return angular.extend({}, aDirective[0], { templateUrl: 'newTemplate.html' });
});

Code taken from https://github.com/angular/angular.js/wiki/Understanding-Directives#specialized-the-directive-configuration

Iamisti
  • 1,680
  • 15
  • 30
  • Thank You for this proposal, but could You also help me with removing error I'm getting right now? – Misiu Feb 19 '16 at 11:59
  • That's a good question. The first that I noticed is that you don't have an ng-model on your `md-radio-group` – Iamisti Feb 19 '16 at 12:13
  • I've added `ng-model` but it didn't help. – Misiu Feb 19 '16 at 12:15
  • I've tried Your approach but it didn't help. I need a way to conditionally replace template of my directive, and those templates contains other directives. Only when adding ngMaterial I'm getting an error. – Misiu Mar 01 '16 at 18:46
  • any ideas why I get that error? I've tried Your approach but without luck. I get this error only when I use angular material inside my directive – Misiu Mar 08 '16 at 13:25
3

Working Demo

There is no need to create and use a directive for your requirement.

You can just use angular templates and ng-include with condition.

You can just create three templates (each for combo, radio and input) on your page like this,

<script type="text/ng-template" id="combo">
    <div>COMBO - {{content.text}}</div>
</script>

And include these templates in a div using ng-include.

<!-- Include question template based on the question -->
<div ng-include="getQuestionTemplate(question)">

Here, getQuestionTemplate() will return the id of the template which should be included in this div.

// return id of the template to be included on the html
$scope.getQuestionTemplate = function(content){
    if(content.type == "combo"){
      return 'combo';
    }
    else if (content.type == "radio"){
      return 'radio';
    }
    else{
      return 'input';
    }
}

That's all. You are done.

Please feel free to ask me any doubt on this.

Abhilash Augustine
  • 4,128
  • 1
  • 24
  • 24
  • Thank You so much for reply. It is more complicated. I'd like to use sub-directives in my directive, because I need different logic for each type of question, for example validation. For simple text input I'd like to check if entered text is equal to correct answer I'd like to get, for checkboxes I must check if only correct are selected. I think this kind of modularity will help me in future. Here is Plunker showing my second approach: http://plnkr.co/edit/fq6nTXGYBT8oJSkvOFIE?p=preview – Misiu Mar 01 '16 at 07:27
  • @Misiu, Sounds good. Sorry to misleads you. Go with your current approach. All the best !!! :) – Abhilash Augustine Mar 01 '16 at 07:43
  • Your solution works fine without error, but I really would like to use that inside directive. Any ideas why I'm getting that error? – Misiu Mar 01 '16 at 07:44
3

In case anyone is wondering, the problem is that the parent component's scope is used to compile each new element. Even when the element is removed, bindings on that scope still remain (unless overwritten), which may cause the errors OP saw (or even worse, memory leaks).

This is why one should take care of cleaning up when manipulating an element's HTML content imperatively, like this. And because this is tricky to get right, it is generally discouraged to do it. Most usecases should be covered by the built-in directives (e.g. ngSwitch for OP's case), which take care of cleaning up after themselves.


But you can get away with manually cleaning up in a simplified scenario (like the one here). In its simplest form, it involves creating a new child scope for each compiled content and destroying it once that content is removed.

Here is what it took to fix OP's plunker:

before

scope.$watch('content', function () {
  element.html(getTemplate(scope.content.type));
  $compile(element.contents())(scope);
});

after

var childScope;
scope.$watch('content', function () {
  if (childScope) childScope.$destroy();
  childScope = scope.$new();
  element.html(getTemplate(scope.content.type));
  $compile(element.contents())(childScope);
});

Here is the fixed version.

gkalpak
  • 47,844
  • 8
  • 105
  • 118
  • Thanks for this deeply buried nugget of gold. I've encountered the same mdRadioGroup required error, but it's on a component rather than a directive, so there isn't an explicit link function that can be changed like this. Is there a corresponding change that can be made to a component, or will the non-destroying scope problem essentially be within the angular js library code and unpatchable? – Uberdude Jun 13 '20 at 00:14
  • Components are just directives in AngularJS (`.component()` is just syntactic suger over `.directive()` with some default values), so this isn't much different. For example, you can use the component controller's `$postLink` lifecycle hook instead of a directive's `post-link` callback: https://docs.angularjs.org/api/ng/service/$compile#life-cycle-hooks – gkalpak Jun 13 '20 at 07:30
2

I played a little with your code and find that, the reason why the error occurred is because the 3rd question got more answers than the 2nd, so when you create the mdRadioGroup the first time it defines 4 $index answers and later for question 3 it go out of bound with 6 answers... So a non elegant solution is to create as many $index as the max answers to any question, the first time, show only the ones with text...

.directive('question', function($compile) {
var combo = '<div>COMBO - {{content.text}}</div>';
var radio = [
'<div>RADIO - {{content.text}}<br/>',
'<md-radio-group layout="row">',
'<md-radio-button ng-repeat="a in content.answers track by $index" ng-show={{a.text!=""}} value="{{a.text}}" class="md-primary">{{a.text}}</md-radio-button>',
'</md-radio-group>',
'</div>'
].join('');
var input = [
'<div>INPUT - {{content.text}}<br/>',
'<md-input-container>',
'<input type="text" ng-model="color" aria-label="{{content.text}}" required md-maxlength="10">',
'</md-input-container>',
'</div>'
].join('');

var getTemplate = function(contentType) {
var template = '';

switch (contentType) {
  case 'combo':
    template = combo;
    break;
  case 'radio':
    template = radio;
    break;
  case 'input':
    template = input;
    break;
}

return template;
}

then change questions to have the max amount of answers every time in all questions:

$scope.questions = [{
type: 'radio',
text: 'Question 1',
answers: [{
  text: '1A'
}, {
  text: '1B'
}, {
  text: '1C'
}, {
  text: ''
}, {
  text: ''
}, {
  text: ''
}, {
  text: ''
}]
}, {
type: 'input',
text: 'Question 2',
answers: [{
  text: '2A'
}, {
  text: '2B'
}, {
  text: '2C'
}, {
  text: ''
}, {
  text: ''
}, {
  text: ''
}, {
  text: ''
}]
}, {
type: 'radio',
text: 'Question 3',
answers: [{
  text: '3A'
}, {
  text: '3B'
}, {
  text: '3C'
}, {
  text: '3D'
}, {
  text: ''
}, {
  text: ''
}, {
  text: ''
}]
}, {
type: 'combo',
text: 'Question 4',
answers: [{
  text: '4A'
}, {
  text: '4B'
}, {
  text: ''
}, {
  text: ''
}, {
  text: ''
}, {
  text: ''
}, {
  text: ''
}]
}];

The rest of the code is the same. As I say before, no elegant and for sure there are better options, but could be a solution for now...

DIEGO CARRASCAL
  • 1,999
  • 14
  • 16
  • Thank You for help. I think it's bit weird behavior, because I'm compiling template every time I change question. I'll leave this question open and start bounty as fast as I can. – Misiu Feb 19 '16 at 23:38