47ea57080b515e5dad5f658c58feb8944a7e7d61
chmalee
Thu Jan 29 15:30:26 2026 -0800
Replace clade/assembly dropdowns with a search bar on most CGIs. Add a recents list to hgGateway and to the species bar and to the 'Genomes' dropdown menu. Track recently selected species in localStorage. Add toGenome and fromGenome arguemnts to hubApi/liftOver in order to find appropriate liftover assemblies, refs #36232
diff --git src/hg/js/autocompleteCat.js src/hg/js/autocompleteCat.js
index 7fd88f25a63..748733ba6ed 100644
--- src/hg/js/autocompleteCat.js
+++ src/hg/js/autocompleteCat.js
@@ -1,176 +1,242 @@
// autocompleteCat: customized JQuery autocomplete plugin that includes watermark and
// can display results broken down by category (for example, genomes from various
// assembly hubs and native genomes).
// Copyright (C) 2018 The Regents of the University of California
///////////////////////////// Module: autocompleteCat /////////////////////////////
/* jshint esnext: true */
var autocompleteCat = (function() {
// Customize jQuery UI autocomplete to show item categories and support html markup in labels.
// Adapted from https://jqueryui.com/autocomplete/#categories and
// http://forum.jquery.com/topic/using-html-in-autocomplete
// Also adds watermark to input.
$.widget("custom.autocompleteCat",
$.ui.autocomplete,
{
_renderMenu: function(ul, items) {
var that = this;
var currentCategory = "";
// There's no this._super as shown in the doc, so I can't override
// _create as shown in the doc -- just do this every time we render...
this.widget().menu("option", "items", "> :not(.ui-autocomplete-category)");
+ // Check if all items are from recents (have displayCategory === "Recent")
+ // If so, skip category headers since they're all recent selections
+ var allRecent = items.length > 0 && items.every(function(item) {
+ return item.displayCategory === "Recent";
+ });
$.each(items,
function(index, item) {
- // Add a heading each time we see a new category:
- if (item.category && item.category !== currentCategory) {
+ // Add a heading each time we see a new category (but not for recents)
+ if (!allRecent && item.category && item.category !== currentCategory) {
ul.append("
");
},
_renderItem: function(ul, item) {
// In order to use HTML markup in the autocomplete, one has to overwrite
// autocomplete's _renderItem method using .html instead of .text.
// http://forum.jquery.com/topic/using-html-in-autocomplete
let clockIcon = '';
- if ($("#positionInput").val().length < 2) {
+ let posInput = $("#positionInput");
+ if (posInput.length > 0 && posInput.val() && posInput.val().length < 2) {
clockIcon = ' ';
}
// Hits to assembly hub top level (not individial db names) have no item label,
// so use the value instead
return $("")
.data("ui-autocomplete-item", item)
.append($("").html(clockIcon + (item.label !== null ? item.label : item.value)))
.appendTo(ul);
}
});
function toggleSpinner(add, options) {
if (options.baseUrl.startsWith("hgGateway")) {
// change the species select loading image
if (add)
$("#speciesSearch").after("");
else
$("#speciesSpinner").remove();
} else if (options.baseUrl.startsWith("hgSuggest")) {
// change the position input loading spinner
if (add)
$("#positionInput").after("");
else
$("#suggestSpinner").remove();
}
}
function init($input, options) {
- // Set up an autocomplete and watermark for $input, with a callback options.onSelect
- // for when the user chooses a result.
+ // Set up an autocomplete and watermark for each input in $input, with a callback
+ // options.onSelect for when the user chooses a result.
// If options.baseUrl is null, the autocomplete will not do anything, but we (re)initialize
// it anyway in case the same input had a previous db's autocomplete in effect.
// options.onServerReply (if given) is a function (Array, term) -> Array that
// post-processes the list of items returned by the server before the list is
// passed back to autocomplete for rendering.
// The following two options apply only when using our locally modified jquery-ui:
// If options.enterSelectsIdentical is true, then if the user hits Enter in the text input
// and their term has an exact match in the autocomplete results, that result is selected.
// options.onEnterTerm (if provided) is a callback function (jqEvent, jqUi) invoked
// when the user hits Enter, after handling enterSelectsIdentical.
+ let $els = ($input instanceof jQuery) ? $input: $($input);
+ $els.each(function() {
+ let $el = $(this);
+ // clone options per-element so later changes don't affect other instances
+ var opts = $.extend(true, {}, options);
+
// The function closure allows us to keep a private cache of past searches.
var cache = {};
+ $el.data("acOptions", opts);
var doSearch = function(term, acCallback) {
// Look up term in searchObj and by sending an ajax request
var timestamp = new Date().getTime();
let cleanedTerm = term.replace(/[\u200b-\u200d\u2060\uFEFF]/g,''); // remove 0 len chars
var url = options.baseUrl + encodeURIComponent(cleanedTerm);
- if (!options.baseUrl.startsWith("hubApi")) {
+ if (!options.baseUrl.includes("hubApi")) {
// hubApi doesn't tolerate extra arguments
url += '&_=' + timestamp;
}
// put up a loading icon so users know something is happening
toggleSpinner(true, options);
$.getJSON(url)
.done(function(results) {
- if (_.isFunction(options.onServerReply)) {
+ if (typeof options.onServerReply === 'function') {
results = options.onServerReply(results, cleanedTerm);
}
// remove the loading icon
toggleSpinner(false, options);
cache[cleanedTerm] = results;
acCallback(results);
+ })
+ .fail(function(jqXHR, textStatus, errorThrown) {
+ // remove the loading icon
+ toggleSpinner(false, options);
+ // If onError is defined, call it to handle the error;
+ // otherwise silently ignore (ref #8816)
+ if (typeof options.onError === 'function') {
+ let results = options.onError(jqXHR, textStatus, errorThrown, cleanedTerm);
+ if (results) {
+ acCallback(results);
+ }
+ }
});
- // ignore errors to avoid spamming people on flaky network connections
- // with tons of error messages (#8816).
};
var autoCompleteSource = function(request, acCallback) {
// This is a callback for jqueryui.autocomplete: when the user types
// a character, this is called with the input value as request.term and an acCallback
// for this to return the result to autocomplete.
// See http://api.jqueryui.com/autocomplete/#option-source
+ // Note: 'this' is the widget instance
+
+ // Handle recent genomes for species search bars
+ if (options.showRecentGenomes && request.term.length < 2) {
+ let recent = getRecentGenomes();
+ if (request.term.length === 0) {
+ // On focus with empty input, show all recent genomes
+ if (recent.length > 0) {
+ acCallback(recent);
+ return;
+ }
+ } else {
+ // On typing 1 char, filter recent genomes
+ let matching = recent.filter(item =>
+ item.label.toLowerCase().includes(request.term.toLowerCase()) ||
+ item.genome.toLowerCase().includes(request.term.toLowerCase())
+ );
+ if (matching.length > 0) {
+ acCallback(matching);
+ return;
+ }
+ }
+ }
+
+ // Handle recent searches for position input
if (this.element[0].id === "positionInput" && request.term.length < 2) {
let searchStack = window.localStorage.getItem("searchStack");
if (request.term.length === 0 && searchStack) {
let searchObj = JSON.parse(searchStack);
let currDb = getDb();
if (currDb in searchObj) {
// sort the results list according to the stack order:
let entries = Object.entries(searchObj[currDb].results);
let stack = searchObj[currDb].stack;
let callbackData = [];
for (let s of stack) {
callbackData.push(searchObj[currDb].results[s]);
}
acCallback(callbackData);
}
return;
}
} else if (request.term.length >=2) {
let results = cache[request.term];
if (results) {
acCallback(results);
} else if (options.baseUrl) {
doSearch(request.term, acCallback);
}
}
};
var autoCompleteSelect = function(event, ui) {
// This is a callback for autocomplete to let us know that the user selected
// a term from the list. See http://api.jqueryui.com/autocomplete/#event-select
// since we are in an autocomplete don't bother saving the
// prefix the user typed in, just keep the geneSymbol itself
if (this.id === "positionInput") {
addRecentSearch(getDb(), ui.item.geneSymbol, ui.item);
}
- options.onSelect(ui.item);
- $input.blur();
+ // Save genome selection for species search bars, but only if item has a definite db.
+ // Taxa-only selections (like "Human" without a specific db) are handled by the
+ // CGI's response handler after the actual db is determined.
+ if (options.showRecentGenomes && ui.item.db && !ui.item.disabled) {
+ addRecentGenome(ui.item);
+ }
+ if (typeof opts.onSelect === 'function') {
+ opts.onSelect(ui.item, $el);
+ }
+ $el.blur();
};
// Provide default values where necessary:
- options.onSelect = options.onSelect || console.log;
- options.enterSelectsIdentical = options.enterSelectsIdentical || false;
+ opts.onSelect = opts.onSelect || function(){};
+ opts.enterSelectsIdentical = opts.enterSelectsIdentical || false;
- $input.autocompleteCat({
+ $el.autocompleteCat({
delay: 500,
minLength: 0,
source: autoCompleteSource,
select: autoCompleteSelect,
- enterSelectsIdentical: options.enterSelectsIdentical,
- enterTerm: options.onEnterTerm
+ enterSelectsIdentical: opts.enterSelectsIdentical,
+ enterTerm: opts.onEnterTerm
});
- if (options.watermark) {
- $input.css('color', 'black');
- $input.Watermark(options.watermark, '#686868');
+ // Trigger autocomplete on focus for species search bars to show recent genomes
+ if (opts.showRecentGenomes) {
+ $el.on('focus', function() {
+ if ($(this).val() === '' || $(this).val() === opts.watermark) {
+ $(this).autocompleteCat('search', '');
}
+ });
+ }
+
+ if (opts.watermark) {
+ $el.css('color', 'black');
+ $el.Watermark(opts.watermark, '#686868');
+ }
+ });
}
return { init: init };
}()); // autocompleteCat