Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web Apps: switched combobox to select2 #28701

Merged
merged 19 commits into from
Nov 16, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* globals moment, MapboxGeocoder */
/* globals moment, MapboxGeocoder, DOMPurify */
hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
var Const = hqImport("cloudcare/js/form_entry/const"),
Utils = hqImport("cloudcare/js/form_entry/utils"),
Expand Down Expand Up @@ -389,7 +389,7 @@ hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
};

this.helpText = function () {
return 'Phone number or Numeric ID';
return gettext('Phone number or Numeric ID');
};

this.enableReceiver(question, options);
Expand Down Expand Up @@ -418,7 +418,7 @@ hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
};

this.helpText = function () {
return 'Decimal';
return gettext('Decimal');
};
}
FloatEntry.prototype = Object.create(IntEntry.prototype);
Expand Down Expand Up @@ -530,15 +530,39 @@ hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
var self = this;
EntrySingleAnswer.call(this, question, options);
self.templateType = 'dropdown';
self.placeholderText = gettext('Please choose an item');

self.options = ko.pureComputed(function () {
self.helpText = function () {
return "";
};

self.options = ko.computed(function () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm guessing pureComputed doesn't have the subscribe option?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does, but the subscribers shouldn't have side effects. I think that rendering the select2 counts as a side effect, although I'm totally certain. I'm looking at the "When not to use a pure computed observable" section in these docs: https://knockoutjs.com/documentation/computed-pure.html

return _.map(question.choices(), function (choice, idx) {
return {
text: choice,
idx: idx + 1,
id: idx + 1,
};
});
});

self.options.subscribe(function () {
self.renderSelect2();
});

self.renderSelect2 = function () {
var $input = $('#' + self.entryId);
$input.select2({
allowClear: true,
placeholder: self.placeholderText,
escapeMarkup: function (m) { return DOMPurify.sanitize(m); },
matcher: self.matchOption,
});
};

self.afterRender = function () {
self.renderSelect2();
};
}
DropdownEntry.prototype = Object.create(EntrySingleAnswer.prototype);
DropdownEntry.prototype.constructor = EntrySingleAnswer;
Expand All @@ -556,147 +580,69 @@ hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
* when the user specifies combobox in the appearance attributes for a
* single select question.
*
* It uses the same UI as the dropdown, but a different matching algorithm.
*
* Docs: https://confluence.dimagi.com/display/commcarepublic/Advanced+CommCare+Android+Formatting#AdvancedCommCareAndroidFormatting-SingleSelect"ComboBox"
*/
function ComboboxEntry(question, options) {
var self = this,
initialOption;
EntrySingleAnswer.call(this, question, options);
DropdownEntry.call(this, question, options);

// Specifies the type of matching we will do when a user types a query
self.matchType = options.matchType;
self.lengthLimit = Infinity;
self.templateType = 'str';
self.placeholderText = gettext('Type to filter answers');

self.options = ko.computed(function () {
return _.map(question.choices(), function (choice, idx) {
return {
name: choice,
id: idx + 1,
};
});
});
self.options.subscribe(function () {
self.renderAtwho();
if (!self.isValid(self.rawAnswer())) {
self.question.error(gettext('Not a valid choice'));
}
});
self.helpText = function () {
return 'Combobox';
return gettext('Combobox');
};

// If there is a prexisting answer, set the rawAnswer to the corresponding text.
if (question.answer()) {
initialOption = self.options()[self.answer() - 1];
self.rawAnswer(
initialOption ? initialOption.name : Const.NO_ANSWER
);
}
self.matchOption = function (params, option) {
var query = $.trim(params.term);
if (!query || !option.text) {
return option;
}

self.renderAtwho = function () {
var $input = $('#' + self.entryId),
limit = Infinity,
$atwhoView;
$input.atwho('destroy');
$input.atwho('setIframe', window.frameElement, true);
$input.atwho({
at: '',
data: self.options(),
maxLen: Infinity,
tabSelectsMatch: false,
limit: limit,
suffix: '',
callbacks: {
filter: function (query, data) {
var results = _.filter(data, function (item) {
return ComboboxEntry.filter(query, item, self.matchType);
});
$atwhoView = $('.atwho-container .atwho-view');
$atwhoView.attr({
'data-message': 'Showing ' + Math.min(limit, results.length) + ' of ' + results.length,
});
return results;
},
matcher: function () {
return $input.val();
},
sorter: function (query, data) {
return data;
},
},
});
var match;
if (self.matchType === Const.COMBOBOX_MULTIWORD) {
// Multiword filter, matches any choice that contains all of the words in the query
//
// Assumption is both query and choice will not be very long. Runtime is O(nm)
// where n is number of words in the query, and m is number of words in the choice
var wordsInQuery = query.split(' ');
var wordsInChoice = option.text.split(' ');

match = _.all(wordsInQuery, function (word) {
return _.include(wordsInChoice, word) ? option : null;
});
} else if (self.matchType === Const.COMBOBOX_FUZZY) {
// Fuzzy filter, matches if query is "close" to answer
match = (
(window.Levenshtein.get(option.text.toLowerCase(), query.toLowerCase()) <= 2 && query.length > 3) ||
option.text.toLowerCase() === query.toLowerCase()
);
}

// If we've already matched, return true
if (match) {
return option;
}

// Standard filter, matches only start of word
return option.text.toLowerCase().startsWith(query.toLowerCase()) ? option : null;
};

self.isValid = function (value) {
if (!value) {
return true;
}
return _.include(
_.map(self.options(), function (option) {
return option.name;
}),
value
);
};

self.afterRender = function () {
self.renderAtwho();
return _.contains(self.choices(), value);
};

self.enableReceiver(question, options);
}

ComboboxEntry.filter = function (query, d, matchType) {
var match;
if (matchType === Const.COMBOBOX_MULTIWORD) {
// Multiword filter, matches any choice that contains all of the words in the query
//
// Assumption is both query and choice will not be very long. Runtime is O(nm)
// where n is number of words in the query, and m is number of words in the choice
var wordsInQuery = query.split(' ');
var wordsInChoice = d.name.split(' ');

match = _.all(wordsInQuery, function (word) {
return _.include(wordsInChoice, word);
});
} else if (matchType === Const.COMBOBOX_FUZZY) {
// Fuzzy filter, matches if query is "close" to answer
match = (
(window.Levenshtein.get(d.name.toLowerCase(), query.toLowerCase()) <= 2 && query.length > 3) ||
d.name.toLowerCase() === query.toLowerCase()
);
}

// If we've already matched, return true
if (match) {
return true;
}

// Standard filter, matches only start of word
return d.name.toLowerCase().startsWith(query.toLowerCase());
};

ComboboxEntry.prototype = Object.create(EntrySingleAnswer.prototype);
ComboboxEntry.prototype.constructor = EntrySingleAnswer;
ComboboxEntry.prototype.onPreProcess = function (newValue) {
var value;
if (newValue === Const.NO_ANSWER || newValue === '') {
this.answer(Const.NO_ANSWER);
this.question.error(null);
return;
}

value = _.find(this.options(), function (d) {
return d.name === newValue;
});
if (value) {
this.answer(value.id);
this.question.error(null);
} else {
this.question.error(gettext('Not a valid choice'));
}
};
ComboboxEntry.prototype = Object.create(DropdownEntry.prototype);
ComboboxEntry.prototype.constructor = DropdownEntry;
ComboboxEntry.prototype.receiveMessage = function (message, field) {
// Iterates through options and selects an option that matches message[field].
// Registers a no answer if message[field] is not in options.
Expand Down
17 changes: 10 additions & 7 deletions corehq/apps/cloudcare/templates/form_entry/templates.html
Original file line number Diff line number Diff line change
Expand Up @@ -354,16 +354,19 @@ <h3 class="caption" data-bind="html: header"></h3>
</script>
<script type="text/html" id="dropdown-entry-ko-template">
<select class="form-control" data-bind="
options: options,
optionsValue: 'idx',
optionsText: 'text',
foreach: options,
value: rawAnswer,
valueAllowUnset: true,
optionsCaption: '{% trans_html_attr 'Choose...' %}',
id: entryId,
'aria-required': $parent.required() ? 'true' : 'false',
attr: {
id: entryId,
'aria-required': $parent.required() ? 'true' : 'false',
},
">
<option data-bind="value: idx, text: text"></option>
</select>
<span class="help-block type" data-bind="
text: helpText(),
visible: helpText(),
"></span>
</script>
<script type="text/html" id="choice-label-entry-ko-template">
<div class="row">
Expand Down