I am altering an observableArray, modifying some data in a subscribe event. First I am converting the ObservableArray using ko.toJS(), mapping trough the data, and altering. At the end I call self.menuCategories(jsArray) to set the observableArray again.
It seems like I lose the "connection" to the observableArray in some way, since the foreach statement in my code suddenly breaks.
Either there is a very much easier way to handle this, or I am not handling the observables correctly.
CODE :
var MenuWizardModel = function() {
var self = this;
self.menuCategories = ko.observableArray();
self.commonDiscount = ko.observable(0);
// Handling adding items to menuCategories.
self.addNewSubMenuItem = function () {
var newSubMenuItem = new SubMenuItemViewModel(self.newSubMenuItemName(), []);
self.menuCategories.push(newSubMenuItem);
self.newSubMenuItemName(null);
self.createNewSubMenu(false);
}
function SubMenuItemViewModel(name, foodItemList) {
var self = this;
self.name = ko.observable(name);
self.foodItemList = ko.observableArray(foodItemList);
}
self.commonDiscount.subscribe(function(val) {
var discount = parseInt(val) / 100;
var jsArray = ko.toJS(self.menuCategories);
console.log(jsArray)
jsArray = ko.toJS(jsonArray[0].foodItemList.map(item => {
item.price = parseInt(item.price) - (parseInt(item.price) * discount);
return item;
}));
self.menuCategories(jsArray);
});
MARKUP :
<div data-bind="foreach: menuCategories">
<h4 data-bind="text: name"></h4>
<div data-bind="foreach: foodItemList" class="list-group">
...
DATA :
I think the best way to handle this type of thing is to add a computed observable to the fooditem that captures the global discount and calculates the discounted price.
something like the following.
var MenuWizardModel = function() {
var self = this;
self.menuCategories = ko.observableArray([{
name: 'Main Meals'
}]);
self.commonDiscount = ko.observable(0);
self.newSubMenuItemName = ko.observable();
// Handling adding items to menuCategories.
self.addNewSubMenuItem = function() {
var newSubMenuItem = new SubMenuItemViewModel(self.newSubMenuItemName(), [{name: 'Oranges', price: 3.99}]);
self.menuCategories.push(newSubMenuItem);
self.newSubMenuItemName(null);
//self.createNewSubMenu(false);
}
function mapFoodItem(item){
item.discountedPrice= ko.pureComputed(function(){
var discount = parseInt(self.commonDiscount()) / 100
return parseInt(item.price) - (parseInt(item.price) * discount);
});
return item;
}
function SubMenuItemViewModel(name, foodItemList) {
var self = this;
self.name = ko.observable(name);
self.foodItemList = ko.observableArray(foodItemList.map(mapFoodItem));
}
};
ko.applyBindings(new MenuWizardModel());
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
<label>Discount <input data-bind="value: commonDiscount"></label>
<label>Sub Menu Name: <input data-bind="value: newSubMenuItemName" /></label>
<button data-bind="click: addNewSubMenuItem">Add Sub Menu</button>
<div data-bind="foreach: {data: menuCategories, as: 'menu' }">
<h4 data-bind="text: menu.name"></h4>
<div data-bind="foreach: {data: menu.foodItemList, as: 'food'}" class="list-group">
<div class="list-group-item">
Name: <span data-bind="text: food.name"></span>
Price: <span data-bind="text: food.price"></span>
Discounted Price: <span data-bind="text: food.discountedPrice"></span>
</div>
</div>
</div>
I currently have a list of students and a list of classes in a school object. I would like each class object to be able to display a filtered list of students based on the class id property.
I have tried to access the parent object via custom binding but have not had any success.
Perhaps I am looking at the problem the wrong way? I have spent a couple days on this and whichever way I tackle it I always need to access a value on a parent object.
Are there any methods of accessing what I need? I am beginning to think that it is not possible to access a global style variable.
function School()
{
var self = this;
self.ClassVMs = ko.observableArray([]).indexed('Number');
self.ChildVMs = ko.observableArray([]).indexed('Number');
}
function ClassVM(classId, text)
{
var self = this;
self.Number = ko.observable();
self.Text = ko.observable(text);
self.ClassId = ko.observable(classId);
}
function ChildVM(classId, text)
{
var self = this;
self.Number = ko.observable();
self.ClassId = ko.observable(classId);
self.Text = ko.observable(text);
}
I have a Fiddle with my setup. Any and all guidance is appreciated. Thanks
You do not need global variables to solve this. Knockout has $root and $parent to step slightly outside of the scope you're in inside a foreach. In addition, if really needed, you can always make sure the view models get another type of view model as its dependency. In fact, if one view model has a list of sub view models it already has such a dependency.
What you need to think about is what your UI/UX is going to be like. Are you designing your view models to support a view where the user is "viewing" a student and enrolls him/her in classes? Or is the app user viewing a class and adding students one by one?
Here's a variant that shows a little bit of both:
function School(classes) {
var self = this;
self.classes = ko.observableArray(classes);
self.students = ko.observableArray([]);
self.enroll = function(child, someClass) {
if (self.students().indexOf(child) < 0) {
self.students.push(child);
}
if (someClass.students().indexOf(child) < 0) {
someClass.students.push(child);
}
};
self.enrollNewChild = function(someClass) {
if (!!someClass.childToBeEnrolled()) {
self.enroll(someClass.childToBeEnrolled(), someClass);
someClass.childToBeEnrolled(null);
}
};
self.enrollInClass = function(child) {
if (!!child.classToBeEnrolledIn()) {
self.enroll(child, child.classToBeEnrolledIn());
child.classToBeEnrolledIn(null);
}
};
}
function Class(id, txt) {
var self = this;
self.id = ko.observable(id);
self.txt = ko.observable(txt);
self.students = ko.observableArray([]);
self.studentsCsv = ko.computed(function() {
return self.students().map(function(s) { return s.txt(); }).join(", ");
});
self.childToBeEnrolled = ko.observable(null);
}
function Child(id, txt) {
var self = this;
self.id = ko.observable(id);
self.txt = ko.observable(txt);
self.classToBeEnrolledIn = ko.observable(null);
}
var english = new Class(1, "English 1");
var math1 = new Class(2, "Mathematics 1");
var math2 = new Class(2, "Mathematics 2");
var john = new Child(1, "John Doe");
var mary = new Child(1, "Mary Roe");
var rick = new Child(1, "Rick Roll");
var marc = new Child(1, "Marcus Aurelius");
var school = new School([english, math1, math2]);
ko.applyBindings(school);
// Method 1:
school.enroll(john, english);
school.enroll(john, math2);
school.enroll(marc, english);
school.enroll(mary, math2);
school.enroll(mary, english);
school.enroll(rick, english);
school.enroll(rick, math1);
td { background-color: #eee; padding: 2px 10px; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.0/knockout-min.js"></script>
<h3>Classes:</h3>
<table>
<tbody data-bind="foreach: classes">
<tr>
<td><span data-bind="text: txt"></span></td>
<td><span data-bind="text: studentsCsv"></span></td>
<td>
Add student
<select data-bind="options: $root.students, value: childToBeEnrolled, optionsText: 'txt', optionsCaption: 'Choose...'"></select>
<button data-bind="click: $root.enrollNewChild">enroll now</button>
</td>
</tr>
</tbody>
</table>
<h3>School Students</h3>
<table>
<tbody data-bind="foreach: students">
<tr>
<td><span data-bind="text: txt"></span></td>
<td>
Enroll in:
<select data-bind="options: $root.classes, optionsText: 'txt', value: classToBeEnrolledIn, optionsCaption: 'Choose...'"></select>
<button data-bind="click: $root.enrollInClass">enroll now</button>
</td>
</tr>
</tbody>
</table>
It does not answer your question directly ("display a filtered list of students based on the class id property") because I think that's an XY-problem, and you're better off trying to find a solution like the above where you have proper references instead of having to use id and some kind of lookup mechanism.
Yes I can see how it could be considered a bit of an XY problem. You both made look at it differently which was a great help.
The way I originally would have liked wasn't looking feasible. My compromise was to add a computed to the root view model and display the list this way.
function School()
{
var self = this;
self.ClassVMs = ko.observableArray([]).indexed('Number');
self.ChildVMs = ko.observableArray([]).indexed('Number');
self.DisplayClassId = ko.observable(1);
self.Display = function(x)
{
console.log(x);
self.DisplayClassId(x);
}
}
var viewModel = new School();
viewModel.filteredItems = ko.computed(function () {
var filter = viewModel.DisplayClassId();
if (!filter) {
return viewModel.ChildVMs();
} else {
var filtered = ko.utils.arrayFilter(viewModel.ChildVMs(), function (item) {
return (item.ClassId() === filter);
});
return filtered;
}
})
Fiddle for reference
Yesterday I make this question:
How can I refresh or load JSON to my viewModel on Knockout JS with complex models
Everything works OK with the fixes but when I try to use a complex json to load in the viewModel some of the buttons (specifically on Groups) doesn't work.
To resume the problem. I have a json with the previous serialized data. I use that json to fill the viewModel, this works, load correctly the data but the problem are in the "group" template, because the data is loaded but the buttons doesn't work, the only button which is working is the "remove group".
(Please refer to the image)
Any idea to fix this? Thanks.
Jsfiddle example with the problem
http://jsfiddle.net/y98dvy56/26/
!Check this picture.
The red circles indicates the buttons with problems.
The green circles indicates the buttons without problems.
Here is the body html
<div class="container">
<h1>Knockout.js Query Builder</h1>
<div class="alert alert-info">
<strong>Example Output</strong><br/>
</div>
<div data-bind="with: group">
<div data-bind="template: templateName"></div>
</div>
<input type="submit" value="Save" data-bind="click: Save"/>
</div>
<!-- HTML Template For Conditions -->
<script id="condition-template" type="text/html">
<div class="condition">
<select data-bind="options: fields, value: selectedField"></select>
<select data-bind="options: comparisons, value: selectedComparison"></select>
<input type="text" data-bind="value: value"></input>
<button class="btn btn-danger btn-xs" data-bind="click: $parent.removeChild"><span class="glyphicon glyphicon-minus-sign"></span></button>
</div>
</script>
<!-- HTML Template For Groups -->
<script id="group-template" type="text/html">
<div class="alert alert-warning alert-group">
<select data-bind="options: logicalOperators, value: selectedLogicalOperator"></select>
<button class="btn btn-xs btn-success" data-bind="click: addCondition"><span class="glyphicon glyphicon-plus-sign"></span> Add Condition</button>
<button class="btn btn-xs btn-success" data-bind="click: .addGroup"><span class="glyphicon glyphicon-plus-sign"></span> Add Group</button>
<button class="btn btn-xs btn-danger" data-bind="click: $parent.removeChild"><span class="glyphicon glyphicon-minus-sign"></span> Remove Group</button>
<div class="group-conditions">
<div data-bind="foreach: children">
<div data-bind="template: templateName"></div>
</div>
</div>
</div>
</script>
<!-- js -->
<script src="js/vendor/knockout-2.2.1.js"></script>
<script src="js/vendor/knockout-mapping.js"></script>
<script src="js/condition.js"></script>
<script src="js/group.js"></script>
<script src="js/viewModel.js"></script>
<script>
window.addEventListener('load', function(){
var json =
{"group":{"templateName":"group-template","children":[{"templateName":"condition-template","fields":["Points","Goals","Assists","Shots","Shot%","PPG","SHG","Penalty Mins"],"selectedField":"Points","comparisons":["=","<>","<","<=",">",">="],"selectedComparison":"=","value":0,"text":"Points = 0"},{"templateName":"condition-template","fields":["Points","Goals","Assists","Shots","Shot%","PPG","SHG","Penalty Mins"],"selectedField":"Points","comparisons":["=","<>","<","<=",">",">="],"selectedComparison":"=","value":0,"text":"Points = 0"},{"templateName":"condition-template","fields":["Points","Goals","Assists","Shots","Shot%","PPG","SHG","Penalty Mins"],"selectedField":"Points","comparisons":["=","<>","<","<=",">",">="],"selectedComparison":"=","value":0,"text":"Points = 0"},{"templateName":"group-template","children":[{"templateName":"condition-template","fields":["Points","Goals","Assists","Shots","Shot%","PPG","SHG","Penalty Mins"],"selectedField":"Points","comparisons":["=","<>","<","<=",">",">="],"selectedComparison":"=","value":0,"text":"Points = 0"},{"templateName":"condition-template","fields":["Points","Goals","Assists","Shots","Shot%","PPG","SHG","Penalty Mins"],"selectedField":"Points","comparisons":["=","<>","<","<=",">",">="],"selectedComparison":"=","value":0,"text":"Points = 0"},{"templateName":"condition-template","fields":["Points","Goals","Assists","Shots","Shot%","PPG","SHG","Penalty Mins"],"selectedField":"Points","comparisons":["=","<>","<","<=",">",">="],"selectedComparison":"=","value":0,"text":"Points = 0"}],"logicalOperators":["AND","OR"],"selectedLogicalOperator":"AND","text":"(Points = 0 AND Points = 0 AND Points = 0)"}],"logicalOperators":["AND","OR"],"selectedLogicalOperator":"AND","text":"(Points = 0 AND Points = 0 AND Points = 0 AND (Points = 0 AND Points = 0 AND Points = 0))"},"text":"(Points = 0 AND Points = 0 AND Points = 0 AND (Points = 0 AND Points = 0 AND Points = 0))"};
var vm = new QueryBuilder.ViewModel();
ko.mapping.fromJS(json.group, {}, vm.group);
ko.applyBindings(vm);
}, true);
</script>
Condition.js:
window.QueryBuilder = (function(exports, ko){
function Condition(){
var self = this;
self.templateName = 'condition-template';
self.fields = ko.observableArray(['Points', 'Goals', 'Assists', 'Shots', 'Shot%', 'PPG', 'SHG', 'Penalty Mins']);
self.selectedField = ko.observable('Points');
self.comparisons = ko.observableArray(['=', '<>', '<', '<=', '>', '>=']);
self.selectedComparison = ko.observable('=');
self.value = ko.observable(0);
}
exports.Condition = Condition;
return exports;
})(window.QueryBuilder || {}, window.ko);
Group.js
window.QueryBuilder = (function(exports, ko){
var Condition = exports.Condition;
function Group(){
var self = this;
self.templateName = 'group-template';
self.children = ko.observableArray();
self.logicalOperators = ko.observableArray(['AND', 'OR']);
self.selectedLogicalOperator = ko.observable('AND');
// give the group a single default condition
self.children.push(new Condition());
self.addCondition = function(){
self.children.push(new Condition());
};
self.addGroup = function(){
self.children.push(new Group());
};
self.removeChild = function(child){
self.children.remove(child);
};
}
exports.Group = Group;
return exports;
})(window.QueryBuilder || {}, window.ko);
ViewModel.js
window.QueryBuilder = (function(exports, ko){
var Group = exports.Group;
function ViewModel() {
var self = this;
self.group = ko.observable(new Group());
self.load = function (data) {
ko.mapping.fromJS(data, self);
}
self.Save = function () {
console.log(ko.toJSON(self));
}
}
exports.ViewModel = ViewModel;
return exports;
})(window.QueryBuilder || {}, window.ko);
Your issue is caused by the fact that the mapping plugin makes your data observable, but doesn't augment your data with the functions in your model such as the add, remove, etc... functions. If you do a console log for the json data when it's inserted into the view model you will notice that the data is observable but the functions are missing. You need to provide a mapping to customize your Group, Condition, etc.. constructors. Because the children array in your case is of mixed types (condition or group) Here is a custom mapping to take care of that:
var childrenMapping = {
'children': {
create: function(options) {
var data = options.data;
console.log(data);
var object;
switch(data.templateName) {
case 'condition-template':
object = new QueryBuilder.Condition(data);
break;
case 'group-template':
object = new QueryBuilder.Group(data);
break;
}
return object;
}
}
};
Then you simply need to provide this mapping in your initial mapping
ko.mapping.fromJS(json.group, childrenMapping, vm.group);
Then inside the constructor of the Group object:
function Group(data){
var self = this;
self.templateName = 'group-template';
...
ko.mapping.fromJS(data, childrenMapping, this);
}
You also need to update the Condition constructor to accept the data provided by the mapping, but since conditions don't have children you do not need to provide the childrenMapping here:
function Condition(data){
var self = this;
self.templateName = 'condition-template';
...
ko.mapping.fromJS(data, {}, this);
}
I've the mapping at the end of both function so that the mapped values override you initial value.
The updated jsfiddle here:
http://jsfiddle.net/omerio/y98dvy56/32/
This answer is related:
knockout recursive mapping issue
I have a simple webpage with a large list of products (20,000+). When you can click on a product, it will load (via AJAX) a list of colors and display them inline. Html...
<div data-bind="foreach: products">
<span data-bind="click: $root.loadColors($data), text: $name"></span>
<ul data-bind="foreach: colors">
<li data-bind="text:$data" />
</ul
</div>
Shop view model:
function shopViewModel()
{
var self = this;
self.products = ko.observableArray([]);
self.loadColors = function(product)
{
var data = GetColorsByAjax();
product.colors(data);
}
}
Product view Model:
function productModel(data)
{
var self = this;
self.name = data.name;
self.colors = ko.observableArray([]);
}
When I have 20,000+ products, it uses a lot of memory. Each product has a colors array, which is always empty/null, until the user clicks on it, but it still uses a lot of memory.
Ideally, I'd like to remove the colors observableArray and somehow create it dynamically when user clicks on the product. Or separate it into a new viewModel.
I want to eliminate the empty observableArrays to minimise memory, but can't figure out how it do it.
I would use one of Knockout's control-flow bindings (if, with) to only bind the colors:foreach when there is actually a colors property on the productModel().
HTML:
<div data-bind="foreach: products">
<span data-bind="click: $root.loadColors($data), text: $name"></span>
<div data-bind="if: hasColors">
<ul data-bind="foreach: colors">
<li data-bind="text:$data" />
</ul>
</div>
</div>
Product View Model:
function productModel(data)
{
var self = this;
self.name = data.name;
self.hasColors = ko.observable(false);
self.colors = null;
}
Shop View Model
function shopViewModel()
{
var self = this;
self.products = ko.observableArray([]);
self.loadColors = function(product)
{
var data = GetColorsByAjax();
if(product.colors == null) {
product.colors = ko.observableArray(data);
product.hasColors(true);
} else {
product.colors(data);
}
}
}
You don't have to store an empty observable array: you can default to undefined and Knockout will treat it as an empty array in a foreach binding.
Here's a demonstration: http://jsfiddle.net/zm62T/
Here is a fiddle
I have this html:
<div class="margin:0px; padding:0px; outline:0; border:0;" data-bind="with: notesViewModel">
<table class="table table-striped table-hover" data-bind="with: notes">
<thead><tr><th>Date Logged</th><th>Content</th><th>Logged By</th><th></th></tr>
</thead>
<tbody data-bind="foreach: allNotes">
<tr>
<td data-bind="text: date"></td>
<td data-bind="text: compressedContent"></td>
<td data-bind="text: logged"></td>
<td><img src="/images/detail.png" data-bind="click: $root.goToNote.bind($data, $index())" width="20" alt="Details"/></td>
</tr>
</tbody>
</table>
<div class="noteView" data-bind="with: chosenNote">
<div class="info">
<p><label>Date:</label><span data-bind="text: date"></span></p>
<p><label>Logged:</label><span data-bind="text: logged"></span></p>
</div>
<p class="message" data-bind="html: content"></p>
<button class="btn btn-default" data-bind="click: $root.toNotes">Back to Notes</button>
</div>
<div class="editor-label" style="margin-top:10px">
Notes
</div>
<div class="editor-field">
<textarea id="contact_note" rows="5" class="form-control" data-bind="value: $root.noteContent"></textarea>
<p data-bind="text: $root.characterCounter"></p>
<button class="btn btn-info" data-bind="click: $root.saveNotes">Save</button>
<div data-bind="html: $root.status">
</div>
</div>
</div>
And this JavaScript using knockout:
var notesViewModel = function () {
var self = this;
self.notes = ko.observable(null);
self.chosenNote = ko.observable();
self.allNotes = new Array();
self.user = "user1";
// behaviours
self.goToNote = function (noteIndex) {
self.notes(null);
self.chosenNote(new note(self.allNotes[noteIndex]));
};
self.toNotes = function () {
self.chosenNote(null);
self.notes({ allNotes: $.map(self.allNotes, function (item) { return new note(item); }) });
console.log(self.notes());
}
self.noteContent = ko.observable();
self.saveNotes = function () {
var request = $.ajax({
url: "EnquiryManagement/Contact/SaveNotes",
type: "GET",
dataType: "json",
data: { id: "1322dsa142d2131we2", content: self.noteContent() }
});
request.done(function (result, message) {
var mess = "";
var err = false;
var imgSrc = "";
if (message = "success") {
if (result.success) {
mess = "Successfully Updated";
imgSrc = "/images/tick.png";
self.allNotes.push({ date: new Date().toUTCString(), content: self.noteContent(), logged: self.user });
self.toNotes();
} else {
mess = "Server Error";
imgSrc = "/images/redcross.png";
err = true;
}
} else {
mess = "Ajax Client Error";
imgSrc = "/images/redcross.png";
err = true;
}
self.status(CRTBL.CreateMessageOutput(err, mess, imgSrc));
self.noteContent(null);
setTimeout(function () {
self.status(null);
}, 4000);
});
};
self.status = ko.observable();
self.characterCounter = ko.computed(function () {
return self.noteContent() == undefined ? 0 : self.noteContent().length;
});
};
var note = function (data) {
var self = this;
console.log(data.date);
self.date = CRTBL.FormatIsoDate(data.date);
self.content = data.content;
self.compressedContent = data.content == null ? "" : data.content.length < 25 ? data.content : data.content.substring(0, 25) + " ...";
self.logged = data.logged;
console.log(this);
};
ko.applyBindings(new notesViewModel());
When I first load the page it says:
Uncaught Error: Unable to parse bindings.
Message: ReferenceError: notes is not defined;
Bindings value: with: notes
However, I pass it null, so it shouldn't show anything, because when I do the function goToNote then do goToNotes it sets the notes observable to null
So why can't I start off with this null value?
The problem is where you have:
<div data-bind="with: notesViewModel">
That makes it look for a property "notesViewModel" within your notesViewModel, which does not exist.
If you only have one view model you can just remove that data binding and it will work fine.
If, however, you wish to apply your view model to just that div specifically and not the entire page, give it an ID or some other form of accessor, and add it as the second parameter in applyBindings, as follows:
HTML:
<div id="myDiv">
JS:
ko.applyBindings(new notesViewModel(), document.getElementById('myDiv'));
This is generally only necessary where you have multiple view models in the same page.
Like what bcmcfc has put, however, due to my scenario being a multi-viewModel scenario I don't think his solution is quite the right one.
In order to achieve the correct results, first of all I extrapolated out the self.notes = ko.observable(null); into a viewModel which makes doing the table binding far easier.
Then to fix the binding issues instead of setting an element for the bind to take place, I merely did this:
ko.applyBindings({
mainViewModel: new mainViewModel(),
notesViewModel: new notesViewModel()
});
In my original code I have two viewModels which is why I was getting this error. With this method the key is:
I don't create dependancies!
Instead of tieing the viewModel to a certain dom element which can change quite easily and cause having to go and changes things with ko, plus if I add more viewModels then it can get more complicated. I simply do:
data-bind="with: viewModel"
That way I can bind to any DOM object and I can have has many as I like.
This is the solution that solved my post.
Here is the jsfiddle