I have a basic application in AngularJS. The model contains a number of items and associated tags of those items. What I'm trying to achieve is the ability to filter the items displayed so that only those with one or more active tags are displayed, however I'm not having a lot of luck with figuring out how to manipulate the model from the view.
The JS is available at http://jsfiddle.net/Qxbka/2 . This contains the state I have managed to reach so far, but I have two problems. First off, the directive attempts to call a method toggleTag() in the controller:
template: "<button class='btn' ng-repeat='datum in data' ng-click='toggleTag(datum.id)'>{{datum.name}}</button>"
but the method is not called. Second, I'm not sure how to alter the output section's ng-repeat so that it only shows items with one or more active tags.
Any pointers on what I'm doing wrong and how to get this working would be much appreciated.
Update
I updated the method in the directive to pass the data items directly, i.e.
template: "<button class='btn' ng-repeat='datum in data' ng-click='toggle(data, datum.id)'>{{datum.name}}</button>"
and also created a toggle() method in the directive. By doing this I can manipulate data and it is reflected in the state HTML, however I would appreciate any feedback as to if this is the correct way to do this (it doesn't feel quite right to me).
Still stuck on how to re-evaluate the output when a tag's value is updated.
You can use a filter (docs) on the ng-repeat:
<li ng-repeat="item in items | filter:tagfilter">...</li>
The argument to the filter expression can be many things, including a function on the scope that will get called once for each element in the array. If it returns true, the element will show up, if it returns false, it won't.
One way you could do this is to set up a selectedTags array on your scope, which you populate by watching the tags array:
$scope.$watch('tags', function() {
$scope.selectedTags = $scope.tags.reduce(function(selected, tag) {
if (tag._active) selected.push(tag.name);
return selected;
}, []);
}, true);
The extra true in there at the end makes angular compare the elements by equality vs reference (which we want, because we need it to watch the _active attribute on each tag.
Next you can set up a filter function:
$scope.tagfilter = function(item) {
// If no tags are selected, show all the items.
if ($scope.selectedTags.length === 0) return true;
return intersects($scope.selectedTags, item.tags);
}
With a quick and dirty helper function intersects that returns the intersection of two arrays:
function intersects(a, b) {
var i = 0, len = a.length, inboth = [];
for (i; i < len; i++) {
if (b.indexOf(a[i]) !== -1) inboth.push(a[i]);
}
return inboth.length > 0;
}
I forked your fiddle here to show this in action.
One small issue with the way you've gone about this is items have an array of tag "names" and not ids. So this example just works with arrays of tag names (I had to edit some of the initial data to make it consistent).
Related
I am still a noobie to angular dev, I had a couple of proposed solutions to my problem, but didn't realize how to implement them.
I am using [Angular Bootstrap Toggle] Switch in my angular expression to control on/off values for multiple parameters.
The state of the toggle (ON/OFF) is based on ng-model="toggleValue", where it expects the toggle value to be the boolean type, i.e. true, false.
Now the data that I import from my database(oracledb) has this parameter "toggleValue" defined as "Y"(True) or "N"(False). How do I make the toggle switch complaint to support the Y/N values?
Solutions thought of:
1) Convert the incoming JSON values from Y to true and N to false.
2) bind ng-model to expressions which evaluate to true if the value is 'Y' else false.
3) Hack Angular Bootstrap toggle JS script. (least preferred)
View Screenshot
Fiddle Link: https://jsfiddle.net/3gt64xz8/1/
<toggle ng-model="item.SHIP_FROM_STORE_IND" aria-label="SFS Switch" size="btn-xs"></toggle>
So I found a couple of solutions may work for this problem. Again I don't necessarily feel these are the most optimum solutions, as regardless we will have to iterate our data object and convert the values of required key: value pair from 'Y' to true and 'N' to false.
1) ng-init
I created a parent division over the division where I was required to use these ng-model variables, and reassigned the values of toggleValue with the following code, as ng-init permits using expressions, which ng-model doesn't
<div ng-init="item.SHIP_FROM_STORE_IND = init(item.SHIP_FROM_STORE_IND);
item.BOPIS_ENABLED_IND=init(item.BOPIS_ENABLED_IND);
item.BOSTS_ENABLED_IND=init(item.BOSTS_ENABLED_IND)">
Init function in controller:
$scope.init = function(value) {
$scope.testInput= (value=='Y')? true:false;
return $scope.testInput;
}
Pros: Since I am using ng-repeat and pagination, only the scope elements of the current page are changed, and thus more efficient, and iteratively others are changed on page change.
cons: I also have a search box in my view, which lets you filter the records based on toggleValue parameter (true/false). Now if the filter is applied, it only filters out the current page elements and not all the elements as they are still in "Y" or "N" values.
2) Iterate the object and swap the value in controller while fetching the data
This is pretty simple and obvious solution, but obviously not the most efficient one.
$scope.items = storeFactory.getRecords().query(
function(response) {
$scope.items=response;
$scope.totalItems = $scope.items.length;
for (var i=0, len=$scope.totalItems; i<len; i++) {
$scope.items[i].toggleValue = ($scope.items[i].SHIP_FROM_STORE_IND=='Y')? true:false;
}
},
function(response) {
$scope.waitMessage = "Error: "+response.status + " " + response.statusText;
});
Similarly, we can convert the data back during any changes/updates made back to server.
I am working on a custom directive in angular (1.4.8) to display an array of items in a vertical list with gradient background colors. I have put together a plunker to demonstrate. The directive takes an initial RGB value (separate values for r,g, and b), converts to HSL, then decreases the saturation value of the HSL for each additional index in the array.
The Good
The directive works fine for a static list of items...
The Bad
...but in my use case items will be added to the array by the user, and the directive should account for changes to the array and re-render the list with the color gradient adjusted accordingly. At the moment I am having a mental block about how to get this part working.
The directive right now sits inside of an <li> that ng-repeats through the array, and I have given it an isolate scope that has knowledge of the array item and the index of the item within that <li> element. I am also passing in the full array as a scope item so that I can $watch for changes to it, which feels hacky and isn't working, to boot:
return {
...
scope: {
origArray: '=',
item: '=',
idx: '#'
},...
}
scope.$watchCollection(function() { return scope.origArray; }, function(newVal) {
if (newVal) {
console.log('there was a new item added');
removeGradient(); // function to set element background to grey
renderGradient(); // function to set element background to index-appropriate gradient color
}
});
If you have any thoughts about how I might achieve the dynamic re-rendering, I'd really appreciate it. Thank you!
The problem is in your listLength variable. It's true that you were tracking the collection but you're not updating listLength that renderGradient is depending on.
So the fix is simple but first of all, there is no need to compute the list's length by querying the dom. You can simply do listLength = scope.origArray.length.
The fix:
// In function decrementHsl, the first line: update listLength
listLength = scope.origArray.length;
...
The updated plunker here.
I've got two multiselect dropdowns on a page that uses angularJS. The first is populated from a service call which returns a lists of objects which are basically category objects. The second dropdown is a list of all the specifics from all those categories. Let's call them Groups and Items for simplicity. Originally, I just populated the second list with a second api call which returned a list of those Items. But since the Group objects already contain a list of the Items which are related, this seems unnecessary. (And I'd like to be able to tie the two together, so that if a group is selected, only the items which fall into that group are displayed in the second dropdown, but that's secondary...) My first dropdown get's populated with this code:
$scope.promiseGetGroupNames = providerContactInfoService.getGroupNames();
$scope.promiseGetGroupNames.then(function (pl) {
$scope.GroupDDLdata = pl.data;
}, function (errorPl) {
//error message...
});
So I would assume that in there I could loop through the data and generate a list there, but it seems like angular would have a simpler way of doing things... Is there a way that I can set the options of the second dropdown to be a complete list of items from all of the groups in the first, and then when a user selects one of those groups, remove all but the items which fall into that category?
Additionally, I have researched this, and found some answers that looked like there was a way to connect the ng-options of 2nd dropdown to the ng-model of the first, which seems like what I want to do, at least for the 2nd half, but I still need to get all options in there at page load...
The other idea I had was to simply make two service calls... the first pulls all of the possible items, the 2nd would pull a subset that only falls into the group select... since only one group is ever allowed to be selected, it's not terribly inefficient, but if the person selects one group, then a different one... seems like that would all be unnecessary if it's possible to link all the objects/options/models together...
What is the best practice for this type of scenario, and if it's the first, could someone explain a little about how to set up that connection? I understand the basics of pulling the possible options from the model, but not exactly sure on the syntax (I'm relatively new to angularJS.)
How about something like this ?
1) Populate your first dropdown list :
<select ng-options="groups" ng-model="selectedGroup">
$scope.init = function () {
service.getGroups.then(function (groups) {
$scope.groups = groups;
});
};
$scope.init();
2) Use ng-change directive on your first dropdown list :
<select ng-options="groups" ng-change="fillSecondList()" ng-model="selectedGroup">
<select ng-options="secondList" ng-model="selectedThing">
$scope.fillSecondList = function () {
// Whatever you want, you can use $scope.selectedGroup here
$scope.secondList = ...
};
$scope.fillSecondList will fire when your selection changes so you can update $scope.secondList according to what's selected in your first list ($scope.selectedGroup), for example using a service call using selectedGroupas a parameter.
EDIT :
If all your needed data is fetched from the first service call, you can use a filter, as you don't actually need to modify your secondList model but rather change what is displayed in your second list.
$scope.init = function () {
service.getGroups.then(function (groups) {
$scope.groups = groups;
// Extract the second list from the first one
$scope.secondList = groups.doSomething();
});
};
<select ng-options="secondList | filter: { group : selectedGroup.id }" ng-model="selectedThing">
For example, this will display only secondList items which have a group property equal to selectedGroup.id.
You can use a custom filter if your filtering is more complicated, or a filter function in your controller (triggered with ng-change) if you don't plan to re-use this filter anywhere else :
$scope.filterSecondList = function () {
$scope.secondList = $scope.secondList.filter(function (item) {
return item.group === $scope.selectedGroup.id;
});
};
I receive data from my back end server structured like this:
{
name : "Mc Feast",
owner : "Mc Donalds"
},
{
name : "Royale with cheese",
owner : "Mc Donalds"
},
{
name : "Whopper",
owner : "Burger King"
}
For my view I would like to "invert" the list. I.e. I want to list each owner, and for that owner list all hamburgers. I can achieve this by using the underscorejs function groupBy in a filter which I then use in with the ng-repeat directive:
JS:
app.filter("ownerGrouping", function() {
return function(collection) {
return _.groupBy(collection, function(item) {
return item.owner;
});
}
});
HTML:
<li ng-repeat="(owner, hamburgerList) in hamburgers | ownerGrouping">
{{owner}}:
<ul>
<li ng-repeat="burger in hamburgerList | orderBy : 'name'">{{burger.name}}</li>
</ul>
</li>
This works as expected but I get an enormous error stack trace when the list is rendered with the error message "10 $digest iterations reached". I have a hard time seeing how my code creates an infinite loop which is implied by this message. Does any one know why?
Here is a link to a plunk with the code: http://plnkr.co/edit/8kbVuWhOMlMojp0E5Qbs?p=preview
This happens because _.groupBy returns a collection of new objects every time it runs. Angular's ngRepeat doesn't realize that those objects are equal because ngRepeat tracks them by identity. New object leads to new identity. This makes Angular think that something has changed since the last check, which means that Angular should run another check (aka digest). The next digest ends up getting yet another new set of objects, and so another digest is triggered. The repeats until Angular gives up.
One easy way to get rid of the error is to make sure your filter returns the same collection of objects every time (unless of course it has changed). You can do this very easily with underscore by using _.memoize. Just wrap the filter function in memoize:
app.filter("ownerGrouping", function() {
return _.memoize(function(collection, field) {
return _.groupBy(collection, function(item) {
return item.owner;
});
}, function resolver(collection, field) {
return collection.length + field;
})
});
A resolver function is required if you plan to use different field values for your filters. In the example above, the length of the array is used. A better be to reduce the collection to a unique md5 hash string.
See plunker fork here. Memoize will remember the result of a specific input and return the same object if the input is the same as before. If the values change frequently though then you should check if _.memoize discards old results to avoid a memory leak over time.
Investigating a bit further I see that ngRepeat supports an extended syntax ... track by EXPRESSION, which might be helpful somehow by allowing you to tell Angular to look at the owner of the restaurants instead of the identity of the objects. This would be an alternative to the memoization trick above, though I couldn't manage to test it in the plunker (possibly old version of Angular from before track by was implemented?).
Okay, I think I figured it out. Start by taking a look at the source code for ngRepeat. Notice line 199: This is where we set up watches on the array/object we are repeating over, so that if it or its elements change a digest cycle will be triggered:
$scope.$watchCollection(rhs, function ngRepeatAction(collection){
Now we need to find the definition of $watchCollection, which begins on line 360 of rootScope.js. This function is passed in our array or object expression, which in our case is hamburgers | ownerGrouping. On line 365 that string expression is turned into a function using the $parse service, a function which will be invoked later, and every time this watcher runs:
var objGetter = $parse(obj);
That new function, which will evaluate our filter and get the resulting array, is invoked just a few lines down:
newValue = objGetter(self);
So newValue holds the result of our filtered data, after groupBy has been applied.
Next scroll down to line 408 and take a look at this code:
// copy the items to oldValue and look for changes.
for (var i = 0; i < newLength; i++) {
if (oldValue[i] !== newValue[i]) {
changeDetected++;
oldValue[i] = newValue[i];
}
}
The first time running, oldValue is just an empty array (set up above as "internalArray"), so a change will be detected. However, each of its elements will be set to the corresponding element of newValue, so that we expect the next time it runs everything should match and no change will be detected. So when everything is working normally this code will be run twice. Once for the setup, which detects a change from the initial null state, and then once again, because the detected change forces a new digest cycle to run. In the normal case no changes will be detected during this 2nd run, because at that point (oldValue[i] !== newValue[i]) will be false for all i. This is why you were seeing 2 console.log outputs in your working example.
But in your failing case, your filter code is generating a new array with new elments every time it's run. While this new array's elments have the same value as the old array's elements (it's a perfect copy), they are not the same actual elements. That is, they refer to different objects in memory that simply happen to have the same properties and values. Hence in your case oldValue[i] !== newValue[i] will always be true, for the same reason that, eg, {x: 1} !== {x: 1} is always true. And a change will always be detected.
So the essential problem is that your filter is creating a new copy of the array every time it's run, consisting of new elements that are copies of the original array's elments. So the watcher setup by ngRepeat just gets stuck in what is essentially an infinite recursive loop, always detecting a change and triggering a new digest cycle.
Here's a simpler version of your code that recreates the same problem: http://plnkr.co/edit/KiU4v4V0iXmdOKesgy7t?p=preview
The problem vanishes if the filter stops creating a new array every time it's run.
New to AngularJS 1.2 is a "track-by" option for the ng-repeat directive. You can use it to help Angular recognize that different object instances should really be considered the same object.
ng-repeat="student in students track by student.id"
This will help unconfuse Angular in cases like yours where you're using Underscore to do heavyweight slicing and dicing, producing new objects instead of merely filtering them.
Thanks for the memoize solution, it works fine.
However, _.memoize uses the first passed parameter as the default key for its cache. This could not be handy, especially if the first parameter will always be the same reference. Hopefully, this behavior is configurable via the resolver parameter.
In the example below, the first parameter will always be the same array, and the second one a string representing on which field it should be grouped by:
return _.memoize(function(collection, field) {
return _.groupBy(collection, field);
}, function resolver(collection, field) {
return collection.length + field;
});
Pardon the brevity, but try ng-init="thing = (array | fn:arg)" and use thing in your ng-repeat. Works for me but this is a broad issue.
I am not sure why this error is coming but, logically the filter function gets called for each element for the array.
In your case the filter function that you have created returns a function which should only be called when the array is updated, not for each element of the array. The result returned by the function can then be bounded to html.
I have forked the plunker and have created my own implementation of it here http://plnkr.co/edit/KTlTfFyVUhWVCtX6igsn
It does not use any filter. The basic idea is to call the groupBy at the start and whenever an element is added
$scope.ownerHamburgers=_.groupBy(hamburgers, function(item) {
return item.owner;
});
$scope.addBurger = function() {
hamburgers.push({
name : "Mc Fish",
owner :"Mc Donalds"
});
$scope.ownerHamburgers=_.groupBy(hamburgers, function(item) {
return item.owner;
});
}
For what it's worth, to add one more example and solution, I had a simple filter like this:
.filter('paragraphs', function () {
return function (text) {
return text.split(/\n\n/g);
}
})
with:
<p ng-repeat="p in (description | paragraphs)">{{ p }}</p>
which caused the described infinite recursion in $digest. Was easily fixed with:
<p ng-repeat="(i, p) in (description | paragraphs) track by i">{{ p }}</p>
This is also necessary since ngRepeat paradoxically doesn't like repeaters, i.e. "foo\n\nfoo" would cause an error because of two identical paragraphs. This solution may not be appropriate if the contents of the paragraphs are actually changing and it's important that they keep getting digested, but in my case this isn't an issue.
My case is that I want to use the createSearchChoice feature of the Select2 widget. So I found out I need to use an html input element instead of a select element and so I cannot use ng-repeat to populate the select2 widget. I find out there is a 'data' option and have been able to populate the select2 widget with static data, but not when I've tried to fill it dynamically.
What works:
html:
<input class='select2' ui-select2='sel2props' type='hidden'>
in the controller:
$scope.sel2props = {
createSearchChoice: ...
data: [
{ id: 0, text: 'yabba' }
etc
]
};
But if I try to set data to a variable which I can then set to whatever the database feeds me the widget isn't populated.
data: $scope.data;
function to retrieve data {
$scope.data = retrieved data;
}
the retrieved data is exactly in the way specified.
If i set up a button to append the data key it will work:
$scope.appenddata = function () {
$scope.data.push({id:1, text: 'anot'});
};
So I'm thinking it's a timing issue and I try $digest and $apply but they don't work in controllers. I tried to set up a directive and actually can do simple widgets, but not select2, so I was hoping not to go down that path, well that is to say I went down that path and drowned. If anyone could help out that would be great.
The solution is straight forward. Just push the elements onto the select2 data array rather than referencing another array.
function (result) {
$scope.lookupOptions.data.length = 0; // remove old items
angular.extend($scope.lookupOptions.data, result.data); // add new items
}
A trick I've recently made use of is to use Select2's query option to pass in your latest data on demand.
I've put together an example, wrapped in a custom directive. See this Plunk.