mirror of
https://we.phorge.it/source/phorge.git
synced 2024-12-24 22:40:55 +01:00
f0fbf7a7d3
Summary: Fixes T12538. Instead of hiding "closed" results unless only closed results match, show closed results but sort them to the bottom. This fixes the actual issue in T12538, and I think this is probably the correct/best behavior for the global search. It also makes all other typeaheads use this behavior. They currently have a "bug" where enabled user `abcd` makes it impossible to select disabled user `abc`. This manifests in some real cases, where enabled function `members(abc)` makes it impossible to disabled user `abc` in some function tokenizers. If ths feels worse, we could go back to filtering in the simpler cases and introduce a rule like "show closed results if only closed results would be shown OR if query is an exact match for the disabled result", but that gets dicier because "exact match" is a fuzzy concept. (There are a lot of other minor bad behaviors that this doesn't try to fix.) Test Plan: Enabled project "instabug" no longer prevents bot user "instabug" from being shown: {F4903843} Disabled user "mmaven" is sorted below enabled user "mmclewis", in defiance of the otherwise alphabetical order. There's no visual cue that this user is disabled because of T6906. {F4903845} Same as above, but this source renders "disabled" in a more obvious way: {F4903848} Function selecting members of active project `members(instabug)` no longer prevents selection of bot user `instabug`: {F4903849} Reviewers: chad Reviewed By: chad Maniphest Tasks: T12538 Differential Revision: https://secure.phabricator.com/D17695
735 lines
19 KiB
JavaScript
735 lines
19 KiB
JavaScript
/**
|
|
* @provides phuix-autocomplete
|
|
* @requires javelin-install
|
|
* javelin-dom
|
|
* phuix-icon-view
|
|
* phabricator-prefab
|
|
*/
|
|
|
|
JX.install('PHUIXAutocomplete', {
|
|
|
|
construct: function() {
|
|
this._map = {};
|
|
this._datasources = {};
|
|
this._listNodes = [];
|
|
this._resultMap = {};
|
|
},
|
|
|
|
members: {
|
|
_area: null,
|
|
_active: false,
|
|
_cursorHead: null,
|
|
_cursorTail: null,
|
|
_pixelHead: null,
|
|
_pixelTail: null,
|
|
_map: null,
|
|
_datasource: null,
|
|
_datasources: null,
|
|
_value: null,
|
|
_node: null,
|
|
_echoNode: null,
|
|
_listNode: null,
|
|
_promptNode: null,
|
|
_focus: null,
|
|
_focusRef: null,
|
|
_listNodes: null,
|
|
_x: null,
|
|
_y: null,
|
|
_visible: false,
|
|
_resultMap: null,
|
|
|
|
setArea: function(area) {
|
|
this._area = area;
|
|
return this;
|
|
},
|
|
|
|
addAutocomplete: function(code, spec) {
|
|
this._map[code] = spec;
|
|
return this;
|
|
},
|
|
|
|
start: function() {
|
|
var area = this._area;
|
|
|
|
JX.DOM.listen(area, 'keypress', null, JX.bind(this, this._onkeypress));
|
|
|
|
JX.DOM.listen(
|
|
area,
|
|
['click', 'keyup', 'keydown', 'keypress'],
|
|
null,
|
|
JX.bind(this, this._update));
|
|
|
|
var select = JX.bind(this, this._onselect);
|
|
JX.DOM.listen(this._getNode(), 'mousedown', 'typeahead-result', select);
|
|
|
|
var device = JX.bind(this, this._ondevice);
|
|
JX.Stratcom.listen('phabricator-device-change', null, device);
|
|
|
|
// When the user clicks away from the textarea, deactivate.
|
|
var deactivate = JX.bind(this, this._deactivate);
|
|
JX.DOM.listen(area, 'blur', null, deactivate);
|
|
},
|
|
|
|
_getSpec: function() {
|
|
return this._map[this._active];
|
|
},
|
|
|
|
_ondevice: function() {
|
|
if (JX.Device.getDevice() != 'desktop') {
|
|
this._deactivate();
|
|
}
|
|
},
|
|
|
|
_activate: function(code) {
|
|
if (JX.Device.getDevice() != 'desktop') {
|
|
return;
|
|
}
|
|
|
|
if (!this._map[code]) {
|
|
return;
|
|
}
|
|
|
|
var area = this._area;
|
|
var range = JX.TextAreaUtils.getSelectionRange(area);
|
|
|
|
// Check the character immediately before the trigger character. We'll
|
|
// only activate the typeahead if it's something that we think a user
|
|
// might reasonably want to autocomplete after, like a space, newline,
|
|
// or open parenthesis. For example, if a user types "alincoln@",
|
|
// the prior letter will be the last "n" in "alincoln". They are probably
|
|
// typing an email address, not a username, so we don't activate the
|
|
// autocomplete.
|
|
var head = range.start;
|
|
var prior;
|
|
if (head > 1) {
|
|
prior = area.value.substring(head - 2, head - 1);
|
|
} else {
|
|
prior = '<start>';
|
|
}
|
|
|
|
switch (prior) {
|
|
case '<start>':
|
|
case ' ':
|
|
case '\n':
|
|
case '\t':
|
|
case '(': // Might be "(@username, what do you think?)".
|
|
case '-': // Might be an unnumbered list.
|
|
case '.': // Might be a numbered list.
|
|
case '|': // Might be a table cell.
|
|
case '>': // Might be a blockquote.
|
|
case '!': // Might be a blockquote attribution line.
|
|
// We'll let these autocomplete.
|
|
break;
|
|
default:
|
|
// We bail out on anything else, since the user is probably not
|
|
// typing a username or project tag.
|
|
return;
|
|
}
|
|
|
|
// Get all the text on the current line. If the line only contains
|
|
// whitespace, don't actiavte: the user is probably typing code or a
|
|
// numbered list.
|
|
var line = area.value.substring(0, head - 1);
|
|
line = line.split('\n');
|
|
line = line[line.length - 1];
|
|
if (line.match(/^\s+$/)) {
|
|
return;
|
|
}
|
|
|
|
this._cursorHead = head;
|
|
this._cursorTail = range.end;
|
|
this._pixelHead = JX.TextAreaUtils.getPixelDimensions(
|
|
area,
|
|
range.start,
|
|
range.end);
|
|
|
|
var spec = this._map[code];
|
|
if (!this._datasources[code]) {
|
|
var datasource = new JX.TypeaheadOnDemandSource(spec.datasourceURI);
|
|
datasource.listen(
|
|
'resultsready',
|
|
JX.bind(this, this._onresults, code));
|
|
|
|
datasource.setTransformer(JX.bind(this, this._transformresult));
|
|
datasource.setSortHandler(
|
|
JX.bind(datasource, JX.Prefab.sortHandler, {}));
|
|
|
|
this._datasources[code] = datasource;
|
|
}
|
|
|
|
this._datasource = this._datasources[code];
|
|
this._active = code;
|
|
|
|
var head_icon = new JX.PHUIXIconView()
|
|
.setIcon(spec.headerIcon)
|
|
.getNode();
|
|
var head_text = spec.headerText;
|
|
|
|
var node = this._getPromptNode();
|
|
JX.DOM.setContent(node, [head_icon, head_text]);
|
|
},
|
|
|
|
_transformresult: function(fields) {
|
|
var map = JX.Prefab.transformDatasourceResults(fields);
|
|
|
|
var icon;
|
|
if (map.icon) {
|
|
icon = new JX.PHUIXIconView()
|
|
.setIcon(map.icon)
|
|
.getNode();
|
|
}
|
|
|
|
map.display = [icon, map.displayName];
|
|
|
|
return map;
|
|
},
|
|
|
|
_deactivate: function() {
|
|
var node = this._getNode();
|
|
JX.DOM.hide(node);
|
|
|
|
this._active = false;
|
|
this._visible = false;
|
|
},
|
|
|
|
_onkeypress: function(e) {
|
|
var r = e.getRawEvent();
|
|
|
|
// NOTE: We allow events to continue with "altKey", because you need
|
|
// to press Alt to type characters like "@" on a German keyboard layout.
|
|
// The cost of misfiring autocompleters is very small since we do not
|
|
// eat the keystroke. See T10252.
|
|
if (r.metaKey || r.ctrlKey) {
|
|
return;
|
|
}
|
|
|
|
var code = r.charCode;
|
|
if (this._map[code]) {
|
|
setTimeout(JX.bind(this, this._activate, code), 0);
|
|
}
|
|
},
|
|
|
|
_onresults: function(code, nodes, value, partial) {
|
|
// Even if these results are out of date, we still want to fill in the
|
|
// result map so we can terminate things later.
|
|
if (!partial) {
|
|
if (!this._resultMap[code]) {
|
|
this._resultMap[code] = {};
|
|
}
|
|
|
|
var hits = [];
|
|
for (var ii = 0; ii < nodes.length; ii++) {
|
|
var result = this._datasources[code].getResult(nodes[ii].rel);
|
|
if (!result) {
|
|
hits = null;
|
|
break;
|
|
}
|
|
|
|
if (!result.autocomplete || !result.autocomplete.length) {
|
|
hits = null;
|
|
break;
|
|
}
|
|
|
|
hits.push(result.autocomplete);
|
|
}
|
|
|
|
if (hits !== null) {
|
|
this._resultMap[code][value] = hits;
|
|
}
|
|
}
|
|
|
|
if (code !== this._active) {
|
|
return;
|
|
}
|
|
|
|
if (value !== this._value) {
|
|
return;
|
|
}
|
|
|
|
if (this._isTerminatedString(value)) {
|
|
if (this._hasUnrefinableResults(value)) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
var list = this._getListNode();
|
|
JX.DOM.setContent(list, nodes);
|
|
|
|
this._listNodes = nodes;
|
|
|
|
var old_ref = this._focusRef;
|
|
this._clearFocus();
|
|
|
|
for (var ii = 0; ii < nodes.length; ii++) {
|
|
if (nodes[ii].rel == old_ref) {
|
|
this._setFocus(ii);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (this._focus === null && nodes.length) {
|
|
this._setFocus(0);
|
|
}
|
|
|
|
this._redraw();
|
|
},
|
|
|
|
_setFocus: function(idx) {
|
|
if (!this._listNodes[idx]) {
|
|
this._clearFocus();
|
|
return false;
|
|
}
|
|
|
|
if (this._focus !== null) {
|
|
JX.DOM.alterClass(this._listNodes[this._focus], 'focused', false);
|
|
}
|
|
|
|
this._focus = idx;
|
|
this._focusRef = this._listNodes[idx].rel;
|
|
JX.DOM.alterClass(this._listNodes[idx], 'focused', true);
|
|
|
|
return true;
|
|
},
|
|
|
|
_changeFocus: function(delta) {
|
|
if (this._focus === null) {
|
|
return false;
|
|
}
|
|
|
|
return this._setFocus(this._focus + delta);
|
|
},
|
|
|
|
_clearFocus: function() {
|
|
this._focus = null;
|
|
this._focusRef = null;
|
|
},
|
|
|
|
_onselect: function (e) {
|
|
if (!e.isNormalMouseEvent()) {
|
|
// Eat right clicks, control clicks, etc., on the results. These can
|
|
// not do anything meaningful and if we let them through they'll blur
|
|
// the field and dismiss the results.
|
|
e.kill();
|
|
return;
|
|
}
|
|
|
|
var target = e.getNode('typeahead-result');
|
|
|
|
for (var ii = 0; ii < this._listNodes.length; ii++) {
|
|
if (this._listNodes[ii] === target) {
|
|
this._setFocus(ii);
|
|
this._autocomplete();
|
|
break;
|
|
}
|
|
}
|
|
|
|
this._deactivate();
|
|
e.kill();
|
|
},
|
|
|
|
_getSuffixes: function() {
|
|
return [' ', ':', ',', ')'];
|
|
},
|
|
|
|
_getCancelCharacters: function() {
|
|
// The "." character does not cancel because of projects named
|
|
// "node.js" or "blog.mycompany.com".
|
|
return ['#', '@', ',', '!', '?', '{', '}'];
|
|
},
|
|
|
|
_getTerminators: function() {
|
|
return [' ', ':', ',', '.', '!', '?'];
|
|
},
|
|
|
|
_getIgnoreList: function() {
|
|
return this._map[this._active].ignore || [];
|
|
},
|
|
|
|
_isTerminatedString: function(string) {
|
|
var terminators = this._getTerminators();
|
|
for (var ii = 0; ii < terminators.length; ii++) {
|
|
var term = terminators[ii];
|
|
if (string.substring(string.length - term.length) == term) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_hasUnrefinableResults: function(query) {
|
|
if (!this._resultMap[this._active]) {
|
|
return false;
|
|
}
|
|
|
|
var map = this._resultMap[this._active];
|
|
|
|
for (var ii = 1; ii < query.length; ii++) {
|
|
var prefix = query.substring(0, ii);
|
|
if (map.hasOwnProperty(prefix)) {
|
|
var results = map[prefix];
|
|
|
|
// If any prefix of the query has no results, the full query also
|
|
// has no results so we can not refine them.
|
|
if (!results.length) {
|
|
return true;
|
|
}
|
|
|
|
// If there is exactly one match and the it is a prefix of the query,
|
|
// we can safely assume the user just typed out the right result
|
|
// from memory and doesn't need to refine it.
|
|
if (results.length == 1) {
|
|
// Strip the first character off, like a "#" or "@".
|
|
var result = results[0].substring(1);
|
|
|
|
if (query.length >= result.length) {
|
|
if (query.substring(0, result.length) === result) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_trim: function(str) {
|
|
var suffixes = this._getSuffixes();
|
|
for (var ii = 0; ii < suffixes.length; ii++) {
|
|
if (str.substring(str.length - suffixes[ii].length) == suffixes[ii]) {
|
|
str = str.substring(0, str.length - suffixes[ii].length);
|
|
}
|
|
}
|
|
return str;
|
|
},
|
|
|
|
_update: function(e) {
|
|
if (!this._active) {
|
|
return;
|
|
}
|
|
|
|
var special = e.getSpecialKey();
|
|
|
|
// Deactivate if the user types escape.
|
|
if (special == 'esc') {
|
|
this._deactivate();
|
|
e.kill();
|
|
return;
|
|
}
|
|
|
|
var area = this._area;
|
|
|
|
if (e.getType() == 'keydown') {
|
|
if (special == 'up' || special == 'down') {
|
|
var delta = (special == 'up') ? -1 : +1;
|
|
if (!this._changeFocus(delta)) {
|
|
this._deactivate();
|
|
}
|
|
e.kill();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Deactivate if the user moves the cursor to the left of the assist
|
|
// range. For example, they might press the "left" arrow to move the
|
|
// cursor to the left, or click in the textarea prior to the active
|
|
// range.
|
|
var range = JX.TextAreaUtils.getSelectionRange(area);
|
|
if (range.start < this._cursorHead) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
|
|
if (special == 'tab' || special == 'return') {
|
|
var r = e.getRawEvent();
|
|
if (r.shiftKey && special == 'tab') {
|
|
// Don't treat "Shift + Tab" as an autocomplete action. Instead,
|
|
// let it through normally so the focus shifts to the previous
|
|
// control.
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
|
|
// If the user hasn't typed any text yet after typing the character
|
|
// which can summon the autocomplete, deactivate and let the keystroke
|
|
// through. For example, We hit this when a line ends with an
|
|
// autocomplete character and the user is trying to type a newline.
|
|
if (range.start == this._cursorHead) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
|
|
// If we autocomplete, we're done. Otherwise, just eat the event. This
|
|
// happens if you type too fast and try to tab complete before results
|
|
// load.
|
|
if (this._autocomplete()) {
|
|
this._deactivate();
|
|
}
|
|
|
|
e.kill();
|
|
return;
|
|
}
|
|
|
|
// Deactivate if the user moves the cursor to the right of the assist
|
|
// range. For example, they might click later in the document. If the user
|
|
// is pressing the "right" arrow key, they are not allowed to move the
|
|
// cursor beyond the existing end of the text range. If they are pressing
|
|
// other keys, assume they're typing and allow the tail to move forward
|
|
// one character.
|
|
var margin;
|
|
if (special == 'right') {
|
|
margin = 0;
|
|
} else {
|
|
margin = 1;
|
|
}
|
|
|
|
var tail = this._cursorTail;
|
|
|
|
if ((range.start > tail + margin) || (range.end > tail + margin)) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
|
|
this._cursorTail = Math.max(this._cursorTail, range.end);
|
|
|
|
var text = area.value.substring(
|
|
this._cursorHead,
|
|
this._cursorTail);
|
|
|
|
this._value = text;
|
|
|
|
var pixels = JX.TextAreaUtils.getPixelDimensions(
|
|
area,
|
|
range.start,
|
|
range.end);
|
|
|
|
var x = this._pixelHead.start.x;
|
|
var y = Math.max(this._pixelHead.end.y, pixels.end.y) + 24;
|
|
|
|
// If the first character after the trigger is a space, just deactivate
|
|
// immediately. This occurs if a user types a numbered list using "#".
|
|
if (text.length && text[0] == ' ') {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
|
|
var trim = this._trim(text);
|
|
|
|
// Deactivate immediately if a user types a character that we are
|
|
// reasonably sure means they don't want to use the autocomplete. For
|
|
// example, "##" is almost certainly a header or monospaced text, not
|
|
// a project autocompletion.
|
|
var cancels = this._getCancelCharacters();
|
|
for (var ii = 0; ii < cancels.length; ii++) {
|
|
if (trim.indexOf(cancels[ii]) !== -1) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
var ignore = this._getIgnoreList();
|
|
for (ii = 0; ii < ignore.length; ii++) {
|
|
if (trim.indexOf(ignore[ii]) === 0) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If the input is terminated by a space or another word-terminating
|
|
// punctuation mark, we're going to deactivate if the results can not
|
|
// be refined by addding more words.
|
|
|
|
// The idea is that if you type "@alan ab", you're allowed to keep
|
|
// editing "ab" until you type a space, period, or other terminator,
|
|
// since you might not be sure how to spell someone's last name or the
|
|
// second word of a project.
|
|
|
|
// Once you do terminate a word, if the words you have have entered match
|
|
// nothing or match only one exact match, we can safely deactivate and
|
|
// assume you're just typing text because further words could never
|
|
// refine the result set.
|
|
|
|
var force;
|
|
if (this._isTerminatedString(text)) {
|
|
if (this._hasUnrefinableResults(text)) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
force = true;
|
|
} else {
|
|
force = false;
|
|
}
|
|
|
|
this._datasource.didChange(trim, force);
|
|
|
|
this._x = x;
|
|
this._y = y;
|
|
|
|
var hint = trim;
|
|
if (hint.length) {
|
|
// We only show the autocompleter after the user types at least one
|
|
// character. For example, "@" does not trigger it, but "@d" does.
|
|
this._visible = true;
|
|
} else {
|
|
hint = this._getSpec().hintText;
|
|
}
|
|
|
|
var echo = this._getEchoNode();
|
|
JX.DOM.setContent(echo, hint);
|
|
|
|
this._redraw();
|
|
},
|
|
|
|
_redraw: function() {
|
|
if (!this._visible) {
|
|
return;
|
|
}
|
|
|
|
var node = this._getNode();
|
|
JX.DOM.show(node);
|
|
|
|
var p = new JX.Vector(this._x, this._y);
|
|
var s = JX.Vector.getScroll();
|
|
var v = JX.Vector.getViewport();
|
|
|
|
// If the menu would run off the bottom of the screen when showing the
|
|
// maximum number of possible choices, put it above instead. We're doing
|
|
// this based on the maximum size so the menu doesn't jump up and down
|
|
// as results arrive.
|
|
|
|
var option_height = 30;
|
|
var extra_margin = 24;
|
|
if ((s.y + v.y) < (p.y + (5 * option_height) + extra_margin)) {
|
|
var d = JX.Vector.getDim(node);
|
|
p.y = p.y - d.y - 36;
|
|
}
|
|
|
|
p.setPos(node);
|
|
},
|
|
|
|
_autocomplete: function() {
|
|
if (this._focus === null) {
|
|
return false;
|
|
}
|
|
|
|
var area = this._area;
|
|
var head = this._cursorHead;
|
|
var tail = this._cursorTail;
|
|
|
|
var text = area.value;
|
|
|
|
var ref = this._focusRef;
|
|
var result = this._datasource.getResult(ref);
|
|
if (!result) {
|
|
return false;
|
|
}
|
|
|
|
ref = result.autocomplete;
|
|
if (!ref || !ref.length) {
|
|
return false;
|
|
}
|
|
|
|
// If the user types a string like "@username:" (with a trailing colon),
|
|
// then presses tab or return to pick the completion, don't destroy the
|
|
// trailing character.
|
|
var suffixes = this._getSuffixes();
|
|
var value = this._value;
|
|
var found_suffix = false;
|
|
for (var ii = 0; ii < suffixes.length; ii++) {
|
|
var last = value.substring(value.length - suffixes[ii].length);
|
|
if (last == suffixes[ii]) {
|
|
ref += suffixes[ii];
|
|
found_suffix = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If we didn't find an existing suffix, add a space.
|
|
if (!found_suffix) {
|
|
ref = ref + ' ';
|
|
}
|
|
|
|
area.value = text.substring(0, head - 1) + ref + text.substring(tail);
|
|
|
|
var end = head + ref.length;
|
|
JX.TextAreaUtils.setSelectionRange(area, end, end);
|
|
|
|
return true;
|
|
},
|
|
|
|
_getNode: function() {
|
|
if (!this._node) {
|
|
var head = this._getHeadNode();
|
|
var list = this._getListNode();
|
|
|
|
this._node = JX.$N(
|
|
'div',
|
|
{
|
|
className: 'phuix-autocomplete',
|
|
style: {
|
|
display: 'none'
|
|
}
|
|
},
|
|
[head, list]);
|
|
|
|
JX.DOM.hide(this._node);
|
|
|
|
document.body.appendChild(this._node);
|
|
}
|
|
return this._node;
|
|
},
|
|
|
|
_getHeadNode: function() {
|
|
if (!this._headNode) {
|
|
this._headNode = JX.$N(
|
|
'div',
|
|
{
|
|
className: 'phuix-autocomplete-head'
|
|
},
|
|
[
|
|
this._getPromptNode(),
|
|
this._getEchoNode()
|
|
]);
|
|
}
|
|
|
|
return this._headNode;
|
|
},
|
|
|
|
_getPromptNode: function() {
|
|
if (!this._promptNode) {
|
|
this._promptNode = JX.$N(
|
|
'span',
|
|
{
|
|
className: 'phuix-autocomplete-prompt',
|
|
});
|
|
}
|
|
return this._promptNode;
|
|
},
|
|
|
|
_getEchoNode: function() {
|
|
if (!this._echoNode) {
|
|
this._echoNode = JX.$N(
|
|
'span',
|
|
{
|
|
className: 'phuix-autocomplete-echo'
|
|
});
|
|
}
|
|
return this._echoNode;
|
|
},
|
|
|
|
_getListNode: function() {
|
|
if (!this._listNode) {
|
|
this._listNode = JX.$N(
|
|
'div',
|
|
{
|
|
className: 'phuix-autocomplete-list'
|
|
});
|
|
}
|
|
return this._listNode;
|
|
}
|
|
|
|
}
|
|
|
|
});
|