Knockout JS using computed arrays outside of ViewModel - javascript

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.

Related

Select element not updating correctly with Knockout

Background
I have a situation where I want to have a few dropdown menus which change options based on what is available. I've managed to simplify this code and replicate the problem with the code below.
In this example I have 5 available colors and I want to choose four of them. If I select one, then I want it to not be available in the other menus.
Problem
The dropdown menus only sort of work. The options that are shown do seem to be valid based on what's available, however sometimes when selecting an entry it will not allow it until I choose a second time. Also, as seen in commented code below, a lodash _.sortBy seems to break functionality altogether.
HTML
<div data-bind="foreach:colorChoices">
<select data-bind="options: localAvailableOptions, optionsCaption: 'Select...', optionsText: function(currentValue) {return 'Color ID ' + currentValue;}, value: id"></select>
</div>
Javascript
function appModel() {
var self = this;
self.colorIds = [1, 2, 3, 4, 5];
self.colorChoices = ko.observableArray();
self.selectedColorIds = ko.computed(function() {
return _(self.colorChoices())
.filter(function(item) {
return item.id()
})
.map(function(item) {
return item.id()
})
.value();
});
self.availableColorIds = ko.computed(function() {
return _.difference(self.colorIds, self.selectedColorIds());
});
self.colorChoices.push(new colorChoice(self));
self.colorChoices.push(new colorChoice(self));
self.colorChoices.push(new colorChoice(self));
self.colorChoices.push(new colorChoice(self));
}
function colorChoice(parent) {
var self = this;
self.id = ko.observable();
self.localAvailableOptions = ko.computed(function() {
//clone as to not modify original array
var availableIds = _.clone(parent.availableColorIds());
//add own ID so dropdown menu contains matching entry
if (self.id()) {
availableIds.push(self.id());
}
//seems to break with _.sortBy
//return _.sortBy(availableIds);
return availableIds;
});
}
ko.applyBindings(new appModel());
CodePen (same code)
https://codepen.io/anon/pen/KEKPKV
I found the issue.
if (self.id()) {
availableIds.push(self.id());
}
This was missing a check to see if it already existed, and meant that the available options included duplicate values, which was presumably producing the undefined behavior.

Insert element in array depends on its index position without replace or change other element in angularjs

I have dynamic select box using ng-repeat. Then I passed the index value for every select box by ng-change.
This is my html:
<thead>
<th ng-repeat="l in labels"><div style="width:200px;"></div>{{l.labelname_en}}
</th>
</thead>
<tbody>
<td ng-repeat="e in excelvalues">
<select name="selectExcel" class="form-control" ng-model="selectedExcel" ng-options="excel for excel in excelvalues" ng-change="change(selectedExcel,$index)">
</select>
</td>
</tbody>
This is my js:
$scope.change = function(excel,index){
var data = {
index:index,
risk_disc_en:excel
};
$scope.arr.splice(index,1,data);
}
This is the screen:
In this, I have used the index value for making it as unique. If I selected the 1st select box the array will be like
[{"index":0,"risk_disc_en":"Risk Description"}]
After that, if I selected the 3rd select box the array be like
[{"index":0,"risk_disc_en":"Risk Description"},{"index":2,"risk_disc_en":"Impact"}]
Note: The index of the 1st element is 0 because I selected the 1st box. Then the index of the 2nd element is 2 because I selected the 3rd box instead of the 2nd box.
After that, I selected the 2nd box the array value in the console like
[{"index":0,"risk_disc_en":"Risk Description"},{"index":1,"risk_disc_en":"Probability"}]
The element in the array position 1 {"index":2,"risk_disc_en":"Impact"}
is replaced by
{"index":1,"risk_disc_en":"Probability"}
but I want the output array like
[{"index":0,"risk_disc_en":"RiskDescription"},`{"index":1,"risk_disc_en":"Probability"},{"index":2,"risk_disc_en":"Impact"}]`
If the index value does not exist before, the element should insert at the correct position. If it exists before it will update or replace the corresponding element having the same index value.
For example :
case 1:
arr={0,2} and I try to insert 1 it should be like arr={0,1,2}. In my case it replace the element and looks like arr={0,1}
case 2:
arr={0,2} and I try to insert 2 again it should replace and array be like arr={0,2}
You can use an Object as map to keep track of selected options and sort the object values to get the sorted array.
$scope = {};
$scope.map = {};
$scope.change = function(excel, index) {
var data = {
index: index,
risk_disc_en: excel
};
$scope.map[index] = data;
$scope.arr = Object.values($scope.map).sort(function(a,b){
return a.value - b.value;
})
}
$scope.change("item2", 2);
$scope.change("item3", 3);
$scope.change("item0", 0);
console.log($scope.arr);
I think it will be better to search for the item first. If you got the item, you don't need to do anything. But if you didn't, you can push the item on the specified index:
$scope.change = function(excel, index) {
var data = {
index: index,
risk_disc_en: excel
};
var index = $scope.arr.findIndex(item => item.index === index);
if (index === -1) {
$scope.arr.splice(index, 0, data);
} else {
$scope.arr.splice(index, 1, data);
}
}

Filtering observable array with knockout

Could you please find what I'm doing wrong with this array filter. Fiddle Here
I've been working on it, and making very slow progress. I checked on a lot of samples but not able to find my issue.
Thanks
//This is the part I'm not able to fix
self.filteredPlaces = ko.computed(function() {
var filter = self.filter().toLowerCase();
if (!filter) {
ko.utils.arrayForEach(self.placeList(), function (item) {
});
return self.placeList();
} else {
return ko.utils.arrayFilter(self.placeList(), function(item) {
var result = (item.city().toLowerCase().search(filter) >= 0);
return result;
});
}
});
You did not data-bind filter to any input. You used query instead.
Change your filter value to use the query observable:
var filter = self.query().toLowerCase();
I think I know what you're trying to accomplish so I'll take a shot. There are a few things wrong with this code.
foreach in knockout accepts an array not a function.
http://knockoutjs.com/documentation/foreach-binding.html
I think you're trying to hide entries that don't contain the text in the search box. For that you need the visible binding. I re-factored your code to the sample below.
<div data-bind="foreach: placeList" class="alignTextCenter">
<p href="#" class="whiteFont" data-bind="text: city, visible: isVisible"></p>
</div>
I added isVisible as an item in your array, and an observable in your class.
var initialPlaces = [
{"city":"Real de Asientos","lat":22.2384759,"lng":-102.089015599999,isVisible:true},
{"city":"Todos Santos","lat":23.4463619,"lng":-110.226510099999,isVisible:true},
{"city":"Palizada","lat":18.2545777,"lng":-92.0914798999999,isVisible:true},
{"city":"Parras de la Fuente","lat":25.4492883,"lng":-102.1747077,isVisible:true},
{"city":"Comala","lat":19.3190634,"lng":-103.7549847,isVisible:true},
];
var Place = function(data) {
this.city = ko.observable(data.city);
this.lat = ko.observable(data.lat);
this.lng = ko.observable(data.lng);
this.isVisible = ko.observable(data.isVisible);
};
Lastly, you want to subscribe to the changes of "query" since it's on your text box so that the list updates when the text box changes. It's the self.query.subscribe line in my fiddle. I apologize about the formatting, I tried several times and could not get it to work.
Working fiddle here

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

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.

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