I have a 3 tier cascading select list that I have implemented using knockout.js / jQuery / json.
There may be instances where a select box only has 1 choice within it - in which case I'd like to not force the user to have to manually select it and instead default it to the single value, and cascade down to the next box automatically. Can this be done?
My select lists (the first is currently slightly different because it's generated by MVC Razor view with values supplied direct from view model):
<!--Variants-->
<select class="dim" data-bind="value: selectedDim1" id="Dim1" name="Dim1" onchange="FetchDim2();"><option selected="selected" value="">Select Colour</option>
<option value="Black">Colour:Black</option>
<option value="NAVYBLUE">Colour:Navy Blue</option>
</select>
<select id="Dim2" data-bind="value: selectedDim2, options: ddlDim2, optionsText: 'Text', optionsValue: 'Value', optionsCaption: 'Select Waist Size'" class="dim"></select>
<select id="Dim3" data-bind="value: selectedDim3, options: ddlDim3, optionsText: 'Text', optionsValue: 'Value', optionsCaption: 'Select Leg Length'" class="dim"></select>
My knockout code:
function DDLViewModel() {
this.ddlDim1 = ko.observableArray([]);
this.ddlDim2 = ko.observableArray([]);
this.ddlDim3 = ko.observableArray([]);
this.selectedDim1 = ko.observable();
this.selectedDim1.subscribe(FetchDim2, this);
this.selectedDim2 = ko.observable();
this.selectedDim2.subscribe(FetchDim3, this);
this.selectedDim3 = ko.observable();
this.selectedDim3.subscribe(FetchVariant, this);
}
var objVM = new DDLViewModel();
// Activates knockout.js
ko.applyBindings(objVM);
function FetchDim2() {
$.ajax({
type: 'POST',
url: '/product/getdims/', // we are calling json method
dataType: 'json',
// here we get value of selected dim.
data: { id: 20408,
level: 2,
head: 'Waist Size',
code: $("#Dim1").val()
},
success: function (dims) {
// dims contains the JSON formatted list of dims passed from the controller
objVM.ddlDim2(dims);
objVM.ddlDim3.removeAll();
},
error: function (ex) {
alert('Failed to retrieve dims.' + ex);
}
});
}
function FetchDim3() {
$.ajax({
type: 'POST',
url: '/product/getdims/', // we are calling json method
dataType: 'json',
// here we get value of selected dim.
data: { id: 20408,
level: 3,
head: 'Leg Length',
code: $("#Dim2").val()
},
success: function (dims) {
// dims contains the JSON formatted list of dims passed from the controller
objVM.ddlDim3(dims);
},
error: function (ex) {
alert('Failed to retrieve dims.' + ex);
}
});
}
I guess I (a) need to specify a default value if there is only on choice and (b) force the call of the code that populates the next level down? Not sure how to do either without breaking it all though!
Yikes! Don't mix jQuery and Knockout like that. Don't let jQuery do DOM manipulation (e.g. val(...)) but instead get the selected value from your view model.
This will also greatly simplify your view, as things like those id attributes become irrelevant.
Furthermore, I recommend making the Fetch... methods a dependency for your view model. In my example below I just inline those functions inside the view model constructor function, but you could also wrap them in a service and have that service as a dependency (you'd still have to provide input and success handlers to that service of course).
Another thing, quite needed / useful if you follow the above advice: use the var self = this idiom, instead of reiterating this everywhere / providing the this argument everywhere.
With all those things changed, it becomes trivial to fix your original question. Triggering cascaded updates can be done inside the success functions. Before I show the full snippet, here's the nitty gritty for your actual question:
success: function(dims) {
self.ddlDim3(dims);
if (dims.length === 1) {
self.selectedDim3(dims[0].Value);
}
}
Simply put, this selects the first option if there is only one, and lets Knockout handle updating the DOM (and cascading, if needed).
Here's a full demo based off your original code:
// Fake the Ajax requests:
var $ = {
ajax: function(options) {
if (options.data.level === 2 && options.data.code === "Black") {
options.success([{
Text: "Waist size S",
Value: "S"
}, {
Text: "Waist size M",
Value: "M"
}, {
Text: "Waist size L",
Value: "L"
}]);
}
if (options.data.level === 2 && options.data.code === "NAVYBLUE") {
options.success([{
Text: "Waist size M",
Value: "M"
}]);
}
// Not faking lvl 3 as extensively, but the same would hold as above.
if (options.data.level === 3) {
options.success([{
Text: "Legs 40",
Value: "40"
}]);
}
}
};
function DDLViewModel() {
var self = this;
self.ddlDim1 = ko.observableArray([]);
self.ddlDim2 = ko.observableArray([]);
self.ddlDim3 = ko.observableArray([]);
self.selectedDim1 = ko.observable();
self.selectedDim1.subscribe(FetchDim2);
self.selectedDim2 = ko.observable();
self.selectedDim2.subscribe(FetchDim3);
self.selectedDim3 = ko.observable();
self.selectedDim3.subscribe(FetchVariant);
function FetchDim2() {
console.log(2);
$.ajax({
url: '/product/getdims/',
data: {
id: 20408,
level: 2,
head: 'Waist Size',
code: self.selectedDim1()
},
success: function(dims) {
self.ddlDim2(dims);
self.ddlDim3.removeAll();
if (dims.length === 1) {
self.selectedDim2(dims[0].Value);
} else {
self.selectedDim2(null);
}
}
});
}
function FetchDim3() {
if (!self.selectedDim2()) {
self.ddlDim3.removeAll();
} else {
$.ajax({
data: {
id: 20408,
level: 3,
head: 'Leg Length',
code: self.selectedDim2()
},
success: function(dims) {
self.ddlDim3(dims);
if (dims.length === 1) {
self.selectedDim3(dims[0].Value);
}
}
});
}
}
function FetchVariant() {
// Noop / not provided in question
}
}
ko.applyBindings(new DDLViewModel());
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.2.0/knockout-min.js"></script>
<select data-bind="value: selectedDim1">
<option selected="selected" value="">Select Colour</option>
<option value="Black">Colour:Black</option>
<option value="NAVYBLUE">Colour:Navy Blue</option>
</select>
<select data-bind="value: selectedDim2, options: ddlDim2, optionsText: 'Text', optionsValue: 'Value', optionsCaption: 'Select Waist Size'"></select>
<select data-bind="value: selectedDim3, options: ddlDim3, optionsText: 'Text', optionsValue: 'Value', optionsCaption: 'Select Leg Length'"></select>
Related
I have a select2js select field which retrieves a list of diseases based on user query to a database. The type of select is tags so that if the entry is not in the database, the user can add their own. An example of what it looks like is seen below:
The options below are populated by an ajax call made to a local api to retrieve the diseases from a database.
How can we write: "Suggestions" below the user input or hide the user input from the dropdown (seen above as "IgA ne") so that the user is more likely directed to choose one of the options from the database?
Some sample code:
HTML
<select id="diseases" class="form-control selectmultiple" name="diseases[]" multiple="multiple" aria-describedby="diseasesHelp">
</select>
<small id="diseasesHelp" class="form-text text-muted">If known to appear in certain diseases e.g. Tn syndrome</small>
JS
$('#diseases').select2({
tags: true,
placeholder: 'Select an item',
minimumInputLength: 3,
ajax: {
url: '/diseaseSelector',
dataType: 'json',
delay: 250,
processResults: function (data) {
return {
results: $.map(data, function (item) {
return {
text: item.name,
id: item.id
}
})
};
},
}
});
About the "Suggestions" below the user input, you could use Option Group. Using Ajax, the options should be in a Option Group object, in this format:
{
"text": "Group Name",
"children": [] // your options
}
So, in your case :
processResults: function (data) {
return {
results: [{
text: "Suggestions",
children: $.map(data, function (item) {
return {
text: item.name,
id: item.id
}
})
}]
};
},
I have been trying to subscribe to when a dropdown value changes. I have the following logic however I cannot seem to get it working.
HTML
<div id="case-pin-#modelItem.CaseID" data-caseid="#modelItem.CaseID" class="row hidden popovercontainer pinBinding">
<select data-bind="options:userPins,
value:selectedPin,
optionsCaption:'-- please select --',
optionsText: 'Name',
optionsValue: 'Id'"></select>
</div>
JS
function UserPinViewModel(caseId) {
var self = this;
self.selectedPin = ko.observable();
self.userPins = ko.observableArray([]);
self.caseId = caseId;
self.selectedPin.subscribe(function (newValue) {
console.log(newValue);
//addCaseToPin(newValue, self.caseId);
});
}
var pinObjs = [];
$(function () {
pinObjs = [];
$(".pinBinding").each(function () {
var caseId = this.getAttribute("data-caseid");
var view = new UserPinViewModel(caseId);
pinObjs.push(view);
ko.cleanNode(this);
ko.applyBindings(view, this);
});
})
The userPins array is populated by an AJAX call to the server as the values in the dropdown are dependent upon another section of the website which can change the values in the dropdown - here the logic I have used to populate the array.
function getPins() {
$.ajax({
type: 'POST',
url: '/Home/GetPins',
success: function (data) {
for (var i = 0; i < pinObjs.length; i++) {
pinObjs[i].userPins(data);
}
},
error: function (request, status, error) {
alert("Oooopppppsss! Something went wrong - " + error);
}
});
}
The actual values in the dropdowns all change to match what is returned from the server however whenever I manually change the dropdown, the subscription event is not fired.
You're using both jQuery and Knockout to manipulate the DOM, which is not a good idea. The whole idea of Knockout is that you don't manipulate the DOM, it does. You manipulate your viewModel.
Using cleanNode is also a code smell, indicating that you're doing things the wrong way. Knockout will handle that if you use the tools Knockout provides.
In this case, I was going to suggest a custom binding handler, but it looks like all you really want is to have a UserPinViewModel object created and applied to each instance of your .pinBinding element in the HTML. You can do that using the with binding, if you expose the UserPinViewModel constructor in your viewModel.
function UserPinViewModel(caseId) {
var self = this;
self.selectedPin = ko.observable();
self.userPins = ko.observableArray([]);
self.caseId = caseId;
self.selectedPin.subscribe(function(newValue) {
console.log(newValue);
//addCaseToPin(newValue, self.caseId);
});
// Pretend Ajax call to set pins
setTimeout(() => {
self.userPins([{
Name: 'option1',
Id: 1
}, {
Name: 'option2',
Id: 2
}, {
Name: 'option3',
Id: 3
}])
}, 800);
// Later, the options change
setTimeout(() => {
self.userPins([{
Name: 'animal1',
Id: 'Elephant'
}, {
Name: 'animal2',
Id: 'Pony'
}, {
Name: 'animal3',
Id: 'Donkey'
}])
}, 4000);
}
ko.bindingHandlers.pin = {
init: () => null,
update: () => null
};
ko.applyBindings({
pinVm: UserPinViewModel
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.2.0/knockout-min.js"></script>
<div id="case-pin-#modelItem.CaseID" data-bind="with: new pinVm('someCaseId')" class="row hidden popovercontainer pinBinding">
<select data-bind="options:userPins,
value:selectedPin,
optionsCaption:'-- please select --',
optionsText: 'Name',
optionsValue: 'Id'"></select>
</div>
Your getPins function suggests that the .pinBinding elements should correspond to the data being received. In that case, pinObjs should really be a part of your viewModel, and the elements should be generated (perhaps in a foreach) from the data, rather than being hard-coded. I don't know how that works with what I presume is the server-side #modelItem.CaseID, though.
I've been struggling with this one all day. I'm making an ajax call to a hard coded JSON file and attempting to store some of the contents into option tags using custom attributes. When I try to retrieve the data that I'm storing in the custom attribute, I keep getting [object Object]. If I try to JSON.stringify() that, I just get "[object Object]" (same as before, except wrapped in double quotes).
Some advice would be very helpful.
This is my currently empty select tag in HTML:
<select id="attackList"></select>
Actual JSON file:
{"attacks":[
{
"attackName":"Jab (1)",
"attackData":{"hitFrame":"9", "faf":"26", "damage":"1.5"}
},
{
"attackName":"Jab (3)",
"attackData":{"hitFrame":"11", "faf":"34", "damage":"2.7"}
},
{
"attackName":"Dash Attack (Early)",
"attackData":{"hitFrame":"15", "faf":"47", "damage":"10"}
},
{
"attackName":"Dash Attack (Late)",
"attackData":{"hitFrame":"21", "faf":"47", "damage":"8"}
},
{
"attackName":"Forward Tilt (1)",
"attackData":{"hitFrame":"12", "faf":"32", "damage":"3.5"}
},
{
"attackName":"Forward Tilt (3)",
"attackData":{"hitFrame":"14", "faf":"43", "damage":"8.5"}
},
{
"attackName":"Up Tilt(1, Early)",
"attackData":{"hitFrame":"7", "faf":"27", "damage":"5"}
},
{
"attackName":"Up Tilt (1, Late)",
"attackData":{"hitFrame":"9", "faf":"27", "damage":"2"}
},
{
"attackName":"Up Tilt (2)",
"attackData":{"hitFrame":"11", "faf":"27", "damage":"6"}
},
{
"attackName":"Down Tilt (Weak)",
"attackData":{"hitFrame":"7", "faf":"26", "damage":"6"}
},
{
"attackName":"Down Tilt (Strong)",
"attackData":{"hitFrame":"7", "faf":"26", "damage":"7"}
},
{
"attackName":"Forward Smash (Weak)",
"attackData":{"hitFrame":"19", "faf":"68", "damage":"14"}
},
{
"attackName":"Forward Smash (Strong)",
"attackData":{"hitFrame":"19", "faf":"68", "damage":"16"}
},
{
"attackName":"Up Smash (Early)",
"attackData":{"hitFrame":"18", "faf":"65", "damage":"17"}
},
{
"attackName":"Up Smash (Mid)",
"attackData":{"hitFrame":"20", "faf":"65", "damage":"16"}
},
{
"attackName":"Up Smash (Late)",
"attackData":{"hitFrame":"22", "faf":"65", "damage":"15"}
},
{
"attackName":"Up Smash (Late)",
"attackData":{"hitFrame":"22", "faf":"65", "damage":"15"}
},
{
"attackName":"Down Smash (1)",
"attackData":{"hitFrame":"20", "faf":"69", "damage":"5"}
},
{
"attackName":"Down Smash (2, Early)",
"attackData":{"hitFrame":"25", "faf":"69", "damage":"16"}
},
{
"attackName":"Down Smash (2, Late)",
"attackData":{"hitFrame":"26", "faf":"69", "damage":"15"}
}
]}
AJAX call that populates the select tag:
$.ajax({
url: attackerFileName,
dataType: 'json',
type: 'get',
cache:true,
success: function(data){
$(data.attacks).each(function(index,value){
console.log(value.attackData);
dropdownOptions.append($("<option></option>").attr("data-value", value.attackData).text(value.attackName));
});
}
});
And the JS code that attempts to retrieve the custom attribute from the currently selected option:
var selectedAttack = $("#attackList option:selected").data("value");
console.log(selectedAttack);
Anyone have any clue why I can't get the actual "attackData" contents from the JSON to come back? If I add code to log the attackData element from the JSON BEFORE its stored into the custom attribute, it comes back just fine. But after I retrieve it, [object Object] is all I get.
Thanks so much in advance to anyone who takes the time to look into this!
The html options can only take primitive values as a string representation.
When you set an option using the attr function, the string representation of the value is taken. In case it is an object, you will get back [object Object] as you are actually storing this value.
However, you can use the $.data function to set the data as an object.
Setting the data value in the following way should do the trick
$('<option></option>').data('value', value.attackData);
or as shown in the code snippet below
'use strict';
var mock = [{
name: 'Option 1',
value: {
identifier: 'option1',
value: {
hello: 'world'
}
}
}, {
name: 'Option 2',
value: {
identifier: 'option2',
value: {
world: 'hello'
}
}
}, {
name: 'Option 3',
value: {
identifier: 'option3',
value: {
sentence: 'hello world'
}
}
}];
$(function() {
setTimeout(function(data) {
// fake postback
var targetElement = $('#dropdown');
data.forEach(function(item) {
var option = $('<option></option>').data('value', item.value).text( item.name );
$(targetElement).append( option );
});
}.bind(null, mock));
$('#dropdown').on('change', function() {
var si = this.selectedIndex,
option = this.options[si],
name = option.text,
value = $.data( option, 'value' );
$('#output').html(name + '<br/>' + JSON.stringify(value));
});
});
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<select id="dropdown">
</select>
<div id="output">
</div>
So I have a Select that has its options from a computed. I want to select a default every time the selects options change.
I have tried several different ways of doing it:
subscribe to list - is called before list has returned so changes the value of the observable alright but it dosnt render right because the list changes AFTER.
afterRender - Does not work with this type of binding.
OptionsafterRender - works, as in the fiddle below, HOWEVER its called for every individual item rather then just once on the whole render so strikes me as the Wrong Way to do this.
var rawData = [{
Type: "1",
Color: "Blue",
Name: "Blue Car"
}, {
Type: "2",
Color: "Blue",
Name: "Blue House"
}, {
Type: "1",
Color: "Red",
Name: "Red Car"
}, {
Type: "2",
Color: "Red",
Name: "Red House"
}];
var viewModel = {
FirstSelectedOption: ko.observable(),
SecondSelectOptions: null,
SecondSelectedOption: ko.observable(),
Load: function() {
var self = viewModel;
self.SecondSelectOptions = ko.computed(function() {
var selected = self.FirstSelectedOption();
var returnValue = new Array({
Type: "*",
Color: "All",
Name: "All"
});
var filteredlist = ko.utils.arrayFilter(rawData, function(item) {
return item.Type == selected;
});
returnValue = returnValue.concat(filteredlist);
return returnValue;
}, self);
self.SecondSelectedOption.SetDefault = function() {
// we want the default to always be blue instead 'all', blue might not be the last option
var self = viewModel;
var defaultoption = ko.utils.arrayFirst(self.SecondSelectOptions(), function(item) {
return item.Color == "Blue";
});
self.SecondSelectedOption(defaultoption);
};
}
};
viewModel.Load();
ko.applyBindings(viewModel);
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.2.0/knockout-min.js"></script>
<select data-bind="value: FirstSelectedOption">
<option value="1">Car</option>
<option value="2">House</option>
</select>
<br/>
<select data-bind="options: SecondSelectOptions,
optionsText: 'Name',
value: SecondSelectedOption,
optionsAfterRender: SecondSelectedOption.SetDefault"></select>
http://jsfiddle.net/dt627rkp/
The only way I can think off off the top of my head is a custom binding...and im not even sure that would really be possible without reimplemnting the entire options binding.
I can't be the first one to want this, is there a best practice/way that I'm missing?
The optionsAfterRender callback passes 2 parameters: option (element), and item (data bound to the option). The callback already loops over the options, so no need to reiterate:
self.SecondSelectedOption.SetDefault = function (option, item) {
var self = viewModel;
if (item.Color === 'Blue')
self.SecondSelectedOption(item);
};
Updated fiddle
Ref: from the docs
EDIT: That being said, if you don't want the options to re-evaluate every time,
you could also simply bind the change event with the setDefault method on the first <select>. If I were faced with this code 'issue', I would probably preprocess the data into separate arrays, like in this fiddle
Problem
I have a text input that I have selectized as tags which works fine for querying remote data, I can search and even create new items using it and that all works OK.
Using selectize:
var $select = $('.authorsearch').selectize({
valueField: 'AuthorId',
labelField: 'AuthorName',
searchField: ['AuthorName'],
maxOptions: 10,
create: function (input, callback) {
$.ajax({
url: '/Author/AjaxCreate',
data: { 'AuthorName': input },
type: 'POST',
dataType: 'json',
success: function (response) {
return callback(response);
}
});
},
render: {
option: function (item, escape) {
return '<div>' + escape(item.AuthorName) + '</div>';
}
},
load: function (query, callback) {
if (!query.length) return callback();
$.ajax({
url: '/Author/SearchAuthorsByName/' + query,
type: 'POST',
dataType: 'json',
data: {
maxresults: 10
},
error: function () {
callback();
},
success: function (res) {
callback(res);
}
});
}
});
The text box:
<input class="authorsearch" id="Authors" name="Authors" type="text" value="" />
Examples:
Then when I select one (in this case 'apple') it comes up in a badge as you'd expect, and the underlying value of the textbox is a comma separated list of the values of these items.
Current Output
The problem is when I load a page and want values retrieved from the database to be displayed in the selectized text input as tags, it only loads the values and I can see no way of displaying the displayname instead.
<input class="authorsearch" id="Authors" name="Authors" type="text" value="1,3,4" />
Desired Ouput
I have tried all sorts of values for the inputs value field to have it load the items as showing their displayname and not their values. Below is an example of a single object being returned as JSON, being able to load a JSON array of these as selectized tags would be ideal.
[{"AuthorId":1,"AuthorName":"Test Author"},
{"AuthorId":3,"AuthorName":"Apple"},
{"AuthorId":4,"AuthorName":"Test Author 2"}]
How can I go about this? Do I need to form the value of the text box a particular way, or do I need to load my existing values using some javascript?
Thanks to your answer and based on your onInitialize() approach I ended up with a similar solution. In my case I just needed to translate one value, thus I was able to store the id and label as data attributes in the input field.
<input type="text" data-actual-value="1213" data-init-label="Label for 1213 item">
Then on initialization:
onInitialize: function() {
var actualValue = this.$input.data('actual-value');
if (actualValue){
this.addOption({id: actualValue, value: this.$input.data('init-label')});
this.setValue(actualValue);
this.blur();
}
}
According to these options:
$('input').selectize({
valueField: 'id',
labelField: 'value',
searchField: 'value',
create: false,
maxItems: 1,
preload: true,
// I had to initialize options in order to addOption to work properly
// although I'm loading the data remotely
options: [],
load: ... ,
render: ...,
onInitialize: ....
});
I know this does not answer your question but wanted to share just in case this could help someone.
I ended up using the onInitialize callback to load the JSON values stored in a data-* field. You can see it in action here in this jsfiddle.
<input class="authorsearch" id="Authors" name="Authors" type="text" value=""
data-selectize-value='[{"AuthorId":1,"AuthorName":"Test"},{"AuthorId":2,"AuthorName":"Test2"}]'/>
Basically it parses the data-selectize-value value and then adds the option(s) to the selectize then adds the items themselves.
onInitialize: function() {
var existingOptions = JSON.parse(this.$input.attr('data-selectize-value'));
var self = this;
if(Object.prototype.toString.call( existingOptions ) === "[object Array]") {
existingOptions.forEach( function (existingOption) {
self.addOption(existingOption);
self.addItem(existingOption[self.settings.valueField]);
});
}
else if (typeof existingOptions === 'object') {
self.addOption(existingOptions);
self.addItem(existingOptions[self.settings.valueField]);
}
}
My solution does presume my JSON object is formed correctly, and that it's either a single object or an object Array, so it may or may not be appropriate for someone elses needs.
So it parses:
[{"AuthorId":1,"AuthorName":"Test"},
{"AuthorId":2,"AuthorName":"Test2"}]
To:
Based of course on my selectize settings in my original post above.
Even simpler on new version of selectize using items attribute. Basically to set a selected item you need to have it first in the options. But if you use remote data like me, the options are empty so you need to add it to both places.
$('select').selectize({
valueField: 'id',
labelField: 'name',
options:[{id:'123',name:'hello'}],
items: ['123'],
...
This is working for me and took me a while to figure it out... so just sharing