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("|");