Is it possible to conditionally apply transclution to directive? - angularjs

Is it possible to decide whether to apply transclusion to an element based on a scope variable ?
For example ( Stupid simplified reduced example of what i'm trying to achieve )
app.directive('myHighlight', function () {
return {
transclude : true,
template : "<div style='border:1px solid red'><span ng-transclude></span></div>"
}
});
app.directive('myDirective', function () {
return {
template : "<span>some text</span>",
link : function (scope,element,attr) {
if ( 'shouldHighlight' in attr) {
// wrap this directive with my-highlight
}
}
}
});
And then in the html
<span my-directive></span>
<span my-directive should-highlight></span>
Note, please don't tell me to just add the highlight instead of should-highlight, as i said this is a dumb reduced example. Thanks.

Instead of optionally applying the highlight directive, always apply it and do the optional wrapping inside that directive. The optional wrapping is achieved with an ng-if and a boolean passed from myDirective to myHighlight via markup:
<div my-highlight="someBooleanValue">some text</div>
The myHighlight template:
<div ng-if="actuallyTransclude" style="border:1px solid red">
<span ng-transclude></span>
</div>
<div ng-if="!actuallyTransclude" ng-transclude></div>
Working jsfiddle: http://jsfiddle.net/wilsonjonash/X6eB5/

Sure. When you specify the transclude option, you know that you can declaratively indicate where the content should go using ng-transclude.
In the linking function of the directive, you will also get a reference to a transclude function (https://docs.angularjs.org/api/ng/service/$compile, see link section):
function link(scope, iElement, iAttrs, controller, transcludeFn) { ... }
The transcludeFn will return the transcluded content, so you can conditionally insert that were and when you want to in the link function of your directive.
Example (http://jsfiddle.net/DKLY9/22/)
HTML
<parentdir flg="1">
Child Content
</parentdir>
JS
app.directive('parentdir', function(){
return {
restrict : 'AE',
scope: {
flg : "="
},
transclude : true,
template : "<div>Parent {{childContent}} Content</div>",
link : function(scope, elem, attr, ctrl, transcludeFn){
if (scope.flg==1){
scope.childContent="Include Me instead";
}
else {
scope.childContent = transcludeFn()[0].textContent;
}
}
}
});
This is a simplified example. To get a better idea of how to use the transclude function, refer to the following : http://blog.omkarpatil.com/2012/11/transclude-in-angularjs.html

When I approach these kind of problems I just look at what angular did. Usually their source code is very readable and easy to re-use. ngTransclude is no different:
https://github.com/angular/angular.js/blob/master/src/ng/directive/ngTransclude.js
I leave the rest to you. You can either create your own transclusion directive that receives also a condition, or just duplicate the code into your specific directive when the if condition is true.
If you still have trouble, please let me know and we'll set up a plunker.

Related

Angularjs Interpolation using double curly braces not working under ng-if

UPDATE1: developed the plunker sample that will reproduce the problem. See below.
I have a strange problem in my project, where it appears in one place only. Finally, I was able to reproduce the problem using plunker sample:
http://plnkr.co/edit/JJbq54?p=preview
In the above sample, see the section "With ng-if" and "Without ng-if", enter something in the input text, and see how the double curly braces not working under ng-if, but ng-bind works fine. Also, if you remove check-if-required from the template sites-and-improvements.html also the problem is solved.
More details below:
I have the the following HTML5 code block:
<div ng-if="isFullCUSPAP" id="sites_and_imrpovements_comments">
<div class="form-row">
<div class="inputs-group">
<label>WIND TURBINE:</label>
<div class="input-binary">
<label>
<input type="radio" id="wind_turbine"
name="wind_turbine"
ng-model="$parent.wind_turbine"
value="Yes" force-model-update />
Yes
</label>
</div>
<div class="input-binary">
<label>
<input type="radio" id="wind_turbine"
name="wind_turbine"
ng-model="$parent.wind_turbine"
value="No" force-model-update />
No
</label>
</div>
<span ng-bind="wind_turbine"></span>
<span>wind_turbine = {{wind_turbine}}</span>
</div>
</div>
</div>
I know that ng-if will create a new child scope. See above code, scope variable wind_trubine. Only in this HTML5 file, the curly braces {{}} is not working. However, if I use ng-bind it works fine. In other HTML5 files, I have no problem what so ever. This HTML5 is implemented using directive as follows:
app.directive('sitesAndImprovements', function() {
return {
restrict: 'E',
replace:true,
templateUrl: '<path-to-file>/site-and-improvments.html',
link: function (scope, elem, attrs) {
//Business Logic for Sites and Improvements
}
}
})
And, simply, I put it in the parent as follows:
<sites-and-improvements></sites-and-improvements>
The only difference I could see, is that this implementation has two levels of nested ng-if, which would look like the following:
<div ng-if="some_expression">
...
...
<sites-and-improvements></sites-and-improvements>
...
...
</div>
Based on comments, I used controller As notation and defined MainController accordingly. See snapshots below. It seems there is a problem if ng-if is nested with two levels. The scope variable is completely confused. I don't get the same results using ng-bind and double curly braces.
If you examine the above snapshots, even though I used controller As notation, you will see that ng-bind gives different results when compared with interpolation using {{}}.
I even changed the default value of wind_turbine to be set as follows in the link function:
scope.MainController.wind_turbine = 'Yes';
I noticed that on page load, everything looks fine, but when I change the value of the input element wind_trubine using the mouse, all related reference are updated correctly except the one that uses {{}}.
Maybe this is because there are two nested levels of ng-if?
Appreciate your feedback.
Tarek
Remove the replace: true from the sites-and-improvements directive:
app.directive('sitesAndImprovements', function() {
return {
restrict: 'E',
̶r̶e̶p̶l̶a̶c̶e̶:̶t̶r̶u̶e̶,̶
templateUrl: 'site-and-improvments.html',
link: function (scope, elem, attrs) {
//debugger;
}
}
})
It is fighting the check-if-required directive:
app.directive('checkIfRequired', ['$compile', '$timeout', function ($compile, $timeout) {
return {
priority: 2000,
terminal: true,
link: function (scope, el, attrs) {
el.removeAttr('check-if-required');
$timeout(function(){
//debugger;
$(':input', el).each(function(key, child) {
if (child && child.id === 'test_me') {
angular.element(child).attr('ng-required', 'true');
}
if (child && child.id === 'testInput1') {
//debugger;
//angular.element(child).attr('ng-required', 'true');
}
});
$compile(el, null, 2000)(scope);
})
}
};
}])
The DEMO on PLNKR.
replace:true is Deprecated
From the Docs:
replace ([DEPRECATED!], will be removed in next major release - i.e. v2.0)
specify what the template should replace. Defaults to false.
true - the template will replace the directive's element.
false - the template will replace the contents of the directive's element.
-- AngularJS Comprehensive Directive API - replace deprecated
From GitHub:
Caitp-- It's deprecated because there are known, very silly problems with replace: true, a number of which can't really be fixed in a reasonable fashion. If you're careful and avoid these problems, then more power to you, but for the benefit of new users, it's easier to just tell them "this will give you a headache, don't do it".
-- AngularJS Issue #7636
For more information, see Explain replace=true in Angular Directives (Deprecated)
Another solution posted by AngularJS team here:
https://github.com/angular/angular.js/issues/16140#issuecomment-319332063
Basically, they recommend to convert the link() function to use compile() function instead. Here is the update code:
app.directive('checkIfRequired', ['$compile', '$timeout', function ($compile, $timeout) {
return {
priority: 2000,
terminal: true,
compile: function (el, attrs) {
el.removeAttr('check-if-required');
var children = $(':input', el);
children.each(function(key, child) {
if (child && child.id === 'test_me') {
angular.element(child).attr('ng-required', 'true');
}
});
var compiled = $compile(el, null, 2000);
return function( scope ) {
compiled( scope );
};
}
};
}]).directive('sitesAndImprovements', function() {
return {
restrict: 'E',
replace:true,
templateUrl: 'site-and-improvments.html'
}
});
The main problem I have with this solution is that I am using the scope parameter which is passed to the link() function. For example, in the .each() loop above, I need to get the value of the element ID which is based on interpolation using {{<angular expre>}}.
So I tried to use pre-link and post-link within the compile function where the scope is available. I noticed that the section with ng-if is removed when execution is in pre-link and then it is added shortly after that. So I had to use $watch to monitor changes to the children to run the needed process when required. I developed this plunker sample:
http://plnkr.co/edit/lsJvhr?p=preview
Even after all such effort, the issue is not resolved. So the bottom line for similar cases, is that if you need to use the scope then you have to remove replace: true.
Any feedback would be appreciated.
Tarek

Add directive in loop based on criteria

I have a timeline.json like:
[{
"from":"twitter",
//...
},
{
"from": "facebook"
//...
}]
Based on from attribute I need "render" specific directives: post-twitter or post-facebook Inside a loop
How could I do that?
There are several ways. You can use ng-switch, ng-if, or a custom handler function in the link phase. You can find more info in the documentation.
Basically, switch between tags that invoke the directive based on a conditional statement, being it the 'from' value in your model. You can combine for example the ng-if directive with ng-include, to render a portion of your template based on this condition. Just make sure it won't degrade the performance.
You can create a directive which compiles wanted directive depending on the passed argument.
Something like:
.directive('renderOther', function($compile) {
return {
restrict: 'E',
scope: {
type: '='
},
link: functtion(scope, elem, attrs) {
var dir = $('<post-' + scope.type + '></post-'+scope.type+'>');
dir.appendTo(elem);
$compile(elem.contents())(scope);
}
}
});
Html:
<div ng-repeat="i in items">
<render-other type="i.from"></render-other>
</div>
(Its not working code - just an idea)

Angular - condition, transclude

I've written a sample directive with a conditional content (component.html):
<div class="panel panel-default">
<div class="panel-heading">{{title}}</div>
<div class="panel-body">
<p ng-show="loadingVisible()" class="text-center">Loading...</p>
<div ng-show="!loadingVisible()" ng-transclude></div>
</div>
Directive code (component.js):
app.directive('component', function () {
return {
restrict : 'A',
transclude : true,
replace : true,
templateUrl : 'component.html',
require : '^title',
scope : {
title : '#',
loadingVisible : '&'
},
controller : [ '$scope', function ($scope) {
if (!$scope.loadingVisible) {
$scope.loadingVisible = function () {
return false;
};
}
} ]
};
});
The main use of this directive is something like this (sample.html):
<div component title="Title">
<div id="t"></div>
</div>
And the controller code (sample.js):
app.directive('sample', function () {
return {
restrict: 'A',
templateUrl: 'sample.html',
controller: [ '$scope', function ($scope) {
$('#id');
} ]
};
});
0
The problem is that the div aquired by using jQuery selector isn't visible. I guess it's because the loadingVisible method (conditional content) hides that div in the construction phase. So when the sample directive tries to get it it fails. Am I doing something wrong? What's the coorect resolution of this problem? Or maybe my knowledge of directives is wrong?
I'll appreciate any help :)
if you're trying to interact with the DOM (or the directive element itself), you'll want to define a link function. The link function gets fired after angular compiles your directive and gives you access to the directives scope, the element itself, and any attributes on the directive. so, something like:
link: function (scope, elem, attrs) {
/* interact with elem and/or scope here */
}
I'm still a little unclear about what your directive is trying to accomplish though, so it's tough to provide much more help. Any additional details?
so if you want to ensure that a title is specified, you can just check for the presence of the title scope var when the directive gets linked, then throw an error if it's not there.
also, is there any reason loadingVisible needs to be an expression? (using '&' syntax). If you just need to show/hide content based on this value, you could just do a normal one-way '#' binding. so overall, something like:
app.directive('component', function () {
return {
restrict : 'A',
transclude : true,
replace : true,
templateUrl : 'component.html',
scope : {
title : '#',
loadingVisible : '#'
},
link : function (scope, elem, attrs) {
if (!scope.title) {
throw 'must specify a title!';
}
if (!attrs.loadingVisible) {
scope.loadingVisible = false;
}
}
};
});
If you need to get access to any of your transcluded content, you can use the elem value that gets injected into your link function, like so:
elem.find('#a');
an (updated) working plnkr: http://embed.plnkr.co/JVZjQAG8gGhcV2tz1ImK/preview
The problem is that directive structure is like this:
<div component>
<div id="a"></div>
</div>
The directive is used somewhere like this:
asd
The test directive uses a "a" element in its controller (or link) function. The problem is that the test controller code is run before the div is transcluded and it cannot see the content :/ A simple workaround is that the component should be before the test directive. Do you have any other solutions to this problem?

Detect if a transclude content has been given for a angularjs directive

I have a directive (a progressbar) which should have two possible states, one without any description and one with a label on the left side.
It would be cool to simple use the transcluded content for this label.
Does anyone know how I can add a class to my directive depending whether a transclude content has been given or not?
So I want to add:
<div class="progress" ng-class="{withLabel: *CODE GOES HERE*}">
<div class="label"><span ng-transclude></span>
<div class="other">...</div>
</div>
Thanks a lot!
After release of Angular v1.5 with multi-slot transclusion it's even simpler. For example you have used component instead of directive and don't have access to link or compile functions. Yet you have access to $transclude service. So you can check presence of content with 'official' method:
app.component('myTransclude', {
transclude: {
'slot': '?transcludeSlot'
},
controller: function ($transclude) {
this.transcludePresent = function() {
return $transclude.isSlotFilled('slot');
};
}
})
with template like this:
<div class="progress" ng-class="{'with-label': withLabel}">
<div class="label"><span ng-transclude="slot"></span>
<div class="other">...</div>
</div>
Based on #Ilan's solution, you can use this simple $transclude function to know if there is transcluded content or not.
$transclude(function(clone){
if(clone.length){
scope.hasTranscluded = true;
}
});
Plnkr demonstrating this approach with ng-if to set default content if nothing to transclude: http://plnkr.co/hHr0aoSktqZYKoiFMzE6
Here is a plunker: http://plnkr.co/edit/ednJwiceWD5vS0orewKW?p=preview
You can find the transcluded element inside the linking function and check it's contents:
Directive:
app.directive('progressbar', function(){
return {
scope: {},
transclude: true,
templateUrl: "progressbar.html",
link: function(scope,elm){
var transcluded = elm.find('span').contents();
scope.withLabel = transcluded.length > 0; // true or false
}
}
})
Template:
<div class="progress" ng-class="{'with-label': withLabel}">
<div class="label"><span ng-transclude></span>
<div class="other">...</div>
</div>
You can also create your custom transclusion directive like so:
app.directive('myTransclude', function(){
return {
link: function(scope, elm, attrs, ctrl, $transclude){
$transclude(function(clone){
// Do something with this:
// if(clone.length > 0) ...
elm.empty();
elm.append(clone);
})
}
}
})
Based on the solution from #plong0 & #Ilan, this seems to work a bit better since it works with whitespace as well.
$transcludeFn(function(clonedElement) {
scope.hasTranscludedContent = clonedElement.html().trim() === "";
});
where previously <my-directive> </my-directive> would return that it has a .length of 1 since it contains a text node. since the function passed into $transcludeFn returns a jQuery object of the contents of the transcluded content, we can just get the inner text, remove whitespace on the ends, and check to see if it's blank or not.
Note that this only checks for text, so including html elements without text will also be flagged as empty. Like this: <my-directive> <span> </span> </my-directive> - This worked for my needs though.

Avoid using extra DOM nodes when using nginclude

I'm struggling to wrap my mind around how to have an ng-include not use an extra DOM element as I'm building an angular app from a plain-HTML demo. I'm working with pretty slim HTML with fully developed, tightly DOM-coupled CSS (built from SASS) and refactoring is something I want to avoid at all costs.
Here's the actual code:
<div id="wrapper">
<header
ng-controller="HeaderController"
data-ng-class="headerType"
data-ng-include="'/templates/base/header.html'">
</header>
<section
ng-controller="SubheaderController"
data-ng-class="subheaderClass"
ng-repeat="subheader in subheaders"
data-ng-include="'/templates/base/subheader.html'">
</section>
<div
class="main"
data-ng-class="mainClass"
data-ng-view>
</div>
</div>
I need <section> to be a repeating element but have its own logic and different content. Both, content and number of repetitions are dependent on business logic. As you can see, putting the ng-controller and the ng-repeat on the <section> element will not work. What would, however, is to insert a new DOM node, which is what I'm trying to avoid.
What am I missing out? Is this best practice or is there a better way?
EDIT: just to clarify as asked in comments, the final HTML I'm trying to generate would be:
<div id="wrapper">
<header>...</header>
<section class="submenuX">
some content from controller A and template B (e.g. <ul>...</ul>)
</section>
<section class="submenuY">
different content from same controller A and template B (e.g. <div>...</div>)
</section>
<section class="submenuZ">
... (number of repetitions is defined in controller A e.g. through some service)
</section>
<div>...</div>
</div>
The reason I want to use the same template B (subheader.html), is for code cleanliness. I conceive subheader.html to have some kind of ng-switch in order to return dynamic content.
But basically, the underlaying quiestion is: is there a way to include the contents of a template transparently, without using a DOM node?
EDIT2: The solution needs to be reusable. =)
Some of the other answers suggest replace:true, but keep in mind that replace:true in templates is marked for deprecation.
Instead, in an answer to a similar question, we find an alternative: It allows you to write:
<div ng-include src="dynamicTemplatePath" include-replace></div>
Custom Directive:
app.directive('includeReplace', function () {
return {
require: 'ngInclude',
restrict: 'A', /* optional */
link: function (scope, el, attrs) {
el.replaceWith(el.children());
}
};
});
(cut'n'paste from the other answer)
Edit: After some research and for the sake of completeness, I've added some info. Since 1.1.4, the following works:
app.directive('include',
function () {
return {
replace: true,
restrict: 'A',
templateUrl: function (element, attr) {
return attr.pfInclude;
}
};
}
);
Usage:
<div include="'path/to/my/template.html'"></div>
There is, however, one gotcha: the template cannot be dynamic (as in, passing a variable through scope because $scope, or any DI for that matter, is not accessible in templateUrl - see this issue), only a string can be passed (just like the html snippet above). To bypass that particular issue, this piece of code should do the trick (kudos to this plunker):
app.directive("include", function ($http, $templateCache, $compile) {
return {
restrict: 'A',
link: function (scope, element, attributes) {
var templateUrl = scope.$eval(attributes.include);
$http.get(templateUrl, {cache: $templateCache}).success(
function (tplContent) {
element.replaceWith($compile(tplContent.data)(scope));
}
);
}
};
});
Usage:
<div include="myTplVariable"></div>
You can create a custom directive, linking to the template with the templateUrl property, and setting replace to true:
app.directive('myDirective', function() {
return {
templateUrl: 'url/to/template',
replace: true,
link: function(scope, elem, attrs) {
}
}
});
That would include the template as-is, without any wrapper element, without any wrapper scope.
For anyone who happens to visit this question:
As of angular 1.1.4+ you can use a function in the templateURL to make it dynamic.
Check out this other answer here
With the right setup, you can define your own ngInclude directive that can run instead of the one provided by Angular.js and prevent the built-in directive to execute ever.
To prevent the Angular-built-in directive from executing is crucial to set the priority of your directive higher than that of the built-in directive (400 for ngInclude and set the terminal property to true.
After that, you need to provide a post-link function that fetches the template and replaces the element's DOM node with the compiled template HTML.
A word of warning: this is rather draconian, you redefine the behavior of ngInclude for your whole application. I therefore set the directive below not on myApp but inside one of my own directives to limit its scope. If you want to use it application-wide, you might want to make its behavior configurable, e.g. only replace the element if a replace attribute is set in the HTML and per default fall back to setting innerHtml.
Also: this might not play well with animations. The code for the original ngInclude-directive is way longer, so if you use animations in your application, c&p the original code and shoehorn the `$element.replaceWith() into that.
var includeDirective = ['$http', '$templateCache', '$sce', '$compile',
function($http, $templateCache, $sce, $compile) {
return {
restrict: 'ECA',
priority: 600,
terminal: true,
link: function(scope, $element, $attr) {
scope.$watch($sce.parseAsResourceUrl($attr.src), function ngIncludeWatchAction(src) {
if (src) {
$http.get(src, {cache: $templateCache}).success(function(response) {
var e =$compile(response)(scope);
$element.replaceWith(e);
});
}
});
}
};
}];
myApp.directive('ngInclude', includeDirective);

Resources