AngularJS dynamic recompilation inside ng-repeat - angularjs

I have a huge table of data displayed with AngularJS (1.7.x). The amount and complexity of data dictated that the data is bound-once inside an ng-repeat like this:
<div ng-repeat="item in [{...}, {...}, ...] track by $index">
<div>{{ ::item.col1 }}</div>
<div>{{ ::item.col2 }}</div>
...
<div>{{ ::item.colx }}</div>
</div>
The problem comes when I try to update some cells, say, column 5 in row 233. It is done by dynamically recompiling and reinserting the very same HTML markup into the right DOM node but I just cannot find a way to get it compiled within the current context.
I followed Angularjs: Compile ng-repeat dynamically but I just cannot reproduce the "recompile" effect. Everything is evaluated with the scope I provide at $compile() which, obviously, cannot see "item" from the ng-repeat.
Edit: I formatted the HTML above a bit and added some sample code. I did not include it in the original post as I feared it's far from how it should be done but you are right, it may provide you more insight.
Directive (taken from the directive mentioned in the linked answer):
app.directive('recompile', function ($compile) {
return {
restrict: 'A',
compile: function (element) {
element.removeAttr('recompile');
var compile = $compile(element);
return function (scope, element) {
compile(scope);
}
}
}
});
Code that tries to make AngularJS rebind a cell:
// by now, column 5 of row 233 got removed from DOM
// $prevnode points column 4 of row 233
var nodest = '<div recompile>{{ ::item.col5 }}</div>';
var comp = $compile(nodest)($scope);
$prevnode.after(comp);
The result is always an empty cell as "item" resolves to undefined. No matter if I use the "recompile" directive or not. $scope variables are resolved fine.

Finally, I managed to solve my problem. For some reason, I did not know I can access the current scope by DOM using angular.element(...).scope()! No directive or other trickery needed, it's this simple:
// get current item's $scope
var $itemscope = $prevnode.scope();
var nodest = '<div>{{ ::item.col5 }}</div>';
// compile with the said scope
var comp = $compile(nodest)($itemscope);
$prevnode.after(comp);
item is correctly resolved now and the cell reflects the value that has been changed in the datasource of the ng-repeat.

Related

Using Two-Way Binding with Custom Directive and ng-repeat

Starting this off by saying that I know a common answer for this is to put the ng-repeat inside the content of the directive, but in this case I can't figure out how that will work for this.
Basic Problem
As the charCounter increases throughout the letter spans, I need to access the offsetTop value of each span in the ng-repeat in order to do some things, (When the charCoutner gets to a new line (has offsetTop > 0), adjust the begin variable that is in the limitTo in the ng-repeat).
Struggling Point
I am not able to update a variable in the custom directive and make it accessible to the ng-repeat.
If I have the custom directive outside of the ng-repeat I have no access to the offsetTop of each span (but the begin variable updates).
<p><span shell counter="charCounter" begin="begin"><span ng-repeat="letter in data | limitTo: limit : begin track by $index"><span>{{letter}}</span></span></span></p>
What I'm thinking that needs to happen is this, but how do I get the begin variable to be updated?
<p><span positioner counter="charCounter" begin="begin" ng-repeat="letter in data | limitTo: limit : begin track by $index"><span>{{letter}}</span></span></p>
Code Pen Sample
I've been playing around with this on Code Pen, You can see this problem in code pen here:
UPDATE
I've taken the example one step further and also integrated r0m4n's feedback about upping the priority of this and #Amy Blankenship's about further clarifying what I am trying to do. Here is the updated CodePen. While this technically works, I'm not thinking I'd even need to do a custom directive now, since I'm accessing the element manually rather than using the element from the directive. I still have a delicate understanding of all this.
Your scope binding is having some priority issues. Take a look at using the priority property. Per the docs:
"When there are multiple directives defined on a single DOM element, sometimes it is necessary to specify the order in which the directives are applied"
So you could do something like this:
myApp.directive('positioner', function($timeout) {
return {
restrict: 'A',
priority: 1001,
scope: {
letter: '=',
begin: '=',
counter: '='
},
link: function($scope, element, attr) {
$timeout(function() {
$scope.begin += 5;
console.info('begin', $scope.begin);
}, 1000);
}
};
});
https://docs.angularjs.org/api/ng/service/$compile

Why is binding lost when nesting directive in directive?

The Issue
Using my directive within another directive causes certain bindings to be lost, specifically in the ng-repeat usage.
The directive is used in many areas of my application without issue. It renders a list of inputs which is passed to it's scope from a parent template, like so:
<filter-header filters="filters"></filter-header>
Working Scenario
I have used the following scenario throughout the application and not come across an issue to date.
$routeProvider resolves filters list with WebAPI call for a controller
controller assigns list to its own scope, like so: $scope.filters = filters
template uses filter-header element and passes filters from it's scope to the directive, like so: <filter-header filters="filters"></filter-header>
The filter-header directive then renders the filters using an ng-repeat without issue. $$hashKey is present in each item of the filters collection, indicating the binding's existence.
Failing Scenario
In the following scenario, the binding seems to be lost and the ng-repeat fails to render anything.
$routeProvider resolves filters list with WebAPI call for a controller
controller assigns list to its own scope, like so: $scope.filters = filters
template uses a new element directive, assigns filters from it's scope to the new directive's via an attribute.
directive's template uses filter-header element and passes filters from it's scope to the directive, like so: <filter-header filters="filters"></filter-header>
The filter-header directive then FAILS TO render the filters using an ng-repeat. $$hashKey is NOT present in any item of the filters collection.
Annoyingly, I cannot replicate this in Plunker...
Oddity
The directive has another collection of item's passed to it, columns="columns" (can be seen in the code below). Columns binds correctly and is rendered in it's own ng-repeat. I cannot see how Columns is different from Filters as both are used almost exactly the same way.
Looking Deeper...
I have debugged the process all the way. The filters object is getting all the way to the end scope successfully. If I output the contents of filters to the screen within the final directive, using {{ filters }} I can see all of the filters as expected. However, in the very next line where my ng-repeat begins, no filters are iterated through.
To be certain it is not my list causing issues, I used a list that already works using the working scenario mentioned above and the ng-repeat does not render here.
To be certain it is not my directive's code causing issues, I converted it to a controller and routed directly to it (skipping the nested directive) as in the working scenario mentioned above and the ng-repeat now works.
Using $log to inspect the list, I notice one difference. In the working scenario, all lists contain a $$hashKey property for each item in the list. In the failing scenario, the $$hashKey is missing on all items in the list. This seems to indicate that the binding is being lost for some reason.
Can someone tell me the error in my ways? The only real difference I can see in my usage is that I pass the object to an middle-man directive before before passing it on the the directive where it is used. Strangely, in the very same directive, another list is used in a very similar way and it renders without issue within it's ng-repeat, and it's item's all have the $$hashKey property appended.
Code
There's a lot of code involved, so I'll try and pick out the relevant parts.
RouteProvider
$routeProvider.when('/Pride/Admin/AuditForms/:id', {
templateUrl: '/Templates/Admin/editAuditForm.html',
controller: 'editAuditFormController',
resolve: {
sectionFilters: function (auditFormSectionRepository) {
return auditFormSectionRepository.getFilters().$promise;
},
sectionColumns: function (auditFormSectionRepository) {
return auditFormSectionRepository.getColumns().$promise;
}
}
});
EditAuditForm Controller
prideModule.controller("editAuditFormController", function ($scope, sectionFilters, sectionColumns) {
$scope.sectionFilters = sectionFilters;
$scope.sectionColumns = sectionColumns;
});
EditAuditForm Template
<audit-admin-sections audit-form="auditForm" section-filters="sectionFilters" section-columns="sectionColumns" show-deleted="false"></audit-admin-sections>
AuditAdminSections Directive
prideModule.directive('auditAdminSections', function ($log) {
return {
restrict: 'E',
templateUrl: 'templates/admin/auditFormSections.html',
scope: {
sectionFilters: '=',
sectionColumns: '='
},
controller: function ($scope, $route, $timeout, $location, filterLogic, auditFormSectionRepository) {
// do stuff
}
});
AuditFormSections Template
<filter-header filters="sectionFilters" columns="sectionColumns"></filter-header>
FilterHeader Directive
prideModule.directive('filterHeader', function($log) {
return {
restrict: 'E',
templateUrl: 'templates/common/filterHeader.html',
scope: {
filters: '=',
columns: '='
},
controller: function ($scope, filterItemsRepository) {
$log.info("$scope.filters");
$log.info($scope.filters);
// This will log the filters as expected, however the $$hashKey property is missing from the items
}
});
FilterHeader template
<!-- at this point, {{ filters }} produces the list of filters -->
<form class="form-horizontal" ng-repeat="filter in filters">
<!-- at this point, nothing renders -->
<label class="col-sm-2 control-label">{{ filter.friendlyName }}</label>
</form>
Update 1
I pulled the code out of the directive and into a new controller to mimic the Working Scenario mentioned above. The ng-repeater now functions as expected and the $$hashKey is present again. So something is definitely related to the difference between route->controller->directive->directive vs route->controller->directive.
Worth mentioning, on top of the code above, there are watches on the filters among other usages.
Update 2: Offender Uncovered
I've nailed it. But it makes no sense yet. It seems as though the form element is to blame. Changing to a div resolves the issue. I'm thinking this may be an angular bug as I'm struggling to see why this could work in one scenario and not the other.
Have discovered two fixes so far, but they are more hacks than fixes as I cannot understand the cause of the original problem. I'm still interested if anyone can point out the real issue, but until then this is the best I have:
Solution 1
After much researching, debugging, head scratching, rewriting, I by luck came across a solution. I'm not happy with it, as it doesn't make sense (unless someone can elaborate for me).
The problem seems to be with the form element and using the ng-repeat attribute on it while being nested in angular directives...!!! This has to be a bug, right?
Solution was as simple as changing this:
<form class="form-horizontal" ng-repeat="filter in filters">
<label class="col-sm-2 control-label">{{ filter.friendlyName }}</label>
</form>
to this:
<div class="form-horizontal" ng-repeat="filter in filters">
<label class="col-sm-2 control-label">{{ filter.friendlyName }}</label>
</div>
Solution 2
It seems that the $scope variables were also playing a part in the issue. If I rename each directive's $scope variable name for this particular variable (ie, filters) so that it is unique for each directive, the form ng-repeat works. This makes it seem like there is some sort of conflict within the directives' isolated scopes, however why this is only an issue for form ng-repeat baffles me. As such, it still doesn't explain to me what is the root cause of this behavior.

d3-driven directive transition doesn't work inside ng-repeat

I am trying to include my d3 code inside a directive.
However, when my directive is inside a ng-repeat, the transitions won't take place.
Here's a JSFiddle of the issue: http://jsfiddle.net/hLtweg8L/1/ : You can see that when you click on the button, the rectangles position doesn't change smoothly, and 'append' is logged to the console once again.
My directive is the following:
myMod.directive('chart',function(){
return {
restrict:'A',
scope:{
data:'=',
},
link:function(scope,elem,attrs){
a=d3.select(elem[0]);
rects=a.selectAll("rect").data(scope.data,function(d));
rects.enter().append("rect")
.attr("x",function(d,i){console.log('append');return i*50+"px"})
.attr("y",100)
.attr("width",35)
.attr("height",function(d){return d.age*10+"px"})
.attr("fill","blue")
rects.transition().duration(200)
.attr("x",function(d,i){console.log('append');return i*50+"px"})
.attr("y",100)
.attr("width",35)
.attr("height",function(d){return d.age*10+"px"})
.attr("fill","blue")
}
}
})
As far as I understand it, the problem is that the elem passed inside the link function is not the same when the ng-repeat gets updated, that's why the append gets called more than once for the same data.
My question is: How can I use d3 transitions inside ng-repeat ? (Corrected Jsfiddle would help a lot). Or why is the elem not the same between different calls ? Can I tell angular that the dom shouldn't be removed and added again ?
A couple things are needed:
If you don't want ng-repeat to create a new element, you need to use the track by option so that it knows how to identify new vs. changed items:
<div ng-repeat="set in sets track by set.group">
D3 will not automatically see that the data has changed unless your directive watches for changes.
a=d3.select(elem[0]);
scope.$watch('data', function() {
updateGraph();
});
Here is an an alternate Fiddle:
http://jsfiddle.net/63tze4Lv/1/

How to find a watch in scope.$$watchers

I'm using angularjs and need to find the watch of the ng-repeat, because I need ng-repeat to stop working from a specific point. this my code:
<ul>
<li ng-repeat="item in items">
<div>{{item.name}}</div>
</li>
</ul>
I need to find the watcher of the ng-repeat. If I go to scope.$parent.$$watchers[x] and perform splice the watch is removed, but how can I find the specific watch?
It's not possible to find the watch (see explanation below), but you can achieve what you wish by use the following directive
app.directive('repeatStatic',
function factory() {
var directiveDefinitionObject = {
restrict : 'A',
scope : true, // this isolates the scope from the parent
priority : 1001, // ng-repeat has 1000
compile : function compile() {
return {
pre : function preLink(tElement, tAttrs) {
},
post : function postLink(scope, iElement, iAttrs, controller,
transcludeFn) {
scope.$apply(); // make the $watcher work at least once
scope.$$watchers = null; // remove the $watchers
},
};
}
};
return directiveDefinitionObject;
}
);
and its usage is
<ul>
<li repeat-static ng-repeat="item in items">
{{ item }}
</li>
</ul>
See http://plnkr.co/k9BTSk as a working example.
The rational behind is that
the angular directive ng-repeat directive uses internal function $watchCollection to add a self created listener that watchs the items object. Since the listener is a function created during the process, and is not keep anywhere as reference, there is no good way to correctly identify which function to remove from the $$watchers list.
However a new scope can be forced into the ng-repeat by using an attribute directive, in this way the $$watchers added by ng-repeat are isolated from the parent scope. Thus, we obtain full control of the scope.$$watchers. Immediate after the ng-repeat uses the function that fills the value, the $$watchers are safe to be removed.
This solution uses hassassin's idea of cleaning the $$watchers list.
I have a fork of Angular that lets you keep the watch in the $$watchers but skip it most of the time. Unlike writing a custom directive that compiles your HTML it lets you use normal Angular templates on the inside, the difference is that once the inside is fully rendered the watches will not get checked any more.
Don't use it unless you really genuinely need the extra performance because it can have surprising behaviour.
Here it is:
https://github.com/r3m0t/angular.js/tree/digest_limit
Here's the directive you can use with it (new-once):
https://gist.github.com/r3m0t/9271790
If you want the page to never update you can use new-once=1, if you want it to sometimes update you can use new-once=updateCount and then in your controller run $scope.updateCount++; to trigger an update.
More information: https://github.com/Pasvaz/bindonce/issues/42#issuecomment-36354087
The way that I have dealt with this in the past is that I created a custom directive that copies the logic of the built in ngRepeat directive but never sets up the watches. You can see an example of this here, which was created from the ngRepeat code from version 1.1.5.
The other way, as you mentioned was to remove it from $$watchers of a scope, which is a little stranger since it accesses a private variable.
How this could be done is that you create a custom directive on the repeat to remove the watch that is the repeat. I created a fiddle that does this. It basically just on the last element of the repeat clears the parent watch (which is the one on data)
if (scope.$last) {
// Parent should only be the ng-repeat parent with the main watch
scope.$parent.$$watchers = null;
}
This can be modified to fit your specific case.
Hope this helps!
It sounds like you want to put the bindonce directive on your ng-repeat.
https://github.com/Pasvaz/bindonce
If you don't need angular dual-binding, have you tried a custom directive with a complie function where you construct HTML yourself by creating DOM from scratch without any angular dual-binded mechanisms ?

AngularJS : ng-repeat doesn't detect changes in scope

I've been reading through various post, questions, answers and documentation but haven't managed to solve my problem so far.
I'm using mbExtruder jQuery plugin, and to integrate it within angular, I've created a directive for it:
directive('mbExtruder', function($compile) {
return {
restrict : 'A',
link : function(scope, element, attrs) {
var mbExtruderConfigurationAttrs = scope.$eval(attrs.mbExtruder);
mbExtruderConfigurationAttrs.onExtContentLoad = function() {
var templateElement = angular.element(angular.element(element.children()[0]).children()[0]);
var clonedElement = $compile(templateElement)(scope);
scope.$apply();
var contentElement = angular.element(angular.element(angular.element(element.children()[0]).children()[0]).children()[0]);
contentElement.replaceWith(clonedElement.html());
};
element.buildMbExtruder(mbExtruderConfigurationAttrs);
$.fn.changeLabel = function(text) {
$(this).find(".flapLabel").html(text);
$(this).find(".flapLabel").mbFlipText();
};
I'm extracting the container div, compiling it, applying scope and replacing the original div with the transformed one.
Now, I'm using the ability of mbExtruder to load contents from separate HTML file which looks like this:
<div>
<ul>
<li ng-repeat="property in properties">
<span><img ng-src="../app/img/{{property.attributes['leadimage']}}"/></span>
<a ng-click="openDetails(property)">{{property.attributtes['summary']}}</a>
</li>
<div ng-hide="properties.length">No data</div>
</ul>
</div>
And, in the HTML of the view I have following:
<div id="extruderRight"
mb-extruder="{position: 'right', width: 300, positionFixed: false, extruderOpacity: .8, textOrientation: 'tb'}"
class="{title:'Lista', url:'partials/listGrid.html'}">
</div>
The scope I'm getting in the directive is the scope of the parent controller which actually handles properties array.
The thing is that, if the properties list is pre-populated with some data, that ends up in the compiled HTML - cool. But, any change to properties does actually nothing. I've added watch handler on properties within directive and really, that is triggered whenever any change is made to properties, but, ng-repeat does not pick that up. The original idea is to have properties list empty in the beginning - that causes ng-repeat compile to have just this output:
<!-- ngRepeat: property in properties -->
Is this doing a problem? The fact that ng-repeat declaration has actually disappeared from DOM.
Am I missing something obvious here? I've read the documentation on $compile and ng-repeat and I would say that I don't need to manipulate DOM by myself, ng-repeat should do its work. Or I'm totally wrong?
Thanks.
EDIT: Plunker
You're passing in
contentElement.replaceWith(clonedElement.html());
Notice here that you're essentially replacing it with raw HTML string code. Hence, Angular has no concept of the directives inside the code. However, I don't see why this is needed at all. Just compiling and attaching the result to scope seems to work just fine:
var templateElement = angular.element(angular.element(element.children()[0]).children()[0]);
$compile(templateElement)(scope);
Here's the updated, working version:
http://plnkr.co/edit/gxPAP43sx3QxyFzBitd5?p=preview
The documentation for $compile didn't say too much, but $compile returns a function, which you then call with the scope you want to attach the element to (as far as I've understood). Hence, you don't need to store the reference to the element at all, unless you want to use it later.

Resources