phorge/webroot/rsrc/js/core/Prefab.js
epriestley f0fbf7a7d3 Sort "closed" results (like disabled users) to the bottom of typeaheads, but don't hide them completely
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
2017-04-14 13:01:48 -07:00

316 lines
8.6 KiB
JavaScript

/**
* @provides phabricator-prefab
* @requires javelin-install
* javelin-util
* javelin-dom
* javelin-typeahead
* javelin-tokenizer
* javelin-typeahead-preloaded-source
* javelin-typeahead-ondemand-source
* javelin-dom
* javelin-stratcom
* javelin-util
* @javelin
*/
/**
* Utilities for client-side rendering (the greatest thing in the world).
*/
JX.install('Prefab', {
statics : {
renderSelect : function(map, selected, attrs, order) {
var select = JX.$N('select', attrs || {});
// Callers may optionally pass "order" to force options into a specific
// order. Although most browsers do retain order, maps in Javascript
// aren't technically ordered. Safari, at least, will reorder maps with
// numeric keys.
order = order || JX.keys(map);
var k;
for (var ii = 0; ii < order.length; ii++) {
k = order[ii];
select.options[select.options.length] = new Option(map[k], k);
if (k == selected) {
select.value = k;
}
}
select.value = select.value || order[0];
return select;
},
newTokenizerFromTemplate: function(markup, config) {
var template = JX.$H(markup).getFragment().firstChild;
var container = JX.DOM.find(template, 'div', 'tokenizer-container');
container.id = '';
config.root = container;
var build = JX.Prefab.buildTokenizer(config);
build.node = template;
return build;
},
/**
* Build a Phabricator tokenizer out of a configuration with application
* sorting, datasource and placeholder rules.
*
* - `id` Root tokenizer ID (alternatively, pass `root`).
* - `root` Root tokenizer node (replaces `id`).
* - `src` Datasource URI.
* - `ondemand` Optional, use an ondemand source.
* - `value` Optional, initial value.
* - `limit` Optional, token limit.
* - `placeholder` Optional, placeholder text.
* - `username` Optional, username to sort first (i.e., viewer).
* - `icons` Optional, map of icons.
*
*/
buildTokenizer : function(config) {
config.icons = config.icons || {};
var root;
try {
root = config.root || JX.$(config.id);
} catch (ex) {
// If the root element does not exist, just return without building
// anything. This happens in some cases -- like Conpherence -- where we
// may load a tokenizer but not put it in the document.
return;
}
var datasource;
// Default to an ondemand source if no alternate configuration is
// provided.
var ondemand = true;
if ('ondemand' in config) {
ondemand = config.ondemand;
}
if (ondemand) {
datasource = new JX.TypeaheadOnDemandSource(config.src);
} else {
datasource = new JX.TypeaheadPreloadedSource(config.src);
}
datasource.setSortHandler(
JX.bind(datasource, JX.Prefab.sortHandler, config));
datasource.setTransformer(JX.Prefab.transformDatasourceResults);
var typeahead = new JX.Typeahead(
root,
JX.DOM.find(root, 'input', 'tokenizer-input'));
typeahead.setDatasource(datasource);
var tokenizer = new JX.Tokenizer(root);
tokenizer.setTypeahead(typeahead);
tokenizer.setRenderTokenCallback(function(value, key, container) {
var result;
if (value && (typeof value == 'object') && ('id' in value)) {
// TODO: In this case, we've been passed the decoded wire format
// dictionary directly. Token rendering is kind of a huge mess that
// should be cleaned up and made more consistent. Just force our
// way through for now.
result = value;
} else {
result = datasource.getResult(key);
}
var icon;
var type;
var color;
if (result) {
icon = result.icon;
value = result.displayName;
type = result.tokenType;
color = result.color;
} else {
icon = (config.icons || {})[key];
type = (config.types || {})[key];
color = (config.colors || {})[key];
}
if (icon) {
icon = JX.Prefab._renderIcon(icon);
}
type = type || 'object';
JX.DOM.alterClass(container, 'jx-tokenizer-token-' + type, true);
if (color) {
JX.DOM.alterClass(container, color, true);
}
return [icon, value];
});
if (config.placeholder) {
tokenizer.setPlaceholder(config.placeholder);
}
if (config.limit) {
tokenizer.setLimit(config.limit);
}
if (config.value) {
tokenizer.setInitialValue(config.value);
}
if (config.browseURI) {
tokenizer.setBrowseURI(config.browseURI);
}
if (config.disabled) {
tokenizer.setDisabled(true);
}
JX.Stratcom.addData(root, {'tokenizer' : tokenizer});
return {
tokenizer: tokenizer
};
},
sortHandler: function(config, value, list, cmp) {
// Sort results so that the viewing user always comes up first; after
// that, prefer unixname matches to realname matches.
var priority_hits = {};
var self_hits = {};
// We'll put matches where the user's input is a prefix of the name
// above mathches where that isn't true.
var prefix_hits = {};
var tokens = this.tokenize(value);
var normal = this.normalize(value);
for (var ii = 0; ii < list.length; ii++) {
var item = list[ii];
if (this.normalize(item.name).indexOf(normal) === 0) {
prefix_hits[item.id] = true;
}
if (!item.priority) {
continue;
}
if (config.username && item.priority == config.username) {
self_hits[item.id] = true;
}
}
list.sort(function(u, v) {
if (self_hits[u.id] != self_hits[v.id]) {
return self_hits[v.id] ? 1 : -1;
}
// If one result is open and one is closed, show the open result
// first. The "!" tricks here are becaused closed values are display
// strings, so the value is either `null` or some truthy string. If
// we compare the values directly, we'll apply this rule to two
// objects which are both closed but for different reasons, like
// "Archived" and "Disabled".
var u_open = !u.closed;
var v_open = !v.closed;
if (u_open != v_open) {
if (u_open) {
return -1;
} else {
return 1;
}
}
if (prefix_hits[u.id] != prefix_hits[v.id]) {
return prefix_hits[v.id] ? 1 : -1;
}
// Sort users ahead of other result types.
if (u.priorityType != v.priorityType) {
if (u.priorityType == 'user') {
return -1;
}
if (v.priorityType == 'user') {
return 1;
}
}
// Sort functions after other result types.
var uf = (u.tokenType == 'function');
var vf = (v.tokenType == 'function');
if (uf != vf) {
return uf ? 1 : -1;
}
return cmp(u, v);
});
},
/**
* Transform results from a wire format into a usable format in a standard
* way.
*/
transformDatasourceResults: function(fields) {
var closed = fields[9];
var closed_ui;
if (closed) {
closed_ui = JX.$N(
'div',
{className: 'tokenizer-closed'},
closed);
}
var icon = fields[8];
var icon_ui;
if (icon) {
icon_ui = JX.Prefab._renderIcon(icon);
}
var display = JX.$N(
'div',
{className: 'tokenizer-result'},
[icon_ui, fields[4] || fields[0], closed_ui]);
if (closed) {
JX.DOM.alterClass(display, 'tokenizer-result-closed', true);
}
return {
name: fields[0],
displayName: fields[4] || fields[0],
display: display,
uri: fields[1],
id: fields[2],
priority: fields[3],
priorityType: fields[7],
imageURI: fields[6],
icon: icon,
closed: closed,
type: fields[5],
sprite: fields[10],
color: fields[11],
tokenType: fields[12],
unique: fields[13] || false,
autocomplete: fields[14],
sort: JX.TypeaheadNormalizer.normalize(fields[0])
};
},
_renderIcon: function(icon) {
return JX.$N(
'span',
{className: 'phui-icon-view phui-font-fa ' + icon});
}
}
});