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 14 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,58 @@ 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 () {
return _.map(question.choices(), function (choice, idx) {
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 [{text: "", id: undefined}].concat(_.map(question.choices(), function (choice, idx) {
return {
text: choice,
idx: idx + 1,
id: idx + 1,
};
});
}));
});

self.isValid = function (value) {
if (!value) {
return true;
}
return _.contains(_.pluck(self.options(), 'text'), value);
};

self.options.subscribe(function () {
self.renderSelect2();
if (!self.isValid(self.rawAnswer())) {
self.question.error(gettext('Not a valid choice'));
}
});

// If there is a prexisting answer, set the rawAnswer to the corresponding text.
if (question.answer()) {
var initialOption = _.findWhere(self.options(), {id: self.answer()});
self.rawAnswer(
initialOption ? initialOption.text : Const.NO_ANSWER
);
}

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

self.afterRender = function () {
self.renderSelect2();
};
}
DropdownEntry.prototype = Object.create(EntrySingleAnswer.prototype);
DropdownEntry.prototype.constructor = EntrySingleAnswer;
Expand All @@ -556,115 +599,59 @@ 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);
var self = this;
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.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;
},
self.additionalSelect2Options = function () {
return {
matcher: function (params, option) {
var query = $.trim(params.term);
if (ComboboxEntry.filter(query, option, self.matchType)) {
return option;
} else {
return 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();
};
};

self.enableReceiver(question, options);
}

ComboboxEntry.filter = function (query, d, matchType) {
ComboboxEntry.filter = function (query, option, matchType) {
if (!query || !option.text) {
return true;
}

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(' ');
var wordsInChoice = option.text.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()
(window.Levenshtein.get(option.text.toLowerCase(), query.toLowerCase()) <= 2 && query.length > 3) ||
option.text.toLowerCase() === query.toLowerCase()
);
}

Expand All @@ -674,11 +661,11 @@ hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
}

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

ComboboxEntry.prototype = Object.create(EntrySingleAnswer.prototype);
ComboboxEntry.prototype.constructor = EntrySingleAnswer;
ComboboxEntry.prototype = Object.create(DropdownEntry.prototype);
ComboboxEntry.prototype.constructor = DropdownEntry;
ComboboxEntry.prototype.onPreProcess = function (newValue) {
var value;
if (newValue === Const.NO_ANSWER || newValue === '') {
Expand All @@ -688,7 +675,7 @@ hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
}

value = _.find(this.options(), function (d) {
return d.name === newValue;
return d.text === newValue;
});
if (value) {
this.answer(value.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,13 @@ describe('Entries', function () {
entry = UI.Question(questionJSON).entry;
assert.isTrue(entry instanceof Controls.DropdownEntry);
assert.equal(entry.templateType, 'dropdown');
assert.deepEqual(entry.options(), [{
var options = _.rest(entry.options()); // drop placeholder
assert.deepEqual(options, [{
text: 'a',
idx: 1,
id: 1,
}, {
text: 'b',
idx: 2,
id: 2,
}]);

entry.rawAnswer(1);
Expand Down Expand Up @@ -137,32 +138,32 @@ describe('Entries', function () {

it('Should properly filter combobox', function () {
// Standard filter
assert.isTrue(Controls.ComboboxEntry.filter('o', { name: 'one two', id: 1 }, null));
assert.isFalse(Controls.ComboboxEntry.filter('t', { name: 'one two', id: 1 }, null));
assert.isTrue(Controls.ComboboxEntry.filter('o', { text: 'one two', id: 1 }, null));
assert.isFalse(Controls.ComboboxEntry.filter('t', { text: 'one two', id: 1 }, null));

// Multiword filter
assert.isTrue(
Controls.ComboboxEntry.filter('one three', { name: 'one two three', id: 1 }, Const.COMBOBOX_MULTIWORD)
Controls.ComboboxEntry.filter('one three', { text: 'one two three', id: 1 }, Const.COMBOBOX_MULTIWORD)
);
assert.isFalse(
Controls.ComboboxEntry.filter('two three', { name: 'one two', id: 1 }, Const.COMBOBOX_MULTIWORD)
Controls.ComboboxEntry.filter('two three', { text: 'one two', id: 1 }, Const.COMBOBOX_MULTIWORD)
);

// Fuzzy filter
assert.isTrue(
Controls.ComboboxEntry.filter('onet', { name: 'onetwo', id: 1 }, Const.COMBOBOX_FUZZY)
Controls.ComboboxEntry.filter('onet', { text: 'onetwo', id: 1 }, Const.COMBOBOX_FUZZY)
);
assert.isTrue(
Controls.ComboboxEntry.filter('OneT', { name: 'onetwo', id: 1 }, Const.COMBOBOX_FUZZY)
Controls.ComboboxEntry.filter('OneT', { text: 'onetwo', id: 1 }, Const.COMBOBOX_FUZZY)
);
assert.isFalse(
Controls.ComboboxEntry.filter('one tt', { name: 'one', id: 1 }, Const.COMBOBOX_FUZZY)
Controls.ComboboxEntry.filter('one tt', { text: 'one', id: 1 }, Const.COMBOBOX_FUZZY)
);
assert.isTrue(
Controls.ComboboxEntry.filter('o', { name: 'one', id: 1 }, Const.COMBOBOX_FUZZY)
Controls.ComboboxEntry.filter('o', { text: 'one', id: 1 }, Const.COMBOBOX_FUZZY)
);
assert.isTrue(
Controls.ComboboxEntry.filter('on', { name: 'on', id: 1 }, Const.COMBOBOX_FUZZY)
Controls.ComboboxEntry.filter('on', { text: 'on', id: 1 }, Const.COMBOBOX_FUZZY)
);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
{% extends "mocha/base.html" %}
{% load hq_shared_tags %}

{% block stylesheets %}{{ block.super }}
<link type="text/css"
rel="stylesheet"
media="all"
href="{% static 'select2/dist/css/select2.min.css' %}" />
{% endblock %}

{% block dependencies %}
{% include "formplayer/dependencies.html" %}
<script src="{% static 'select2/dist/js/select2.full.min.js' %}"></script>
<script src="{% static 'jasmine-fixture/dist/jasmine-fixture.js' %}"></script>
{% endblock %}

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: id, 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
13 changes: 10 additions & 3 deletions corehq/apps/cloudcare/templates/preview_app/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
rel="stylesheet"
media="all"
href="{% static 'preview_app/less/preview_app.less' %}"/>
<link type="text/css"
rel="stylesheet"
media="all"
href="{% static 'select2/dist/css/select2.min.css' %}" />
{% endcompress %}
{% endblock %}

Expand Down Expand Up @@ -75,7 +79,10 @@
{% endblock %}

{% block js %}{{ block.super }}
<script src="{% static 'app_manager/js/app_manager_utils.js' %}"></script>
<script src="{% static 'cloudcare/js/preview_app/preview_app.js' %}"></script>
<script src="{% static 'cloudcare/js/preview_app/main.js' %}"></script>
{% compress js %}
<script src="{% static 'select2/dist/js/select2.full.min.js' %}"></script>
<script src="{% static 'app_manager/js/app_manager_utils.js' %}"></script>
<script src="{% static 'cloudcare/js/preview_app/preview_app.js' %}"></script>
<script src="{% static 'cloudcare/js/preview_app/main.js' %}"></script>
{% endcompress %}
{% endblock %}
2 changes: 2 additions & 0 deletions corehq/apps/mocha/templates/mocha/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
}
</style>

{% block stylesheets %}{% endblock %}

<script src="{% static 'mocha/mocha.js' %}"></script>
<script src="{% static 'chai/chai.js' %}"></script>
<script src="{% static 'sinon/pkg/sinon.js' %}"></script>
Expand Down