I am using Knockout in combination with bootstrap-multiselect. I have three columns in an HTML table which allow you to specify a search item, search operator and search criteria. The search operator column actually holds three different elements: one multi-select, one single select and an input element; only one out of the three elements is actually displayed at any given time. Which element is displayed is controlled by the value of the search item column.
All select elements are initialized as bootstrap-multiselect. The search criteria column and the search operator column work without issue. The search item column however does not seem to be properly binding back to the native select which bootstrap-multiselect hides. The search item column will have the radio button selected next to the value you picked, however the text will display the first value from the select list. If I do not initialize the search item column as a bootstrap-multiselect, everything works as expected with the native select for the search-item column. The Knockout viewmodel is updated with the correct value as expected.
Below is my HTML declaration for the search criteria table:
<table id="search-criteria" class="table table-condensed hidden" data-bind="css: {hidden:SpecifiedCriteria().length == 0}">
<thead>
<tr>
<th class ="search-item">
Item
</th>
<th class="search-operator">
Operator
</th>
<th class="search-criteria">
Criteria
</th>
</tr>
</thead>
<tbody data-bind="foreach:SpecifiedCriteria">
<tr>
<td class="search-item">
<select class="search-item" data-bind="searchDropDownList:SelectedItem, foreach: $root.CriteriaItems, value:SelectedItem">
<optgroup data-bind="attr: {label: label}, foreach: children">
<option data-bind="text: DisplayName, option: $data"></option>
</optgroup>
</select>
</td>
<td class="search-operator">
<select class="search-operator" data-bind="searchDropDownList:MatchCompareOperator, options:AvailableCompareOperators(), optionsValue: 'Value', optionsText: 'Key', value:MatchCompareOperator"></select>
</td>
<td class="search-criteria">
<input type="text" class="search-criteria" data-bind="value:MatchValue" />
<select class="search-criteria" data-bind="searchDropDownList:MatchValueListSelectedItemIds, options:MatchValueList, optionsValue:'Id', optionsText:'Description', selectedOptions:MatchValueListSelectedItemIds"></select>
<select class="search-criteria" multiple="multiple" data-bind="searchDropDownList:MatchValueListSelectedItemIds, options:MatchValueList, optionsValue:'Id', optionsText:'Description', selectedOptions:MatchValueListSelectedItemIds"></select>
</td>
</tr>
</tbody>
</table>
This is my view model, of which I new up an instance and pass into ko.applyBindings():
searchCriteria = function () {
var comparisonOperators = Array(
{ "Key": "Starts With", "Value": 0 },
{ "Key": "Greater Than", "Value": 1 },
{ "Key": "Less Than", "Value": 2 },
{ "Key": "Greater Or Equal To", "Value": 3 },
{ "Key": "Less or Equal To", "Value": 4 },
{ "Key": "Not Equal To", "Value": 5 },
{ "Key": "Equal To", "Value": 6 },
{ "Key": "Contains", "Value": 7 },
{ "Key": "Does Not Contain", "Value": 8 });
var criteriaDataType = {
Alpha: 0,
AlphaNumeric: 1,
Integer: 2,
Date: 3,
DateTime: 4,
Boolean: 5
};
//available styles constants
var matchStyle = {
InputBox: 0,
DropDownList: 1,
MultiSelectDropDownList: 2,
DatePicker: 3
};
//available operator constants
var matchCompareOperator = {
StartsWith: 0,
GreaterThan: 1,
LessThan: 2,
GreaterOrEqualTo: 3,
LessOrEqualTo: 4,
NotEqualTo: 5,
EqualTo: 6,
Contains: 7,
DoesNotContain: 8
};
//determine which if the operator is eligible for the matchStyle
function filterOperators(operator, specifiedMatchStyle, specifiedCriteriaDataType) {
var eligible = false;
switch (specifiedMatchStyle) {
case matchStyle.MultiSelectDropDownList:
case matchStyle.DropDownList:
eligible = (operator.Value === matchCompareOperator.EqualTo || operator.Value === matchCompareOperator.NotEqualTo);
break;
case matchStyle.DatePicker:
eligible = (operator.Value !== matchCompareOperator.StartsWith &&
operator.Value !== matchCompareOperator.Contains &&
operator.Value !== matchCompareOperator.DoesNotContain);
break;
case matchStyle.InputBox:
if (specifiedCriteriaDataType !== criteriaDataType.Integer &&
specifiedCriteriaDataType !== criteriaDataType.Date &&
specifiedCriteriaDataType !== criteriaDataType.DateTime) {
if (operator.Value !== matchCompareOperator.GreaterThan &&
operator.Value !== matchCompareOperator.GreaterOrEqualTo &&
operator.Value !== matchCompareOperator.LessThan &&
operator.Value !== matchCompareOperator.LessOrEqualTo) {
eligible = true;
}
} else {
if (operator.Value !== matchCompareOperator.StartsWith)
eligible = true;
}
break;
}
return eligible;
}
var searchCriteriaModel = function (criteriaItems, postUrl) {
var modelObject = this;
//setup the model elements
modelObject.SpecifiedCriteria = ko.mapping.fromJS([]);
modelObject.ComparisonOperators = comparisonOperators;
var resultsDataTable = $("#search-results").dataTable();
//create the criteria group items
modelObject.CriteriaItems = [];
var group = function (label, children) {
this.label = ko.observable(label);
this.children = ko.observableArray(children);
}
var groups = ko.utils.arrayMap(criteriaItems, function (item) { return item.DisplayGroupName; });
groups = ko.utils.arrayGetDistinctValues(groups).sort();
//filter the criteria items array based on the group and build the list of grouped items
ko.utils.arrayForEach(groups, function (groupItemDisplayGroupName) {
modelObject.CriteriaItems.push(
new group(groupItemDisplayGroupName,
ko.utils.arrayFilter(criteriaItems, function (item) {
return (item.DisplayGroupName === groupItemDisplayGroupName);
})
)
);
});
var criteriaItem = function () {
var itemObject = this;
//setup the properties we are binding observables on
itemObject.SelectedItem = ko.observable();
itemObject.MatchValueColumnName = ko.observable();
itemObject.MatchCompareOperator = ko.observable();
itemObject.MatchValue = ko.observable();
itemObject.DisplayName = ko.observable();
itemObject.MatchStyle = ko.observable();
itemObject.MatchValueList = ko.observableArray();
itemObject.MatchValueListSelectedItemIds = ko.observableArray();
itemObject.AvailableCompareOperators = ko.observableArray();
//a computed which contains just the list of our selected items
itemObject.MatchValueListSelectedItems = ko.computed(function () {
return ko.utils.arrayFilter(itemObject.MatchValueList(), function (matchValueListItem) {
return ko.utils.arrayFirst(itemObject.MatchValueListSelectedItemIds(), function (selectedItemId) {
return (matchValueListItem.Id === selectedItemId);
});
});
});
itemObject.SelectedItem.subscribe(function (newValue) {
if (newValue === undefined || newValue === null)
return;
//copy over all of the other attributes from the selected item to our item
itemObject.DataIdColumnName = newValue.DataIdColumnName;
itemObject.DataIdColumnValue = newValue.DataIdColumnValue;
itemObject.DisplayGroupName = newValue.DisplayGroupName;
itemObject.DisplayName(newValue.DisplayName);
itemObject.MatchValueDataType = newValue.MatchValueDataType;
itemObject.MatchValue(newValue.MatchValue);
itemObject.MatchStyle(newValue.MatchStyle);
itemObject.MatchValueColumnName = newValue.MatchValueColumnName;
//initialize the compareoperator list
itemObject.AvailableCompareOperators.removeAll();
ko.utils.arrayPushAll(itemObject.AvailableCompareOperators,
ko.utils.arrayFilter(modelObject.ComparisonOperators, function (operator) { return filterOperators(operator, newValue.MatchStyle, newValue.MatchValueDataType) }));
itemObject.MatchCompareOperator(newValue.MatchCompareOperator);
//initialize the value list
itemObject.MatchValueList.removeAll();
if (newValue.MatchValueList !== null)
ko.utils.arrayPushAll(itemObject.MatchValueList, newValue.MatchValueList);
itemObject.MatchValueListDisplayMember = newValue.MatchValueListDisplayMember;
itemObject.MatchValueListSelectedItemIds.removeAll();
});
};
//add a new item
modelObject.addCriteriaItem = function () {
var item = new criteriaItem();
modelObject.SpecifiedCriteria.push(item);
};
//clear all items
modelObject.clearCriteriaItems = function () {
modelObject.SpecifiedCriteria.removeAll();
$("#item-criteria-results").addClass("hidden");
resultsDataTable.fnClearTable();
modelObject.addCriteriaItem();
};
//seed with an empty row
modelObject.addCriteriaItem();
ko.bindingHandlers.option = {
update: function (element, valueAccessor) {
var value = ko.utils.unwrapObservable(valueAccessor());
ko.selectExtensions.writeValue(element, value);
}
};
//set up the ko search-multiselect binding for use with bootstrap multiselect
ko.bindingHandlers.searchDropDownList = {
update: function (element, valueAccessor, allBindings, viewMode, bindingContext) {
if (element.nodeName.toLowerCase() !== "select")
return;
//get the value and wire up the subscription
var value = ko.utils.unwrapObservable(valueAccessor());
if (value === null || value === undefined)
return;
//cache the element so we don't have to look it up every time
var triggerElement = $(element);
var row = $(triggerElement).parents("tr");
//initialize the multi-select list
var searchItem = null;
var operator = $(row).find("select.search-operator");
//initialize the multi-selects if this is our first time through
if (!triggerElement.hasClass("initialized")) {
if (triggerElement.hasClass("search-item")) {
//make sure we deselect any item
//triggerElement.prop("selectedIndex", -1);
triggerElement.multiselect({
buttonWidth: "100%",
maxHeight: 310,
enableFiltering: true,
enableCaseInsensitiveFiltering: true
});
} else {
triggerElement.multiselect({ buttonWidth: "100%" });
}
//flag the element as initialized
triggerElement.addClass("initialized");
//toggle off the operator and all of the criteria entry boxes
operator.siblings(".btn-group").addClass("hidden");
$(row).find("select.search-criteria").siblings(".btn-group").addClass("hidden");
$(row).find("input.search-criteria").addClass("hidden");
return;
}
//toggle the display of the operator and the search criteria
if (triggerElement.hasClass("search-item")) {
if (operator.hasClass("initialized")) {
operator.multiselect("rebuild");
$(operator).siblings(".btn-group").first().removeClass("hidden");
}
//toggle off all of the criteria entry boxes
$(row).find("select.search-criteria").siblings(".btn-group").addClass("hidden");
$(row).find("input.search-criteria").addClass("hidden");
var searchCriteria = null;
if (value.MatchStyle === matchStyle.MultiSelectDropDownList)
searchCriteria = $(row).find("select[multiple='multiple'].search-criteria");
else if (value.MatchStyle === matchStyle.DropDownList)
searchCriteria = $(row).find("select:not([multiple]).search-criteria");
else
searchCriteria = $(row).find("input[type=text].search-criteria");
if (searchCriteria != null) {
if (searchCriteria.hasClass("initialized")) {
//we are a select
searchCriteria.multiselect("rebuild");
searchCriteria.next(".btn-group").removeClass("hidden");
} else {
searchCriteria.removeClass("hidden"); //we are an inputbox
}
}
}
}
};
}
return {
criteriaDataType: criteriaDataType,
matchStyle: matchStyle,
matchCompareOperator: matchCompareOperator,
SearchCriteriaModel: searchCriteriaModel
}
}();
Just to recap: if I do not wire up the search-item column as a bootstrap-multiselect, everything works. If I do wire it up, the column will display the first value in the list. Setting a breakpoint in the searchDropDownList binding handler will always show the value gotten via the valueAccessor to be the first item in the list for the given search-item element.
I am wits end and cannot understand or explain this behavior for the searc-item column, especially since the other two columns seeming work without issue.