Properly support Categories so it updates the search box correctly Use category id, as it is more consistent with search results than using the slugs, especially for parent/subcategory Added Status Improve AutoComplete so it can receive updates Added the ability for AutoComplete to receive updates to badge-selector and group-selector Respect null, which is set via web-hooks Support both # and category: for category detection. Only update the searchedTerms if they differ from its current value (this helps the Category Selector receive updates) Opt in receive updates (#3) * Make the selectors opt-in for receiving updates * Opt-in to receive updates * Fix category detection for search-advanced-options Fix eslint error Update user-selector so it can receive updates live too Make the canReceiveUpdates check validate against 'true' Converted to use template literals Refactor the regex involved with this feature Split apart the init to make it a bit more manageable/testable Switch the category selector to category-chooser, so it is a dropdown of categories instead of auto-complete Reduce RegEx to make this happier with unicode languages and reduce some of the complexity
508 lines
12 KiB
JavaScript
508 lines
12 KiB
JavaScript
/**
|
|
This is a jQuery plugin to support autocompleting values in our text fields.
|
|
|
|
@module $.fn.autocomplete
|
|
**/
|
|
export const CANCELLED_STATUS = "__CANCELLED";
|
|
import { setCaretPosition, caretPosition } from 'discourse/lib/utilities';
|
|
|
|
const allowedLettersRegex = /[\s\t\[\{\(\/]/;
|
|
|
|
const keys = {
|
|
backSpace: 8,
|
|
tab: 9,
|
|
enter: 13,
|
|
shift: 16,
|
|
ctrl: 17,
|
|
alt: 18,
|
|
esc: 27,
|
|
space: 32,
|
|
leftWindows: 91,
|
|
rightWindows: 92,
|
|
pageUp: 33,
|
|
pageDown: 34,
|
|
end: 35,
|
|
home: 36,
|
|
leftArrow: 37,
|
|
upArrow: 38,
|
|
rightArrow: 39,
|
|
downArrow: 40,
|
|
insert: 45,
|
|
deleteKey: 46,
|
|
zero: 48,
|
|
a: 65,
|
|
z: 90,
|
|
};
|
|
|
|
|
|
let inputTimeout;
|
|
|
|
export default function(options) {
|
|
const autocompletePlugin = this;
|
|
|
|
if (this.length === 0) return;
|
|
|
|
if (options === 'destroy' || options.updateData) {
|
|
Ember.run.cancel(inputTimeout);
|
|
|
|
$(this).off('keyup.autocomplete')
|
|
.off('keydown.autocomplete')
|
|
.off('paste.autocomplete')
|
|
.off('click.autocomplete');
|
|
|
|
if (options === 'destroy')
|
|
return;
|
|
}
|
|
|
|
if (options && options.cancel && this.data("closeAutocomplete")) {
|
|
this.data("closeAutocomplete")();
|
|
return this;
|
|
}
|
|
|
|
if (this.length !== 1) {
|
|
if (window.console) {
|
|
window.console.log("WARNING: passed multiple elements to $.autocomplete, skipping.");
|
|
if (window.Error) {
|
|
window.console.log((new window.Error()).stack);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
|
|
const disabled = options && options.disabled;
|
|
let wrap = null;
|
|
let autocompleteOptions = null;
|
|
let selectedOption = null;
|
|
let completeStart = null;
|
|
let completeEnd = null;
|
|
let me = this;
|
|
let div = null;
|
|
let prevTerm = null;
|
|
|
|
// input is handled differently
|
|
const isInput = this[0].tagName === "INPUT";
|
|
let inputSelectedItems = [];
|
|
|
|
function closeAutocomplete() {
|
|
if (div) {
|
|
div.hide().remove();
|
|
}
|
|
div = null;
|
|
completeStart = null;
|
|
autocompleteOptions = null;
|
|
prevTerm = null;
|
|
}
|
|
|
|
function addInputSelectedItem(item) {
|
|
var transformed,
|
|
transformedItem = item;
|
|
|
|
if (options.transformComplete) { transformedItem = options.transformComplete(transformedItem); }
|
|
// dump what we have in single mode, just in case
|
|
if (options.single) { inputSelectedItems = []; }
|
|
transformed = _.isArray(transformedItem) ? transformedItem : [transformedItem || item];
|
|
|
|
const divs = transformed.map(itm => {
|
|
let d = $(`<div class='item'><span>${itm}<a class='remove' href><i class='fa fa-times'></i></a></span></div>`);
|
|
const $parent = me.parent();
|
|
const prev = $parent.find('.item:last');
|
|
|
|
if (prev.length === 0) {
|
|
me.parent().prepend(d);
|
|
} else {
|
|
prev.after(d);
|
|
}
|
|
|
|
inputSelectedItems.push(itm);
|
|
return d[0];
|
|
});
|
|
|
|
if (options.onChangeItems) { options.onChangeItems(inputSelectedItems); }
|
|
|
|
$(divs).find('a').click(function() {
|
|
closeAutocomplete();
|
|
inputSelectedItems.splice($.inArray(transformedItem, inputSelectedItems), 1);
|
|
$(this).parent().parent().remove();
|
|
if (options.single) {
|
|
me.show();
|
|
}
|
|
if (options.onChangeItems) {
|
|
options.onChangeItems(inputSelectedItems);
|
|
}
|
|
return false;
|
|
});
|
|
};
|
|
|
|
var completeTerm = function(term) {
|
|
if (term) {
|
|
if (isInput) {
|
|
me.val("");
|
|
if(options.single){
|
|
me.hide();
|
|
}
|
|
addInputSelectedItem(term);
|
|
} else {
|
|
if (options.transformComplete) {
|
|
term = options.transformComplete(term);
|
|
}
|
|
|
|
if (term) {
|
|
var text = me.val();
|
|
text = text.substring(0, completeStart) + (options.key || "") + term + ' ' + text.substring(completeEnd + 1, text.length);
|
|
me.val(text);
|
|
setCaretPosition(me[0], completeStart + 1 + term.length);
|
|
|
|
if (options && options.afterComplete) {
|
|
options.afterComplete(text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
closeAutocomplete();
|
|
};
|
|
|
|
if (isInput) {
|
|
const width = this.width();
|
|
|
|
if (options.updateData) {
|
|
wrap = this.parent();
|
|
wrap.find('.item').remove();
|
|
me.show();
|
|
} else {
|
|
wrap = this.wrap("<div class='ac-wrap clearfix" + (disabled ? " disabled" : "") + "'/>").parent();
|
|
wrap.width(width);
|
|
}
|
|
|
|
if(options.single) {
|
|
this.css("width","100%");
|
|
} else {
|
|
this.width(150);
|
|
}
|
|
|
|
this.attr('name', (options.updateData) ? this.attr('name') : this.attr('name') + "-renamed");
|
|
|
|
var vals = this.val().split(",");
|
|
_.each(vals,function(x) {
|
|
if (x !== "") {
|
|
if (options.reverseTransform) {
|
|
x = options.reverseTransform(x);
|
|
}
|
|
if(options.single){
|
|
me.hide();
|
|
}
|
|
addInputSelectedItem(x);
|
|
}
|
|
});
|
|
|
|
if(options.items) {
|
|
_.each(options.items, function(item){
|
|
if(options.single){
|
|
me.hide();
|
|
}
|
|
addInputSelectedItem(item);
|
|
});
|
|
}
|
|
|
|
this.val("");
|
|
completeStart = 0;
|
|
wrap.click(function() {
|
|
autocompletePlugin.focus();
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function markSelected() {
|
|
const links = div.find('li a');
|
|
links.removeClass('selected');
|
|
return $(links[selectedOption]).addClass('selected');
|
|
};
|
|
|
|
function renderAutocomplete() {
|
|
if (div) {
|
|
div.hide().remove();
|
|
}
|
|
if (autocompleteOptions.length === 0) return;
|
|
|
|
div = $(options.template({ options: autocompleteOptions }));
|
|
|
|
var ul = div.find('ul');
|
|
selectedOption = 0;
|
|
markSelected();
|
|
ul.find('li').click(function() {
|
|
selectedOption = ul.find('li').index(this);
|
|
completeTerm(autocompleteOptions[selectedOption]);
|
|
return false;
|
|
});
|
|
var pos = null;
|
|
var vOffset = 0;
|
|
var hOffset = 0;
|
|
if (isInput) {
|
|
pos = {
|
|
left: 0,
|
|
top: 0
|
|
};
|
|
vOffset = -32;
|
|
hOffset = 0;
|
|
} else {
|
|
pos = me.caretPosition({
|
|
pos: completeStart,
|
|
key: options.key
|
|
});
|
|
hOffset = 27;
|
|
}
|
|
div.css({
|
|
left: "-1000px"
|
|
});
|
|
|
|
me.parent().append(div);
|
|
|
|
if (!isInput) {
|
|
vOffset = div.height();
|
|
|
|
if ((window.innerHeight - me.outerHeight() - $("header.d-header").innerHeight()) < vOffset) {
|
|
vOffset = -23;
|
|
}
|
|
|
|
if (Discourse.Site.currentProp('mobileView')) {
|
|
div.css('width', 'auto');
|
|
|
|
if ((me.height() / 2) >= pos.top) { vOffset = -23; }
|
|
if ((me.width() / 2) <= pos.left) { hOffset = -div.width(); }
|
|
}
|
|
}
|
|
|
|
var mePos = me.position();
|
|
var borderTop = parseInt(me.css('border-top-width'), 10) || 0;
|
|
div.css({
|
|
position: 'absolute',
|
|
top: (mePos.top + pos.top - vOffset + borderTop) + 'px',
|
|
left: (mePos.left + pos.left + hOffset) + 'px'
|
|
});
|
|
};
|
|
|
|
const SKIP = "skip";
|
|
|
|
function dataSource(term, opts) {
|
|
if (prevTerm === term) {
|
|
return SKIP;
|
|
}
|
|
|
|
prevTerm = term;
|
|
if (term.length !== 0 && term.trim().length === 0) {
|
|
closeAutocomplete();
|
|
return null;
|
|
} else {
|
|
return opts.dataSource(term);
|
|
}
|
|
};
|
|
|
|
function updateAutoComplete(r) {
|
|
|
|
if (completeStart === null || r === SKIP) return;
|
|
|
|
if (r && r.then && typeof(r.then) === "function") {
|
|
if (div) {
|
|
div.hide().remove();
|
|
}
|
|
r.then(updateAutoComplete);
|
|
return;
|
|
}
|
|
|
|
// Allow an update method to cancel. This allows us to debounce
|
|
// promises without leaking
|
|
if (r === CANCELLED_STATUS) {
|
|
return;
|
|
}
|
|
|
|
autocompleteOptions = r;
|
|
if (!r || r.length === 0) {
|
|
closeAutocomplete();
|
|
} else {
|
|
renderAutocomplete();
|
|
}
|
|
};
|
|
|
|
// chain to allow multiples
|
|
const oldClose = me.data("closeAutocomplete");
|
|
me.data("closeAutocomplete", function() {
|
|
if (oldClose) {
|
|
oldClose();
|
|
}
|
|
closeAutocomplete();
|
|
});
|
|
|
|
$(this).on('click.autocomplete', () => closeAutocomplete());
|
|
|
|
$(this).on('paste.autocomplete', function() {
|
|
_.delay(function(){
|
|
me.trigger("keydown");
|
|
}, 50);
|
|
});
|
|
|
|
function checkTriggerRule(opts) {
|
|
return options.triggerRule ? options.triggerRule(me[0], opts) : true;
|
|
};
|
|
|
|
$(this).on('keyup.autocomplete', function(e) {
|
|
if ([keys.esc, keys.enter].indexOf(e.which) !== -1) return true;
|
|
|
|
var cp = caretPosition(me[0]);
|
|
|
|
if (options.key && completeStart === null && cp > 0) {
|
|
var key = me[0].value[cp-1];
|
|
if (key === options.key) {
|
|
var prevChar = me.val().charAt(cp-2);
|
|
if (checkTriggerRule() && (!prevChar || allowedLettersRegex.test(prevChar))) {
|
|
completeStart = completeEnd = cp-1;
|
|
updateAutoComplete(dataSource("", options));
|
|
}
|
|
}
|
|
} else if (completeStart !== null) {
|
|
var term = me.val().substring(completeStart + (options.key ? 1 : 0), cp);
|
|
updateAutoComplete(dataSource(term, options));
|
|
}
|
|
});
|
|
|
|
$(this).on('keydown.autocomplete', function(e) {
|
|
var c, i, initial, prev, prevIsGood, stopFound, term, total, userToComplete;
|
|
let cp;
|
|
|
|
if (e.ctrlKey || e.altKey || e.metaKey){
|
|
return true;
|
|
}
|
|
|
|
if (options.allowAny){
|
|
// saves us wiring up a change event as well
|
|
|
|
Ember.run.cancel(inputTimeout);
|
|
inputTimeout = Ember.run.later(function(){
|
|
if(inputSelectedItems.length === 0) {
|
|
inputSelectedItems.push("");
|
|
}
|
|
|
|
if(_.isString(inputSelectedItems[0]) && me.val().length > 0) {
|
|
inputSelectedItems.pop();
|
|
inputSelectedItems.push(me.val());
|
|
if (options.onChangeItems) {
|
|
options.onChangeItems(inputSelectedItems);
|
|
}
|
|
}
|
|
|
|
}, 50);
|
|
}
|
|
|
|
if (!options.key) {
|
|
completeStart = 0;
|
|
}
|
|
if (e.which === keys.shift) return;
|
|
if ((completeStart === null) && e.which === keys.backSpace && options.key) {
|
|
c = caretPosition(me[0]);
|
|
c -= 1;
|
|
initial = c;
|
|
prevIsGood = true;
|
|
while (prevIsGood && c >= 0) {
|
|
c -= 1;
|
|
prev = me[0].value[c];
|
|
stopFound = prev === options.key;
|
|
if (stopFound) {
|
|
prev = me[0].value[c - 1];
|
|
if (checkTriggerRule({ backSpace: true }) && (!prev || allowedLettersRegex.test(prev))) {
|
|
completeStart = c;
|
|
cp = completeEnd = initial;
|
|
term = me[0].value.substring(c + 1, initial);
|
|
updateAutoComplete(dataSource(term, options));
|
|
return true;
|
|
}
|
|
}
|
|
prevIsGood = /[a-zA-Z\.-]/.test(prev);
|
|
}
|
|
}
|
|
|
|
// ESC
|
|
if (e.which === keys.esc) {
|
|
if (div !== null) {
|
|
closeAutocomplete();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (completeStart !== null) {
|
|
cp = caretPosition(me[0]);
|
|
|
|
// allow people to right arrow out of completion
|
|
if (e.which === keys.rightArrow && me[0].value[cp] === ' ') {
|
|
closeAutocomplete();
|
|
return true;
|
|
}
|
|
|
|
// If we've backspaced past the beginning, cancel unless no key
|
|
if (cp <= completeStart && options.key) {
|
|
closeAutocomplete();
|
|
return true;
|
|
}
|
|
|
|
// Keyboard codes! So 80's.
|
|
switch (e.which) {
|
|
case keys.enter:
|
|
case keys.tab:
|
|
if (!autocompleteOptions) return true;
|
|
if (selectedOption >= 0 && (userToComplete = autocompleteOptions[selectedOption])) {
|
|
completeTerm(userToComplete);
|
|
} else {
|
|
// We're cancelling it, really.
|
|
return true;
|
|
}
|
|
e.stopImmediatePropagation();
|
|
return false;
|
|
case keys.upArrow:
|
|
selectedOption = selectedOption - 1;
|
|
if (selectedOption < 0) {
|
|
selectedOption = 0;
|
|
}
|
|
markSelected();
|
|
return false;
|
|
case keys.downArrow:
|
|
total = autocompleteOptions.length;
|
|
selectedOption = selectedOption + 1;
|
|
if (selectedOption >= total) {
|
|
selectedOption = total - 1;
|
|
}
|
|
if (selectedOption < 0) {
|
|
selectedOption = 0;
|
|
}
|
|
markSelected();
|
|
return false;
|
|
case keys.backSpace:
|
|
completeEnd = cp;
|
|
cp--;
|
|
|
|
if (cp < 0) {
|
|
closeAutocomplete();
|
|
if (isInput) {
|
|
i = wrap.find('a:last');
|
|
if (i) {
|
|
i.click();
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
term = me.val().substring(completeStart + (options.key ? 1 : 0), cp);
|
|
|
|
if ((completeStart === cp) && (term === options.key)) {
|
|
closeAutocomplete();
|
|
}
|
|
|
|
updateAutoComplete(dataSource(term, options));
|
|
return true;
|
|
default:
|
|
completeEnd = cp;
|
|
return true;
|
|
}
|
|
}
|
|
});
|
|
|
|
return this;
|
|
}
|