Skip to content

Commit

Permalink
Merge pull request #28701 from dimagi/jls/combobox-select2
Browse files Browse the repository at this point in the history
Web Apps: switched combobox to select2
  • Loading branch information
orangejenny committed Nov 16, 2020
2 parents 11d0283 + 242a564 commit 923025e
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 122 deletions.
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 @@ -526,22 +526,60 @@ hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
}
};

/**
* For dropdowns, each option is assigned an id, which is its index,
* with the first option given index 1. Both the entry's answer and
* rawAnswer contain this index value.
*/
function DropdownEntry(question, options) {
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 () {
return [{text: "", id: undefined}].concat(_.map(question.choices(), function (choice, idx) {
return {
text: choice,
idx: idx + 1,
id: idx + 1,
};
});
}));
});

self.options.subscribe(function () {
// Clear answer if options change
self.rawAnswer(undefined);
});

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;
DropdownEntry.prototype.onAnswerChange = function (newValue) {
var self = this;
EntrySingleAnswer.prototype.onAnswerChange.call(self, newValue);
_.delay(function () {
$("#" + self.entryId).trigger("change.select2");
});
};
DropdownEntry.prototype.onPreProcess = function (newValue) {
// When newValue is undefined it means we've unset the select question.
if (newValue === Const.NO_ANSWER || newValue === undefined) {
Expand All @@ -556,115 +594,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 +656,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 +670,7 @@ hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
}

value = _.find(this.options(), function (d) {
return d.name === newValue;
return d.id === newValue;
});
if (value) {
this.answer(value.id);
Expand All @@ -709,8 +691,8 @@ hqDefine("cloudcare/js/form_entry/entrycontrols_full", function () {
var fieldByPriority = fieldsByPriority[i];
for (var j = 0; j < options.length; j++) {
var option = options[j];
if (option.name === message[fieldByPriority]) {
self.rawAnswer(option.name);
if (option.text === message[fieldByPriority]) {
self.rawAnswer(option.id);
return;
}
}
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 All @@ -77,6 +78,27 @@ describe('Entries', function () {
assert.isTrue(spy.calledTwice);
});

it('Should clear Dropdown on options change', function () {
var entry,
question;
questionJSON.datatype = Const.SELECT;
questionJSON.style = { raw: Const.MINIMAL };
questionJSON.choices = ['a', 'b'];
question = UI.Question(questionJSON);

entry = question.entry;
assert.isTrue(entry instanceof Controls.DropdownEntry);

entry.rawAnswer(2); // 'b'
assert.equal(entry.answer(), 2);

question.choices(['b', 'c', 'd']);
assert.equal(entry.answer(), Const.NO_ANSWER);

question.choices(['e', 'f']);
assert.equal(entry.answer(), Const.NO_ANSWER);
});

it('Should return FloatEntry', function () {
questionJSON.datatype = Const.FLOAT;
var entry = UI.Question(questionJSON).entry;
Expand Down Expand Up @@ -104,19 +126,19 @@ describe('Entries', function () {

entry = UI.Question(questionJSON).entry;
assert.isTrue(entry instanceof Controls.ComboboxEntry);
assert.equal(entry.rawAnswer(), 'b');
assert.equal(entry.rawAnswer(), 2);

entry.rawAnswer('a');
entry.rawAnswer(1);
assert.equal(entry.answer(), 1);

entry.rawAnswer('');
assert.equal(entry.answer(), Const.NO_ANSWER);

entry.rawAnswer('abc');
entry.rawAnswer(15);
assert.equal(entry.answer(), Const.NO_ANSWER);
});

it('Should validate Combobox properly', function () {
it('Should clear Combobox on options change', function () {
var entry,
question;
questionJSON.datatype = Const.SELECT;
Expand All @@ -127,42 +149,44 @@ describe('Entries', function () {
entry = question.entry;
assert.isTrue(entry instanceof Controls.ComboboxEntry);

entry.rawAnswer('a');
assert.equal(entry.answer(), 1);
entry.rawAnswer(2); // 'b'
assert.equal(entry.answer(), 2);

question.choices(['c', 'd']);
assert.isFalse(entry.isValid(entry.rawAnswer()));
assert.isTrue(!!question.error());
question.choices(['b', 'c', 'd']);
assert.equal(entry.answer(), Const.NO_ANSWER);

question.choices(['e', 'f']);
assert.equal(entry.answer(), Const.NO_ANSWER);
});

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

0 comments on commit 923025e

Please sign in to comment.