403ab7c9c2b204f487bb2f86260ffdab355e9517 jcasper Wed Aug 19 05:49:25 2026 -0700 Faceted composites should apply the active sort order to the tracks being displayed; changing the sort changes the display order. We also preserve that order when returning to the page. refs #36320 diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js index b6796214e5e..f19ff0657bf 100644 --- src/hg/js/facetedComposite.js +++ src/hg/js/facetedComposite.js @@ -1,23 +1,33 @@ // SPDX-License-Identifier: MIT; (c) 2025 Andrew D Smith (author) /* jshint esversion: 11 */ $(function() { /* ADS: Uncomment below to force confirm on unload/reload */ // window.addEventListener("beforeunload", function (e) { // e.preventDefault(); e.returnValue = ""; }); const DEFAULT_MAX_CHECKBOXES = 20; // ADS: without default, can get crazy + // Hover help for the sort note above the table. addMouseover() in utils.js + // renders this with innerHTML, so simple tags are fine. + const SORT_ORDER_HELP = + "The row order of this table sets the order the subtracks appear in the " + + "Genome Browser image.

" + + "Click a column heading to sort by that column; click it again to reverse " + + "the direction.

" + + "To sort on more than one column, click the first heading, then " + + "shift-click each additional heading, in the order you want them applied."; + const isValidColorMap = obj => // check the whole thing and ignore if invalid typeof obj === "object" && obj !== null && !Array.isArray(obj) && Object.values(obj).every(x => typeof x === "object" && x !== null && !Array.isArray(x) && Object.values(x).every(value => typeof value === "string")); // fetch file dynamically const loadOptional = (url, hgsid, track) => { // load if possible otherwise carry on if (!url) return Promise.resolve(null); let fetchBody = `fileUrl=${url}&track=${track}`; if (hgsid !== null) { fetchBody = fetchBody + `&hgsid=${hgsid}`; } const fetchUrl = `/cgi-bin/hgTrackUi?${fetchBody}`; const req = (fetchUrl.length > 2048 || embeddedData.udcTimeout) ? @@ -134,40 +144,52 @@ const dataTag = document.getElementById("app-data"); return dataTag ? JSON.parse(dataTag.innerText) : ""; })(); // Store initial checkbox states for delta computation on server const initialState = { dataElements: new Set(), dataTypes: new Set() }; function generateHTML() { const container = document.createElement("div"); container.id = "myTag"; container.innerHTML = `
+
`; // Instead of appending to body, append into the placeholder div document.getElementById("metadata-placeholder").appendChild(container); + + // The table's row order drives the order tracks are drawn in the browser + // image. That's easy to miss (the classic composite UI never says so + // either), so state it in one line and put the details in the hover. + // The icon is appended as a node because createInfoIcon() returns an + // element that already has its mouseover listeners attached. + const note = document.getElementById("sortNote"); + note.appendChild(document.createTextNode( + "Tracks appear in the Genome Browser in the same order as the table " + + "below - click a column heading to re-sort.")); + note.appendChild(createInfoIcon(SORT_ORDER_HELP)); } function updateVisibilities(uriForUpdate, submitBtnEvent) { // get query params from URL const paramsFromUrl = new URLSearchParams(window.location.search); const db = paramsFromUrl.get("db"); const hgsid = paramsFromUrl.get("hgsid"); let body = `${uriForUpdate}`; if (db !== null) { body = body + `&db=${db}`; } if (hgsid !== null) { body = body + `&hgsid=${hgsid}`; } fetch("/cgi-bin/cartDump", { @@ -269,58 +291,86 @@ orderable: false, defaultContent: "", title: ` `, // no render function needed }; const hasDataTypes = embeddedData.dataTypes && Object.keys(embeddedData.dataTypes).length > 0; const itemLabel = hasDataTypes ? "samples" : "tracks"; const singularLabel = itemLabel.slice(0, -1); const columns = [checkboxColumn, ...ordinaryColumns]; + // Map a metadata field name to its DataTables column index, matching + // case-insensitively and ignoring leading underscores. Returns -1 when + // the name isn't one of the metadata columns. + const colIdxForName = name => { + const target = name.replace(/^_+/, "").toLowerCase(); + const idx = colNames.findIndex( + c => c.replace(/^_+/, "").toLowerCase() === target); + return idx >= 0 ? idx + 1 : -1; // +1 for the checkbox column + }; + // Determine which column to sort by: use defaultSortField if it matches - // a metadata column (case-insensitive, ignoring leading underscores), - // otherwise fall back to the first data column. + // a metadata column, otherwise fall back to the first data column. let defaultSortCol = 1; // column 0 is checkboxes, 1 is first data col if (embeddedData.defaultSortField) { - const target = embeddedData.defaultSortField.replace(/^_+/, "").toLowerCase(); - const idx = colNames.findIndex( - c => c.replace(/^_+/, "").toLowerCase() === target); - if (idx >= 0) - defaultSortCol = idx + 1; // +1 for the checkbox column + const idx = colIdxForName(embeddedData.defaultSortField); + if (idx > 0) + defaultSortCol = idx; + } + + // A sort the user established on an earlier visit wins over + // defaultSortField. facetSortOrder mirrors the classic composite + // '.sortOrder' cart value: 'field=+ field2=-', in sort precedence + // order. Fields no longer present in the metadata are dropped, so a + // changed metadata file degrades to a partial (or default) sort rather + // than an error. + let initialOrder = [[defaultSortCol, "asc"]]; + if (embeddedData.facetSortOrder) { + const savedOrder = embeddedData.facetSortOrder.trim().split(/\s+/) + .map(token => { + const eq = token.lastIndexOf("="); + if (eq < 1) return null; + const idx = colIdxForName(token.slice(0, eq)); + if (idx < 0) return null; + return [idx, token.slice(eq + 1) === "-" ? "desc" : "asc"]; + }) + .filter(Boolean); + if (savedOrder.length > 0) + initialOrder = savedOrder; } const table = $("#theMetaDataTable").DataTable({ data: metadata, deferRender: true, // seems faster columns: columns, columnDefs: [ { targets:0, render: DataTable.render.select() } ], // 'responsive' (collapsing overflow columns) is intentionally off: // a wide table instead gets an internal horizontal scrollbar via // the .table-xscroll wrapper added at the end of initTable. responsive: false, layout: { topStart: 'pageLength', topEnd: null, // omit global search bottomStart: 'info', bottomEnd: 'paging' }, - order: [[defaultSortCol, "asc"]], + order: initialOrder, pageLength: 25, // show 25 rows per page by default lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]], language: { lengthMenu: `Show _MENU_ ${itemLabel}`, select: { rows: { 0: "", 1: `1 ${singularLabel} selected`, _: `%d ${itemLabel} selected` } }, info: `Showing _START_ to _END_ of _TOTAL_ ${itemLabel}`, infoFiltered: `(filtered from _MAX_ total ${itemLabel})`, }, select: { style: "multi", selector: "td:not(:has(a))" }, @@ -758,54 +808,62 @@ removeBtn.className = "remove-chip"; removeBtn.type = "button"; removeBtn.textContent = "\u00d7"; removeBtn.addEventListener("click", () => { checkbox.checked = false; checkbox.dispatchEvent(new Event("change")); }); chip.appendChild(removeBtn); container.appendChild(chip); }); }); container.style.display = "flex"; } - function initSubmit(table) { // logic for the submit event + function initSubmit(table, allData) { // logic for the submit event + const { colNames } = allData; const { mdid, primaryKey } = embeddedData; // mdid: metadata identifier const hasDataTypes = embeddedData.dataTypes && Object.keys(embeddedData.dataTypes).length > 0; document.getElementById("Submit").addEventListener("click", (submitBtnEvent) => { submitBtnEvent.preventDefault(); // hold the submit button event const currentDataTypes = []; if (hasDataTypes) { // Get current data type selections document.querySelectorAll("input.cbgroup").forEach(cb => { if (cb.checked) { currentDataTypes.push(cb.value); } }); // Require at least one data type when the selector exists if (currentDataTypes.length === 0) { alert("Please select at least one data type."); return; // abort submission } } - // Get current data element selections - const currentDataElements = table.rows({selected: true}).data().toArray() + // Get current data element selections, in the order they are + // currently sorted/displayed in the table. The server uses this + // order to assign each shown subtrack a '.priority' cart value so + // the tracks appear in hgTracks in the same order as here. 'search' + // is 'none' so selected-but-facet-filtered rows still get a sensible + // position rather than being dropped from the ordering. + const currentDataElements = + table.rows({selected: true, order: "current", search: "none"}) + .data().toArray() .map(obj => primaryKeyId(obj[primaryKey])); // Enforce an upper bound on the number of tracks on at the same time. // This is imperfect when data types are present - some combinations might // have been manually hidden by the user. But it should be a good ballpark. const trackLimit = 1000; if (hasDataTypes) { if (currentDataTypes.length * currentDataElements.length > trackLimit) { alert("You have turned on too many subtracks (over 1000) - please uncheck some."); return; // abort submission } } else { if (currentDataElements.length > trackLimit) { alert("You have turned on too many subtracks (over 1000) - please uncheck some."); return; // abort submission @@ -837,39 +895,55 @@ if (initialState.dataTypes.size > 0) { initialState.dataTypes.forEach(dt => { uriForUpdate.append(`${mdid}.dt_was`, dt);}); } else { uriForUpdate.append(`${mdid}.dt_was`, ""); } if (currentDataTypes.length > 0) { currentDataTypes.forEach(dt => { uriForUpdate.append(`${mdid}.dt_now`, dt);}); } else { uriForUpdate.append(`${mdid}.dt_now`, ""); } } // No ${mdid}.dt* variables indicates that the composite doesn't use data types + // Preserve the current sort so the table comes back the same way on the + // next visit. Column names rather than DataTables column indexes, so + // this survives a change in metadata column order - the same reason + // defaultSortField is matched by name. Format mirrors the classic + // composite '.sortOrder' cart value: 'field=+ field2=-'. + const sortSpec = table.order() + // column 0 is the checkboxes and isn't orderable; an entry with + // neither direction is a column in its unsorted state + .filter(o => Array.isArray(o) && o[0] >= 1 && o[0] <= colNames.length && + (o[1] === "asc" || o[1] === "desc")) + .map(o => colNames[o[0] - 1] + (o[1] === "asc" ? "=+" : "=-")) + // whitespace in a field name would break the space-separated format + .filter(token => !/\s/.test(token)); + // Sent even when empty, so the server clears any stale value + uriForUpdate.append(`${mdid}.facetSortOrder`, sortSpec.join(" ")); + updateVisibilities(uriForUpdate, submitBtnEvent); }); } // end initSubmit function initAll(dataForTable) { initDataTypeSelector(); const table = initTable(dataForTable); initFilters(table, dataForTable); - initSubmit(table); + initSubmit(table, dataForTable); hideLoading(); // table is built and drawn; remove the spinner } 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;