161ce497109c05416bfe719cb391667d64c580b2
chmalee
  Tue Mar 17 15:15:18 2026 -0700
Merge hgSearch results by trackName before looping over results, which prevents having multiple sections per hgFindSpec as some tracks have multiple hgFindSpecs that can match for a given term. Also fix category display in a similar way so that the same track does not show up in the tree multiple times which breaks checkbox selection, refs #34334 and #37078

diff --git src/hg/js/hgSearch.js src/hg/js/hgSearch.js
index 895fa7de179..965f15cabfa 100644
--- src/hg/js/hgSearch.js
+++ src/hg/js/hgSearch.js
@@ -128,34 +128,36 @@
     function tracksToTree(trackList) {
         /* Go through the list of all tracks for this assembly, filling
          * out the necessary information for jstree to be able to work.
          * Only include categories that have results.
          * The groups object will get filled out along the way. */
         trackGroups = uiState.trackGroups;
         var ret = [];
         var parentsHash = {};
         var groups = {};
         visibleTrackGroup.children = [];
         visibleTrackGroup.numMatches = 0;
         visibleTrackGroup.searchTime = -1;
         hiddenTrackGroup.children = [];
         hiddenTrackGroup.numMatches = 0;
         hiddenTrackGroup.searchTime = -1;
+        var seenTracks = {};
         _.each(trackList, function(track) {
-            if (!(track.id in uiState.resultHash)) {
+            if (!(track.id in uiState.resultHash) || track.id in seenTracks) {
                 return;
             }
+            seenTracks[track.id] = true;
             var newCateg = {};
             _.assign(newCateg, track);
             newCateg.text = track.longLabel;
             newCateg.text = track.label;
             var group = track.group;
             newCateg.state = {checked: true, opened: true};
             newCateg.text = track.label;
             newCateg.li_attr = {title: track.description};
             newCateg.numMatches = uiState.resultHash[newCateg.id].matches.length;
             newCateg.searchTime = uiState.resultHash[newCateg.id].searchTime;
             addCountAndTimeToLabel(newCateg);
             if (track.visibility > 0) {
                 if (!groups.visible) {
                     groups.visible = visibleTrackGroup;
                 }
@@ -537,62 +539,63 @@
             $("#searchBarSearchString").val("");
         }
         if (uiState && uiState.positionMatches && uiState.positionMatches.length > 0) {
             // clear the old search results if there were any:
             parentDiv.empty();
 
             // create the elements that will hold results:
             var newList = document.createElement("ul");
             var noUlStyle = document.createAttribute("class");
             noUlStyle.value = "ulNoStyle";
             newList.setAttributeNode(noUlStyle);
             parentDiv.append(newList);
 
             clearOldFacetCounts();
             var categoryCount = 0;
-            // Loop through categories of match (public hubs, help docs, a single track, ...
-            _.each(uiState.positionMatches, function(categ) {
-                let title = categ.name;
+            // Loop through merged results (resultHash combines matches from
+            // multiple hgFindSpecs for the same track into one entry)
+            _.each(Object.keys(uiState.resultHash), function(title) {
+                let categ = uiState.resultHash[title];
                 let searchDesc = categ.description;
                 let matches = categ.matches;
-                let numMatches = matches.length;
-                let newListObj = document.createElement("li");
+                let idKey = title + 'Results';
                 let idAttr = document.createAttribute("id");
-                idAttr.value = title + 'Results';
+                idAttr.value = idKey;
+                let newListObj = document.createElement("li");
                 newListObj.setAttributeNode(idAttr);
                 let noLiStyle = document.createAttribute("class");
                 noLiStyle.value = "liNoStyle";
                 newListObj.setAttributeNode(noLiStyle);
                 let inp = document.createElement("input");
                 inp.type = "hidden";
-                inp.id = idAttr.value + categoryCount;
+                inp.id = idKey + categoryCount;
                 inp.value = "0";
                 newListObj.appendChild(inp);
                 let ctrlImg = document.createElement("img");
                 ctrlImg.height = "18";
                 ctrlImg.width = "18";
-                ctrlImg.id = idAttr.value + categoryCount + "_button";
+                ctrlImg.id = idKey + categoryCount + "_button";
                 ctrlImg.src = "../images/remove_sm.gif";
                 newListObj.appendChild(ctrlImg);
                 let descText = document.createTextNode(" " + searchDesc + ":");
                 newListObj.appendChild(descText);
                 // Now loop through each actual hit on this table and unpack onto list
                 let subList = document.createElement("ul");
                 // only print the first 10 at first
                 printMatches(subList, matches.slice(0,10), title, searchDesc, false);
                 if (matches.length > 10) {
-                    let idStr = idAttr.value + "_" + categoryCount;
+                    let idStr = idKey + "_" + categoryCount;
                     let showMoreLi = document.createElement("li");
                     showMoreLi.id = idStr;
                     showMoreLi.classList.add("liNoStyle","searchResult");
                     let showMoreInp = document.createElement("input");
                     showMoreInp.type = "hidden";
                     showMoreInp.value = '0';
                     showMoreInp.id = showMoreLi.id + "showMore";
                     showMoreLi.appendChild(showMoreInp);
                     let showMoreImg = document.createElement("img");
                     showMoreImg.height = "18";
                     showMoreImg.width = "18";
                     showMoreImg.id = showMoreLi.id + "_showMoreButton";
                     showMoreImg.src = "../images/add_sm.gif";
                     showMoreLi.appendChild(showMoreImg);
                     let showMoreDiv = document.createElement("div");
@@ -603,33 +606,33 @@
                     let newText = "";
                     if (matches.length > 500) {
                         newText = " Show 490 (out of " + (matches.length) + " total) more matches for " + searchDesc;
                     } else {
                         newText = " Show " + (matches.length - 10) + " more matches for " + searchDesc;
                     }
                     showMoreA.textContent = newText;
                     showMoreDiv.appendChild(showMoreA);
                     showMoreLi.appendChild(showMoreDiv);
                     subList.appendChild(showMoreLi);
                 }
                 newListObj.append(subList);
                 newList.append(newListObj);
 
                 // make result list collapsible:
-                $('#'+idAttr.value+categoryCount+"_button").click(collapseNode);
-                $('#'+idAttr.value+"_" +categoryCount+"_showMoreButton").click(showMoreResults);
-                $('#'+idAttr.value + "_" + categoryCount + "_showMoreLink").click(showMoreResults);
+                $('#'+idKey+categoryCount+"_button").click(collapseNode);
+                $('#'+idKey+"_" +categoryCount+"_showMoreButton").click(showMoreResults);
+                $('#'+idKey + "_" + categoryCount + "_showMoreLink").click(showMoreResults);
                 categoryCount += 1;
             });
         } else if (uiState && typeof uiState.search !== "undefined") {
             // No results from match
             var msg = "<p>No results</p>";
             parentDiv.empty();
             parentDiv.html(msg);
             clearOldFacetCounts();
         } else {
             parentDiv.empty();
         }
     }
 
     function handleGenomeChange(jsonData) {
         // Handle the response from getUiState after the user changes the genome.
@@ -731,34 +734,40 @@
             return true;
         }
         return false;
     }
 
     function updateStateAndPage(jsonData, doSaveHistory) {
         // Update uiState with new values and update the page.
         _.assign(uiState, jsonData);
         db = uiState.db;
         if (typeof jsonData === "undefined" || jsonData === null) {
             // now that the show more text is a link, a popstate event gets fired because the url changes
             // we can safely return because there is no state to change
             return;
         }
         if (typeof jsonData.positionMatches !== "undefined") {
-            // clear the old resultHash
+            // clear the old resultHash, merging matches from multiple hgFindSpecs
+            // for the same track into one entry
             uiState.resultHash = {};
             _.each(uiState.positionMatches, function(match) {
+                if (match.name in uiState.resultHash) {
+                    uiState.resultHash[match.name].matches =
+                        uiState.resultHash[match.name].matches.concat(match.matches);
+                } else {
                     uiState.resultHash[match.name] = match;
+                }
             });
         } else {
             // no results for this search
             uiState.resultHash = {};
             uiState.positionMatches = [];
         }
         updateFilters(uiState);
         updateSearchResults(uiState);
         updateCurrentGenomeLabel();
         urlVars = {"db": db, "search": uiState.search, "showSearchResults": ""};
         // changing the url allows the history to be associated to a specific url
         var urlParts = changeUrl(urlVars);
         $("#searchCategories").jstree(true).refresh(false,true);
         saveLinkClicks();
         if (doSaveHistory)
@@ -955,32 +964,38 @@
                 }
                 window.location.replace(newUrl);
             }
             var urlParts = {};
             if (debugCartJson) {
                 console.log('from server:\n', cartJson);
             }
             if (typeof cartJson.search !== "undefined") {
                 urlParts = changeUrl({"search": cartJson.search});
             } else {
                 urlParts = changeUrl({"db": db});
                 cartJson.search = urlParts.urlVars.search;
             }
             _.assign(uiState,cartJson);
             if (typeof cartJson.categs  !== "undefined") {
+                uiState.resultHash = {};
                 _.each(uiState.positionMatches, function(match) {
+                    if (match.name in uiState.resultHash) {
+                        uiState.resultHash[match.name].matches =
+                            uiState.resultHash[match.name].matches.concat(match.matches);
+                    } else {
                         uiState.resultHash[match.name] = match;
+                    }
                 });
                 filtersToJstree();
                 makeCategoryTree();
             } else {
                 cart.send({ getUiState: {db: db} }, handleRefreshState);
                 cart.flush();
             }
             $("#searchCategories").bind('ready.jstree', function(e, data) {
                 // wait for the category jstree to finish loading before showing the results
                 $("#searchBarSearchString").val(uiState.search);
                 updateSearchResults(uiState);
 
                 // when a category is checked/unchecked we show/hide that result
                 // from the result list
                 $("#searchCategories").on('check_node.jstree uncheck_node.jstree', function(e, data) {