I'm currently working on a project that will need to display editable tables of arbitrary dimensions. I'm using AngularJS, but as the tables can get very large I expect things will get slow if I don't use some form of bind-once. The problem is, as far as I can tell, bind-once is absolute -- I want it to actually keep or at least reinstate all the watchers for rows currently being edited.
Here's the basic idea of how I'm currently doing this.
<tr ng-repeat="row in rows track by key" hackonce>
<td ng-repeat="col in cols">
<div ng-click="editStuff()">{{contents}}
directive('hackonce', function() etc
link: function(scope, elem, attrs) {
if (!scope.row.editing)
setTimeout(function(){ scope.$destroy() }, 0)
}
I'm generating a string key on the row from its various identifiers. When the item is flagged for editing, the controller switches it out for a copy and changes its key to include what will eventually be transaction lock data. This triggers a replacement in the ng-repeat, and the next time the tr is constructed with the replaced row data, the scope is left intact and updates as you edit stuff.
Somehow, it actually works (so far anyway). It drops me from over 500 watchers on a small list to under 100, and what remains is mostly top-level controls, so I hope it will scale well. It also seems to retain and properly clean up ng-click listeners and such even though the scope is gone.
However, it's the sort of hack that I cringed just typing it out in brief, and I get the feeling it may induce vomiting in sane people who know this library better than I do. I was wondering if there was a cleaner and/or safer way to do the same thing. What I'm looking for is a better way to conditionally bind once, or have an element/scope otherwise skip its $digest for itself and all its children based on some exposed flag.
Is there any way to do this that doesn't involve taking a meat cleaver to the scope or manually reattaching all the jQuery garbage I was hoping to avoid with Angular?
It's kind of difficult without more implementation detail, but I'm assuming that you're using some mechanism to show/hide the read-only/editable elements for each row? If you use ng-if to do it, it will attach the elements to the DOM and re-bind when the condition is true and detach when false.
So, something like:
<td ng-repeat="col in cols">
<span ng-if="!isEditing(row)">Read-only stuff here</span>
<span ng-if="isEditing(row)">Editable stuff here.</span>
</td>
I created a really simple example plunk here:
http://plnkr.co/edit/SkdgTuK5UvEEsZmzWat6?p=preview
Related
I am using a knockout foreach (more specifically, template: { foreach: items }) binding to display a list of elements.
I then proceed to take the following actions:
Swap the first and second elements of the observable array. I see the changes reflected on screen, as expected.
Repeat the previous action to revert to the initial state. Again, this works as expected.
Now, swap the first and second DOM elements. I see the changes reflected on screen, as expected.
Repeat the previous action to revert to the initial state. Again, this works as expected.
Even though we have manually tampered with the DOM, we have reverted to exactly the initial state, without invoking knockout during the DOM tampering. This means the state is restored to the last time knockout was aware of it, so it should look to knockout as if nothing ever changed to begin with.
However, if I perform the first action again, that is, swap the first two elements in the array, the changes are not reflected on screen.
Here is a jsfiddle to illustrate the problem: https://jsfiddle.net/k7u5wep9/.
I know that manually tampering with the DOM managed by knockout is a bad idea and that it can lead to undefined behaviour. This is unfortunately unavoidable in my situation due to third party code. What stumps me is that, even after reverting the manual edits to the exact initial state, knockout still does not work as expected.
My question is: what causes this behaviour?
And then, how does one work around it?
Turns out there is nothing magical happening here. The mistake I made was to only consider elements instead of all nodes. The knockout template binding keeps a record of all nodes when reordering, not just elements.
Before manually editing the DOM, the child nodes of the template binding are:
NodeList(6) [text, div, text, text, div, text].
After manually swapping the first two elements using parent.insertBefore(parent.children[1], parent.children[0]), this turns into:
NodeList(6) [text, div, div, text, text, text].
Repeating the action yields:
NodeList(6) [text, div, div, text, text, text].
Although this is identical to the initial state when only referring to elements, it is quite different when referring to all nodes.
The solution now becomes clear. One way to perform a proper manual swap is to replace
parent.insertBefore(parent.children[1], parent.children[0]);
with
let nexts = [parent.children[0].nextSibling, parent.children[1].nextSibling];
parent.insertBefore(parent.children[1], nexts[0]);
parent.insertBefore(parent.children[0], nexts[1]);
as seen in https://jsfiddle.net/k7u5wep9/2/.
Obviously more care has to be taken when there are no text nodes before/after, but the idea remains the same.
By manipulating the DOM, you have broken the binding made.
Do not manipulate directly the DOM. Knockout will not detect the changes made.
If you put a with: items around your foreach, it at least keeps working but requires double click if dom order != array order .. might get you on track atleast, maybe you can re-order the ko-array inside the dom function to keep their 'orders' in sync?
let vm = {
items: ko.observableArray(['item1', 'item2']),
reorder_array() {
vm.items([vm.items()[1], vm.items()[0]]);
},
reorder_dom() {
let parent = document.querySelector('#items');
parent.insertBefore(parent.children[1], parent.children[0]);
vm.reorder_array();
}
};
ko.applyBindings(vm);
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
<div data-bind="with: items">
<div id="items" data-bind="template: { foreach: $data }">
<div data-bind="text: $data"></div>
</div>
</div>
<button data-bind="click: reorder_array">Reorder array</button>
<button data-bind="click: reorder_dom">Reorder DOM</button>
<div>
Reorder the array twice, then reorder DOM twice. This should work as expected, and end up with the initial state. Then, try to reorder the array again. It should not work. Why?
</div>
I have a simple DOM hierarchy and I want to grab a specific set of elements (I want all the canvas elements). This is the entire template for this directive:
<div id='charts-container'>
<div class='chart-wrapper' ng-repeat='chart in getNumberOfCharts() track by $index'>
<canvas id="{{'exam-chart-' + $index}}" class='chart-canvas'></canvas>
</div>
</div>
I want to create a list with all the canvas within the #charts-container element, but I just can't. This is what I'm trying to do inside the link function:
link: function (scope, element, attributes, controller) {
var look = element.find('#chart-canvas');
$log.debug(look);
}
And then I get this element, but I don't know how to get all the chart-wrapper elements from here.
I tried doing look.context.children, it returned an empty list, but at the same time showed me what I wanted as if the list were populated, here is an image. If I try to access any index of this list, it returns undefined (which is fine, because it's an empty list. But why the console is showing me these values?)
What is the best way to achieve this? To get all the canvas elements in this template? (there are 15 of them). Thanks!
--- UPDATE ---
I realized that if I remove the ng-repeat attribute I'm using in the template, it works! But I need the ng-repeat...
element.find('.chart-canvas') manage to get the canvas element, but only if there are no ng-repeat attribute.
Is this a common issue when using ng-repeat. Is there any special treatment when falling in this case?
I found a solution in this thread.
Apparently I was trying to access DOM that hasn't been rendered yet. So, wrapping my query for the canvas with
$timeout(function() {
var canvasList = element.find('.chart-canvas');
}, 0);
solved the issue, since using $timeout will wait until all $digest cycles are complete.
I would like to get to know better how the rendering pipeline works in AngularJS, to avoid falling in issues of this kind. If anyone has a link to a thread or explanation of this, it would be much appreciated.
I think it should be element.find('.chart-canvas') instead of element.find('#chart-canvas'). Does it work with that change ?
I have been developing an advanced web-based GUI in AngularJS. Recently, I decided to use the call document.getElementsByClassName() (I hate using element collecting methods, but here I had to use one) and my boss flipped his lid for accessing the document element. He says that I "need to use only Angular calls for everything", even for element collection! Is there an "Angular way" to collect elements by class name? If so, which way is better to use within the Angular framework? Please provide reasons why. Thanks!
UPDATE: Why I need to use an element collector...
So, I really wish I didn't have to do this, but I do...
I am using a third-party directive that I found online called the Bootstrap DateTimePicker. Its pretty cool and very nice looking, yet it might have a bug...
First, I make a directive bound to an attribute, stating that the element I pass in is meant to be a "DateTimePicker". I then pass that element to the DateTimePicker function.
When invoked, this function creates a new div with absolute positioning and appends it to the body of the page.
Now, I open a dialog in my GUI that has a table in it. On each row of the table, I have two DateTimePickers: one for end-date and one for start-date.
My problem is that, once I leave my screen and the elements which the DateTimePickers were bound to are destroyed, the DateTimePickers still remain! If I open the dialog box again, it creates a ton more of these divs as well!
Until I could determine a true solution to this issue, I decided to use the element collector as a temporary quick-fix. I grab all of the elements with the datetimepicker class and perform a:
elem[i].parentNode.removeChild(elem[i]);
Not having your exact use case but knowing that you are attempting to aggregate elements by class name in your controller makes me agree with you boss. Think of the controller as an object which exposes data and and services to your declarative html page. The data is bound into the markup for presentation and possible modification. THe services are usually wrapped in functions on your controller which are then tied to event handling directives like ng-click or ng-change. These services should operate exclusively on your data and never touch the DOM. If you need to modify a DOM element in your declarative markup then that should be done through directives like ng-class etc.
In any case, It would be useful to know what you are trying to accomplish so as to give you a better idea of the "angular way" to approach the problem.
Well, I have my answer. This does not solve the question "Grab all elements with a certain class name without touching the document element" yet it does solve my problem and eliminates my need to use document.getElementsByClassName.
First of all, it turns out that every element using the DateTimePicker directive have an element.datetimepicker("remove") function.
I use a directive for each DateTimePicker:
components.directive('DateTimePicker', function() {
// Requires bootstrap-datetimestamp.js
return {
restrict: 'E',
replace: true,
scope: {
dateTimeField: '='
},
template:
'<div>' +
'<input type="text" readonly data-date-format="yyyy-mm-ddThh:ii:ssZ" data-date-time required/>'+
'</div>',
link: function(scope, element, attrs, ngModel)
{
var input = element.find('input');
input.
datetimepicker(
{
//stuff
})
.on('changeDate', function(ev)
{
//more stuff
});
...
Directive drastically shortened for the sake of your eyeballs...
I then need to remove the DateTimePicker and the input it is bound to from the DOM on destruction of the dialog box that the input is a child of. To do so, I added this to my directive:
scope.$on("$destroy",function handleDestroyEvent()
{
input.datetimepicker("remove");
input = null;
});
And it works! The DateTimePicker gets removed, the DateTimePicker's handles to the input are cleaned up, and I've marked my input for the GC! WooHoo! Thanks everybody!
If you include jQuery in your project before AngularJS, Angular will use jQuery instead of jqLite for the angular.element function. This means you should be able to use jQuery's selectors for finding / referencing DOM elements.
It seems that getting an element in AngularJS is a bad idea, i.e. doing something like:
$('.myElement')
in say, a controller is not an angular way of doing things.
Now my question is, how should I get something in angular?
Right now, what I'm doing (and is an accepted way of doing it) is by watching a variable, and my directive does something based on it.
scope.$watch('varToWatch', function (varToWatch) {
if(attrs.id == varToWatch)
{
//Run my Directive specific code
}
});
However, while this particular design works for most cases, watch is an expensive operation, and having lots of directives watching can really slow down your application.
TL:DR - What is an angular way of getting a directive based on a variable on the directive? (like the one above)?
If you want to get/set values you don't need to fetch the element using jQuery. Angular data binding is the way to do it.
directives is the way to go if you want to do animations or any kind of element attributes and DOM manipulation.
Your code is basically right; the directive should watch something in the $scope and perform it's logic when that thing changes. Yes, watch statements are expensive, and that is a problem once your number of watches start to approach ~2000.
Looking at your code though, I see one problem:
The variable $scope.varToWatch references an id in the template.
When this variable changes, you want something to happen to the element which has this id.
The problem here is in the first point: The controller should know nothing about the DOM, including the id of any element. You should find another way to handle this, for example:
<div my-directive="one"> ... </div>
<div my-directive="two"> ... </div>
<div my-directive="three"> ... </div>
...etc
And in your directive:
scope.$watch('varToWatch', function (varToWatch) {
if(attrs.myDirective == varToWatch)
{
// Run my Directive specific code
}
});
You are very vague as to what you're trying to achieve, but I'll try to answer in context of your last comment.
I have a lot of the same directives (therefore the code will run on all of them), but I need to get only one directive from the lot.
You talk a lot about getting the right element. The directive element is passed to the link function in the directive. If you are not using this element (or children of it) directly, but rather trying to search for the element you want somehow, you are most likely approaching the problem the wrong way.
There are several ways to solve this, I'm sure. If you're thinking about animations, there is already support for that in Angular, so please don't try reinvent the wheel yourself. For other logic, here are two suggestions:
Secondary directive
If the logic you want to apply to this directive is generic, i.e. it could be applied to other directives in your application, you could create a new directive which works together with directives. You can set prioritization in directive in order to control which directive is executed first.
<main-directive ... helper-directive="{{condition_for_applying_logic}}"></main-directive>
jsFiddle example
Expanding main directive
If the logic is tightly coupled to this directive, you can just create a new attribute, either dynamic or static, and bind to it in the directive. Instead of checking 'attrs.id == varToWatch', you check if $scope.apply-logic === 'true' and apply the logic then.
<main-directive ...></main-directive> <!-- Not applied here -->
<main-directive apply-logic="true" ...></main-directive> <!-- Applied here -->
<main-directive apply-logic="{{some.varOnScope}}"...></main-directive> <!-- Conditional -->
Please comment if something is unclear.
I'm trying to think of the most elegant solution to handle multiple Fx.Slides within mootools. I'm developing a dictionary page with a very long list of words where there's a pair word -- translation and all the translations must be hidden by default showing just a word list. I'm looking for a solution that won't require creating a separate slide for each word on the page, so that they're created on-the-fly when a visitor clicks on a word because the size of the script and performance hit concern me. There's another problem in that their initial states must be set to 'hidden' beforehand and I don't want to do it in CSS (that would hide everything from people whose browsers don't support javascript).
Is anything of this sort possible or will I have to rely on creating slides in a loop (my element ids go like w01, w02, ...)? If so, how would I put that block inside a loop?
Check out this question regarding if the user does not have Javascript Embedding extra styles with noscript.
After that is taken care of we can concentrate in mootools. You want the elements to have visability: hidden when you load the page with Javascript. Give your elements a class so we can select them all at once. Example to initialize the elements.
$$('.sliders').each(function(el) {
el.slide('hide').setStyle('visibility', 'visible');
});
Now we need to handle the click event. The same goes here.
Example html:
<h3 class="slideIn" >Some title</h3>
<div class="sliders>Some lengthy text<div>
Example html:
$$('.slideIn').addEvent('click', function() {
this.getNext().getChildren('.sliders').slide();
});
Example fiddle: http://jsfiddle.net/b4Zjs/
Edit: If there are a lot of elements that should have click events it's better to use event delegation. Then you are only adding one event listener to the page, and it can make a huge difference some times.
$('parent').addEvent('click:relay(h3.slideIn)', function(event, target) {
target.getNext().getChildren('.sliders').slide();
});
jsFiddle example: http://jsfiddle.net/b4Zjs/2/