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("
  • " + item.category + "
  • " ); currentCategory = item.category; } that._renderItem( ul, item ); }); ul.append("
    Unable to find a genome? Send us a request
    "); }, _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