ng-repeat track by $index and removing elements from the array - javascript

I am using track by $index because I want to allow repeated elements in my array, but at the same time this is causing a side effect when removing elements from this collection.
I have this set of players which is declared in the controller as $scope.players = [].
You can populate this array as follows:
<input type="text" ng-model="player">
<button ng-click="addPlayer()">
addPlayer() just pushes the player model to the players array:
$scope.addPlayer = function() {
if (!$scope.player)
return;
$scope.players.push($scope.player);
$scope.player = null;
};
And the collection is shown using ng-repeat. But also when an item is clicked on, it should be deleted.
<div ng-repeat="player in players track by $index" ng-click="deletePlayer($index)">
{{player}}
</div>
$scope.deletePlayer = function(index) {
if (index > -1)
$scope.players.splice(index, 1);
};
The issue is that since it's tracking by index, when an element is removed the collection of players will be short by 1 because the collection has changed.
What I mean by this is the following: say I have the array of players ["p1", "p2", "p3"]. If I remove one of these except the last, for example, p1, the ng-repeat is not showing [p2, p3] even though these are the contents of the array, but it shows just p3. This is what I mean when I say the collection is one element short.
I think the issue happens because it's unknown to ng-repeat in the track by $index mode that the length of the array has changed. Therefore, it's skipping one element when iterating through the changed array, because it's using the old indices to iterate it, I believe.
Is there a standard way of tackling this side effect?

You can make each item in players array to be an object that has name and id properties. Demo.
Object.assign($scope, {
players: [],
player: '',
addPlayer: function() {
if(!$scope.player) {
return
}
$scope.players = $scope.players.concat({
name: $scope.player,
id: Date.now() //fake id (timestamp)
})
$scope.player = ''
},
deletePlayer: function(id) {
$scope.players = $scope.players.filter(function(player){
return player.id !== id
})
}
})
<div ng-repeat="player in players track by player.id" ng-click="deletePlayer(player.id)">
{{player.name}}
</div>

The problem is with your deletePlayer function. Your argument name is 'i' but you are trying to use 'index' instead.
This:
$scope.deletePlayer = function(i) {
if (i > -1)
$scope.players.splice(index, 1);
};
should be:
$scope.deletePlayer = function(i) {
if (i > -1)
$scope.players.splice(i, 1);
};

In the element you could use: ng-click="remove(phones, $index)
And in the code:
$scope.remove = function(array, index){
array.splice(index, 1);
}
You shouldn't have any problems with this approach.

Related

Replace object with existing value in array in AngularJS

I have an ng-repeat with a select in every item.
The user can select a value (trigging a function that pushes the an object into an array), but they can also change their mind, in which case the code just pushes a second object with the new value, duplicating the first one.
How could I manage to actually delete existing values, leaving only the last one on every ng-change?
Here's my HTML:
<select ng-change="insertproduct(pa.nom, basket)" ng-model="basket">
<option ng-repeat="select in numberofproducts">{{select}}</option>
</select>
And my javascript:
$scope.numberofproducts = [1,2,3,4,5,6,7,8,9,10]
$scope.singleorder = [];
$scope.insertproduct = function(nom, basket){
$scope.numero = {
'producte': nom,
'numero': basket
};
$scope.singleorder.push($scope.numero);
console.log($scope.singleorder);
}
The idea is to create a condition in which if the array contains an object with the parameter ´producte´ equal to the new one, delete the existing and push the new one.
Any tips?
First, use the findIndex method to check if an object with the same property is already in the singleorder array.
function duplicateOrder(order) {
return order.producte === nom;
}
var index = $scope.singleorder.findIndex(duplicateOrder);
Note: browser support for findIndex is limited; it is not supported in Internet Explorer.
Then remove the item with splice:
if(index > -1){
$scope.singleorder.splice(index, 1);
}
You can then push the new one in.
You should also clean up your coding style: don't mix french and english, and use either camelCase or snake_case for your functions to improve readability.
Observation :
Use AngularJS ngOptions attribute instead of ng-repeat.
You can check the index of the element in an array if that was already there you can easily remove previous one.
DEMO
var myApp = angular.module('myApp',[]);
myApp.controller('MyCtrl',function($scope) {
$scope.numberofproducts = [1,2,3,4,5,6,7,8,9,10]
$scope.newArray = [];
$scope.insertproduct = function(basket) {
var prevIndex = $scope.newArray.indexOf(basket);
if(prevIndex > -1) {
$scope.newArray.splice(prevIndex, 1);
} else {
$scope.newArray.push(basket);
}
console.log($scope.newArray);
}
});
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.23/angular.min.js"></script>
<div ng-app="myApp" ng-controller="MyCtrl">
<select ng-change="insertproduct(basket)" ng-model="basket" ng-options="select for select in numberofproducts">
</select>
</div>

list an object in (Angular.js)

I am generating a list to search for the key "name" and "type".
results.push({ name: item.beast, type: 'color of animal' });
but I see this error to find an element that is contained in the array $scope.data:
Error: [$ rootScope: infdig] $ 10 digest () iterations reached. Aborting! Watchers fired in the last five iterations.
This is the code that I have:
http://plnkr.co/edit/EDd578?p=preview
The problem here is that you're using a set of data to filter against but trying to display a resulting data set from that filtering process that's in a different format. I'd advocate using ng-change on the input and using a new data set to fill the repeated items.
controller
$scope.matches = [];
$scope.findMatches = function(items, searchText) {
var results = [];
if (searchText) {
angular.forEach(items, function(item) {
if (item.beast.indexOf(searchText) === 0) {
results.push({
name: item.beast,
type: 'animal'
});
}
if (item.color.indexOf(searchText) === 0) {
results.push({
name: item.color,
type: 'color of animal'
});
}
});
}
return results;
}
html
<input type='text' ng-model='search' id='search' ng-change="matches = findMatches(data, search)">
<hr/>
<ul>
<li ng-repeat="item in matches track by $index">{{item.name}} and {{item.type}}</li>
</ul>
plunkr - http://plnkr.co/edit/hkMXPP?p=preview
You are creating a new array everytime your filter is run, and returning that. This makes angular think you've changed the array everytime (it doesn't check for item equality, rather, reference equality by ===).
Have a look at this for more details.
A solution is to modify the items array inplace, and return it, so the reference remains the same.

Knockout JS using computed arrays outside of ViewModel

I would like to display a list of items on a page, and be able to dynamically reposition items by using a dropdown list of all positions. Selecting a position from the dropdown will change the current position of the item and re-shift the position any affected elements in the list.
I do have a working version of this concept, but it is not ideal. For some reason, when I reference my selectedItems computed array (I filter my items by setting the selectedItem observable), the position that is contained in the returned item is the original position value of the item, and not the one that has been set via the dropdowns/my reposition function. This is somewhat odd because the 'items' observableArray does contain the updated value, and the computedArray does return the right item, just not with the most up to date value.
A working JSfiddle is below. However, it does a lot of manual calculation and does not take advantage of the computed array as described above. The issue might be somehow related to setting Knockout observables from outside the ViewModel. To see the issue, uncomment the 2 lines in the 'document ready' block', where I attempt to find the current position of an item, and comment out the for loop where I look for the current item manually.
https://jsfiddle.net/tq1m873m/5/
I'm new to KnockoutJS & JS in general, be gentle :)
$(document).ready(function () {
$("select[id^='selectName_']").change(function () {
//Extract the item ID from the select html id attribute
var curItemIDNum = $(this).attr('id').substring(15);
var currentPos = 0;
// myViewModel.selectedItem("item" + curItemIDNum);
// currentPos = myViewModel.selectedItems()[0].position();
// START - really bad code, shield your eyes
// I can't seem to get the current position via the 2 commented lines above and have to resort to converting the observable array to a regular array and pulling the value that way. Not pretty!
var itemsJS = ko.toJS(self.items());
for (var x = 0; x < itemsJS.length; x++) {
if (("item" + curItemIDNum) == itemsJS[x].name) {
currentPos = itemsJS[x].position;
break;
}
}
// END - really bad code
reposition("item" + curItemIDNum, currentPos, $(this).val());
refreshDropDowns();
});
refreshDropDowns();
});
You were working on this before, and I didn't have a working solution for you. Today, I do. Your use of a jQuery trigger is not going to work out well. Let's do it all with Knockout.
I made items to be just an array of objects that do not have assigned positions. orderedItems is a computed that goes through items in order and creates an observable for position.
A subscription on that position observable calls moveItemTo, which rearranges items, and all the dependencies are updated by Knockout.
$(function() {
ko.applyBindings(viewModel());
});
function item(name) {
return {
name: name
};
}
var viewModel = function() {
var self = {};
self.items = ko.observableArray([
item('item1'),
item('item2'),
item('item4'),
item('item5'),
item('item3')
]);
function moveItemTo(item, pos) {
var oldPos = self.items.indexOf(item),
newPos = pos - 1,
items;
if (oldPos < newPos) {
items = self.items.slice(oldPos, newPos + 1);
items.push(items.shift());
self.items.splice.bind(self.items, oldPos, items.length).apply(self.items, items);
} else {
items = self.items.slice(newPos, oldPos + 1);
items.unshift(items.pop());
self.items.splice.bind(self.items, newPos, items.length).apply(self.items, items);
}
}
self.orderedItems = ko.computed(function() {
return ko.utils.arrayMap(self.items(), function(item, index) {
var pos = ko.observable(index + 1);
pos.subscribe(moveItemTo.bind(null, item));
return {
name: item.name,
position: pos
};
});
});
return self;
}; //end of viewmodel
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.2.0/knockout-min.js"></script>
<div>Set the order of the item by selecting a new position from the dropdown:
<ul data-bind="foreach: orderedItems">
<li>
<div> <span data-bind="text: name"></span>
<select data-bind="options: $root.orderedItems, optionsValue:'position', value: position"></select>
</div>
</li>
</ul>
</div>ITEMS CONTENTS:
<BR>
<span data-bind="text: JSON.stringify(ko.toJS(items), null, 4)"></span>
One way I can think of doing this would be to use a computed value to hold the current state of the position. This would allow you to do your reshuffling when a new position is set on an item like below :
ko.utils.arrayForEach(self.items(), function (x) {
x.newPosition = ko.computed({
read: function () {
return x.position();
},
write: function (val) {
//get the item in the prev position
var temp = ko.utils.arrayFirst(self.items(), function (y) {
return y.position() == val;
});
//swap positons here
if (temp) {
temp.position(x.position());
x.position(val);
}
}
});
});
in the Mark up it would be
<select data-bind="options:positions,value:newPosition"></select>
so on computed "write" ... the script swaps the position values. I left your original binding to the orderedItems. you can find a working sample here https://jsfiddle.net/p1yhmvcr/2/ ... the one thing worth noting here would be the sorting is not really physical. the observable array items are still on their original index in the array and the code is only changing the position values.

angularJs exclude already selected items from array

I have an array of objects in $scope.currentSChannels.scgsLink This array of objects have something like
$scope.currentSChannels.scgsLink = [{channelId:1, sCgsLinkId:1, groupNo:1, percentage: 50, expireHrs:4},{channelId:1, sCgsLinkId:2, groupNo:2, percentage:50, expireHrs:1}]
and I also have the following select list
<div class="col-md-4">
<select class="form-control" ng-model="newLink.groupNo"
name="groupNo" id="groupNo"
ng-options="t.value as t.text for t in metaData.spGroups"></select>
</div>
I need to filter that list to not show already selected items in the $scope.currentSChannels.scgsLink groupNo column. I looked at http://christian.fei.ninja/Angular-Filter-already-selected-items-from-ng-options/ and also at AngularJS ng-options to exclude specific object and both seem to be close but not enough as I need to filter against an array and a particular column in that array. How should I implement that filtering?
The template is getting a bit tricky. Assuming selectedLink is the variable that points to the selected groupNo
ng-options="t.value as t.text for t in metaData.spGroups | filter: {value: '!' + currentSChannels.scgsLink[selectedLink].groupNo}"
See this fiddle : the second select contains the same collection as the first one, excluded what is already selected.
Edit: Solution above is for excluding elements according to one value. So as to exclude the elements according to a collection of values, a custom filter would suit best:
Filter
app.filter('channelFilter', function () {
return function (metadata, exclusions) {
var filterFunction = function (metadata) {
// return the metadata object if exclusions array does NOT contain his groupNo
return !exclusions.some(function (exclusion) {
return exclusion.groupNo === metadata.value;
});
};
return metadatas.filter(filterFunction);
};
});
Usage
ng-options="metadata in metadatas | channelFilter: exclusions"
Template
ng-options="t.value as t.text for t in metaData.spGroups | channelFilter: currentSChannels.scgsLink"
Fiddle
That said, would be more efficient to group selected links by groupNo to avoid searches in the array, and filter in the controller.
I wanted to make it a bit more generic, so I've done the following
http://jsfiddle.net/96m4sfu8/
app.filter('excludeFrom', function () {
return function (inputArray, excludeArray, excludeColumnName, inputColumnName) {
if (inputColumnName==undefined)
inputColumnName = 'value';
var filterFunction = function (inputItem) {
return !excludeArray.some(function (excludeItem) {
return excludeItem[excludeColumnName] === inputItem[inputColumnName];
});
};
return inputArray.filter(filterFunction);
};
});

Is it possible to .sort(compare) and .reverse an array in angularfire?

Update: The problem I'm having is doing a combination of three things:
Adding a header to an array when the $priority (set to date created) changes. This is so I can group tasks by week and day in an ng-repeat.
Resorting that list when a task is checked. Checked tasks should go to the bottom.
When creating new tasks, I need to add them to the top of the list instead of the bottom.
Here is a plnkr of all the code: http://plnkr.co/edit/A8lDKbNvhcSzbWVrysVm
I'm using a priorityChanged function to add a header based on comparing the dates on a task:
//controller
var last = null;
$scope.priorityChanged = function(priority) {
var current = moment(priority).startOf('day');
var changed = last === null || !last.isSame(current);
last = current;
return changed;
};
//view
<li ng-repeat="task in list track by task.$id">
<h3 ng-show="priorityChanged(task.$priority)">{{getDayName(task.$priority)}}</h3>
and to move a task to the bottom of the list when a task is completed I am using a .sort function when I populate the task list:
var populateTasks = function(start, end) {
$scope.start = start;
$scope.end = end;
var ref = new Firebase('https://plnkr.firebaseio.com/tasks').startAt(start).endAt(end);
var list = $firebase(ref).$asArray();
list.sort(compare);
list.$watch(function() {
list.sort(compare);
});
function compare(a, b) {
return a.completeTime - b.completeTime;
}
$scope.list = list;
};
It seems as though these approaches will not work together. Is there a way of combining them so that when the list is re-sorted the ng-repeat will run through the tasks again and add the necessary headers? Is that the ideal solution? Can the header be separate?
Update: I moved the ng-init functionality directly into the h3 to try to get that to run again but it does not display the header in that case.
Update2: The header does seem to show up if at least two of the $priority dates are unique but I still have the problem of deleting or moving the associated list item removing the connected header.
USING A DIRECTIVE
You can create a directive to simplify things by nesting your client contents. demo
app.directive('repeatByWeek', function($parse, $window) {
return {
// must be an element called <repeat-by-week />
restrict: 'E',
// replace the element with template's contents
replace: true,
templateUrl: 'repeat.html',
// create an isolate scope so we don't interfere with page
scope: {
// an attribute collection="nameOfScopeVariable" must exist
'master': '=collection'
},
link: function(scope, el, attrs) {
// get the global moment lib
var moment = $window.moment;
scope.weeks = [];
updateList();
// whenever the source collection changes, update our nested list
scope.master.$watch(updateList);
function updateList() {
scope.weeks = sortItems(parseItems(scope.master));
}
function sortItems(sets) {
var items = [];
// get a list of weeks and sort them
var weeks = sortDescending(Object.keys(sets));
for(var i=0, wlen=weeks.length; i < wlen; i++) {
var w = weeks[i];
// get a list of days and sort them
var days = sortDescending(Object.keys(sets[w]));
var weekEntry = {
time: w,
days: []
};
items.push(weekEntry);
// now iterate the days and add entries
for(var j=0, dlen=days.length; j < dlen; j++) {
var d = days[j];
weekEntry.days.push({
time: d,
// here is the list of tasks from parseItems
items: sets[w][d]
});
}
}
console.log('sortItems', items);
return items;
}
// take the array and nest it in an object by week and then day
function parseItems(master) {
var sets = {};
angular.forEach(master, function(item) {
var week = moment(item.$priority).startOf('week').valueOf()
var day = moment(item.$priority).startOf('day').valueOf();
if( !sets.hasOwnProperty(week) ) {
sets[week] = {};
}
if( !sets[week].hasOwnProperty(day) ) {
sets[week][day] = [];
}
sets[week][day].push(item);
});
console.log('parseItems', sets);
return sets;
}
function sortDescending(list) {
return list.sort().reverse();
}
}
}
});
The repeat.html template:
<ul>
<!--
it would actually be more elegant to put this content directly in index.html
so that the view can render it, rather than needing a new directive for
each variant on this layout; transclude should take care of this but I
left it out for simplicity (let's slay one dragon at a time)
-->
<li ng-repeat="week in weeks">
<h3>{{week.time | date:"MMMM dd'th'" }}</h3>
<ul>
<li ng-repeat="day in week.days">
<h4>{{day.time | date:"MMMM dd'th'" }}</h4>
<ul>
<li ng-repeat="task in day.items">
<input type="checkbox" ng-model="task.complete" ng-change="isCompleteTask(task)">
<input ng-model="task.title" ng-change="updateTask(task)">
<span ng-click="deleteTask(task)">x</span>
</li>
</ul>
</li>
</ul>
</li>
</ul>
OTHER IDEAS
Most likely, you just need to move your changed out of ng-init. I don't think that is re-run when elements move/resort.
<li ng-repeat="task in list">
<h3 ng-show="priorityChanged(task.$priority)">{{getDayName(task.$priority)}}</h3>
<!-- ... -->
</li>
Since your list may resort several times, you can probably also get a pretty significant speed boost by using track by
<li ng-repeat="task in list track by task.$id">
If that doesn't resolve the problem, it might be time to think about writing your own directive (these are more fun than they sound) and possibly to consider setting aside AngularFire and going right to the source.
You really want a more deeply nested data structure here you can iterate at multiple levels, and you may need to structure that yourself either on the client or the server, now that you have a sense of how you want them organized (essentially a group by week functionality).
you could use "unshift" javascript function
var fruits = ["1", "2", "3", "4"];
fruits.unshift("5","6");
Result
[ '5', '6', '1', '2', '3', '4' ]

Categories

Resources