b5af24c744cd0f5571634a863c34f208bbd0a199
max
  Wed Sep 23 11:15:40 2026 -0700
hgTrackUi: better error when a faceted composite's metadata file is refused, refs #29344

The 400 error now says which URL was refused and for which track, and
that a hub's metaDataUrl or colorSettingsUrl has to point inside the
hub's directory. facetedComposite.js now shows that text instead of
just "HTTP Status: 400".

Also in facetedComposite.js: facet values with a count of zero are
hidden, unless they are checked, and there is a new "Clear all filters"
button that unchecks all facets and empties the search boxes.

diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js
index 587ca5a2bea..2ff6b993c3d 100644
--- src/hg/js/facetedComposite.js
+++ src/hg/js/facetedComposite.js
@@ -993,32 +993,38 @@
                     }
                 }
                 if (passes) {
                     for (const [col, text] of textFilters) {
                         if (!row[col]?.toLowerCase().includes(text)) {
                             passes = false; break;
                         }
                     }
                 }
                 if (passes) {
                     for (const id of parseCellIds(row[facetCol])) {
                         counts.set(id, (counts.get(id) ?? 0) + 1);
                     }
                 }
             }
-            for (const [id, span] of valMap) {
-                span.textContent = `(${counts.get(id) ?? 0})`;
+            for (const [id, {span, checkbox, label}] of valMap) {
+                const count = counts.get(id) ?? 0;
+                span.textContent = `(${count})`;
+                // Hide unchecked values that would add nothing, the way most
+                // faceted-search UIs (e.g. Amazon) prune the option list as
+                // filters narrow it. A *checked* value stays visible even at
+                // 0 so the user can see, and undo, the filter that zeroed it.
+                label.style.display = (count === 0 && !checkbox.checked) ? "none" : "";
             }
         }
     }
 
     function initFilters(table, allData) {
         const { metadata, colorMap, colNames } = allData;
         const colDescriptions = allData.colDescriptions || {};
 
         // iterate once over entire data not separately per attribute
         // Keyed by lowercase id; first-seen label (or id) is the canonical display.
         const possibleValues = {};  // key -> Map<lowerId, {id, display, count}>
         for (const entry of metadata) {
             for (const [key, val] of Object.entries(entry)) {
                 // The drag-order field is ours, not metadata; it never becomes
                 // a facet and its values are row numbers.
@@ -1033,30 +1039,57 @@
                         existing.count++;
                     } else {
                         map.set(idLower, { id, display: label ?? id, count: 1 });
                     }
                 }
             }
         }
 
         let { maxCheckboxes, primaryKey } = embeddedData;
         if (maxCheckboxes === null || maxCheckboxes === undefined) {
             maxCheckboxes = DEFAULT_MAX_CHECKBOXES;
         }
         const excludeCheckboxes = [primaryKey];
 
         const filtersDiv = document.getElementById("filters");
+
+        // "Clear all filters" sits above every facet group and resets every
+        // filter in one click: facet checkboxes and the per-column table
+        // search boxes alike, since both narrow the table the same way. Built
+        // here, before the facets, so it lands first in the sidebar; its
+        // listener closes over allCboxGroups, filled in as each facet below
+        // is built, so it works regardless of build order. Only actually
+        // inserted into the DOM once we know at least one facet group got
+        // built (see below), matching the "no-facets" check that hides the
+        // whole sidebar otherwise.
+        const clearAllBtn = document.createElement("button");
+        clearAllBtn.textContent = "Clear all filters";
+        clearAllBtn.type = "button";
+        const allCboxGroups = [];
+        clearAllBtn.addEventListener("click", () => {
+            allCboxGroups.forEach(cboxes => cboxes.forEach(cb => { cb.checked = false; }));
+            checkboxFilters.clear();
+            $("#theMetaDataTable thead input[type='text']").each(function () {
+                this.value = "";
+            });
+            textFilters.clear();
+            table.columns().search("");
+            saveUiState({facets: {}, searches: {}});
+            table.draw();
+            updateActiveFilters();
+        });
+
         colNames.forEach((key) => {
             // skip attributes if they should be excluded from checkbox sets
             if (excludeCheckboxes.includes(key) || key.startsWith("_")) {
                 return;
             }
 
             // possibleValues[key] is Map<lowerId, {id, display, count}>
             const sortedPossibleVals = Array.from(possibleValues[key].values());
             sortedPossibleVals.sort((a, b) => b.count - a.count);
 
             // Use 'maxCheckboxes' most frequent items (if they appear > 1 time)
             let topToShow = sortedPossibleVals
                 .filter(({id, count}) =>
                     id.trim().toUpperCase() !== "NA" && count > 1)
                 .slice(0, maxCheckboxes);
@@ -1147,56 +1180,57 @@
                 checkbox.type = "checkbox";
                 checkbox.dataset.valueId = id;
                 label.appendChild(checkbox);
                 if (colorMap && key in colorMap) {
                     const colorBox = document.createElement("span");
                     colorBox.classList.add("color-box");
                     if (id in colorMap[key]) {
                         colorBox.style.backgroundColor = colorMap[key][id];
                     }
                     label.appendChild(colorBox);
                 }
                 label.appendChild(document.createTextNode(`${display} `));
                 const countSpan = document.createElement("span");
                 countSpan.textContent = `(${count})`;
                 label.appendChild(countSpan);
-                colSpans.set(id.toLowerCase(), countSpan);
+                colSpans.set(id.toLowerCase(), { span: countSpan, checkbox, label });
 
                 // "only" narrows this facet to this one value.  Hidden until
                 // the row is hovered, so a long list stays quiet to read.  It
                 // sits inside the label, which would otherwise toggle the
                 // checkbox when the link is clicked, hence preventDefault as
                 // well as stopPropagation.
                 const onlyLink = document.createElement("a");
                 onlyLink.className = "facet-only";
                 onlyLink.href = "#";
                 onlyLink.textContent = "only";
                 onlyLink.title = `Show only ${display}`;
                 onlyLink.addEventListener("click", e => {
                     e.preventDefault();
                     e.stopPropagation();
                     cboxes.forEach(c => { c.checked = (c === checkbox); });
                     applyFacetChange(true);
                 });
                 label.appendChild(onlyLink);
 
                 facetBody.appendChild(label);
                 cboxes.push(checkbox);
             });
 
             facetDiv.appendChild(facetBody);
             filtersDiv.appendChild(facetDiv);
+            allCboxGroups.push(cboxes);
 
             // --- Wire up collapse toggle ---
             heading.addEventListener("click", () => {
                 const isCollapsed = facetBody.classList.toggle("collapsed");
                 heading.classList.toggle("collapsed", isCollapsed);
             });
 
             // --- Wire up checkbox filtering ---
             // Filtering is handled by the custom search extension below, which
             // parses each cell's ids and checks them against checkboxFilters.
             // Restore the boxes this column was left with.  Values that are no
             // longer in the metadata are simply not found and stay unticked.
             const savedFacet = (loadUiState().facets || {})[key];
             if (Array.isArray(savedFacet) && savedFacet.length) {
                 const want = new Set(savedFacet.map(v => String(v).toLowerCase()));
@@ -1216,31 +1250,34 @@
                 cb.addEventListener("change", () => applyFacetChange(cb.checked));
             });
 
             // --- Wire up Clear button ---
             clearBtn.addEventListener("click", () => {
                 cboxes.forEach(cb => cb.checked = false);
                 applyFacetChange(false);
             });
         });  // done creating collapsible checkbox filters for each column
 
         // With every column either the primary key, underscored, or holding
         // values too unique to be worth a checkbox, no facet group gets built.
         // The sidebar is a fixed-width column that does not shrink, so an empty
         // one would sit beside the table as 300px of nothing.  Checked on the
         // element rather than with :empty, which a stray newline in the markup
-        // template would quietly defeat.
+        // template would quietly defeat.  "Clear all" is only worth having, and
+        // is only inserted, once at least one facet group exists to clear.
+        if (filtersDiv.children.length > 0)
+            filtersDiv.insertBefore(clearAllBtn, filtersDiv.firstChild);
         filtersDiv.classList.toggle("no-facets", filtersDiv.children.length === 0);
 
         // Any facet boxes put back from the last visit were ticked while the
         // groups were still being built, so draw the chip bar now that every
         // column has been through the loop.
         if (checkboxFilters.size || textFilters.size) {
             table.draw();
             updateActiveFilters();
         }
 
         // Custom search extension: filter rows by parsed cell ids vs checkboxFilters.
         // Replaces the old per-column regex search so that id-based collapsing works
         // (e.g. "CD8+T" and "CD8+T|CD8+ T Cells" both match the same facet entry).
         $.fn.dataTable.ext.search.push(function(_, __, dataIndex) {
             const rowData = table.row(dataIndex).data();
@@ -1429,55 +1466,75 @@
             // empty, for the same reason as the sort order above.
             uriForUpdate.append(`${mdid}.groupBy`, getGroupBy() || "");
 
             updateVisibilities(uriForUpdate, submitBtnEvent);
         });
     }  // end initSubmit
 
     function initAll(dataForTable) {
         initDataTypeSelector();
         const table = initTable(dataForTable);
         initFilters(table, dataForTable);
         initSubmit(table, dataForTable);
         hideLoading();  // table is built and drawn; remove the spinner
     }
 
+    function extractErrorText(html) {
+        // The CGI's error page is a full HTML document; DOMParser lets us pull out
+        // just the rendered body text (skipping <head>/<title>) without ever
+        // inserting the untrusted markup into the live document.
+        try {
+            const doc = new DOMParser().parseFromString(html, "text/html");
+            return (doc.body?.textContent || "").replace(/\s+/g, " ").trim();
+        } catch (e) {
+            return "";
+        }
+    }
+
     function loadDataAndInit() {  // load data and call init functions
         const { mdid, primaryKey, metadataUrl, colorSettingsUrl, track } = embeddedData;
 
         const paramsFromUrl = new URLSearchParams(window.location.search);
         const hgsid = paramsFromUrl.get("hgsid");
         let fetchBody = `fileUrl=${metadataUrl}&track=${track}`;
         if (hgsid !== null) {
             fetchBody = fetchBody + `&hgsid=${hgsid}`;
         }
 
         // fetch file dynamically
         const fetchUrl = "/cgi-bin/hgTrackUi?" + fetchBody;
         const req = (fetchUrl.length > 2048 || embeddedData.udcTimeout) ?
             fetch("/cgi-bin/hgTrackUi", {
                 method: "POST",
                 headers: { "Content-Type": "application/x-www-form-urlencoded" },
                 body: fetchBody,
             })
             : fetch(fetchUrl, {
                 method: "GET",
                 headers: { "Content-Type": "application/x-www-form-urlencoded" },
             });
         req.then(response => {
-            if (!response.ok) {  // a 404 will look like plain text
-                throw new Error(`HTTP Status: ${response.status}`);
+            if (!response.ok) {
+                // The server's error page explains *why* the fetch was rejected (e.g.
+                // a metaDataUrl outside the hub's directory), which is far more useful
+                // than the bare status code, so pull that text out instead of
+                // discarding the response body.
+                return response.text().then(bodyText => {
+                    const detail = extractErrorText(bodyText);
+                    throw new Error(`HTTP Status: ${response.status}` +
+                                     (detail ? ` - ${detail}` : ""));
+                });
             }
             return response.text();
             })
             .then(tsvText => {  // metadata table is a TSV file to parse
                 loadOptional(colorSettingsUrl, hgsid, track).then(colorMap => {
                     const rows = tsvText.trim().split("\n");
                     // A header cell may carry an optional longer description
                     // after a '|', e.g. "Sample_class|HPRC = ...".  Only the
                     // name part is the column name, because it is also the key
                     // every row object is looked up by.
                     const rawColNames = parseTsvRow(rows[0]);
                     const colNames = [];
                     const colDescriptions = {};
                     rawColNames.forEach(raw => {
                         const bar = raw.indexOf("|");