321bb7e5cd86f33caac0f1975fc1197c01d301a6 chmalee Tue Mar 18 12:39:55 2025 -0700 More hubspace changes. Set height of selected file row to prevent page jumping, cgiEncode the actual ui values to display filenames correctly, make file uploads to an already existing hub actually display the files in that hub, make clicks on table rows more fine tuned: clicks on the checkbox select the file or all subdirectory files like before, but clicks anywhere else depend on the row that was clicked, if a regular row, just select, if a directory row, open the directory (and clear previous selections) diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js index c531b3b323b..507bd807871 100644 --- src/hg/js/hgMyData.js +++ src/hg/js/hgMyData.js @@ -1,29 +1,43 @@ /* jshint esnext: true */ var debugCartJson = true; function prettyFileSize(num) { if (!num) {return "0B";} if (num < (1024 * 1024)) { return `${(num/1024).toFixed(1)}KB`; } else if (num < (1024 * 1024 * 1024)) { return `${((num/1024)/1024).toFixed(1)}MB`; } else { return `${(((num/1024)/1024)/1024).toFixed(1)}GB`; } } +function cgiEncode(value) { + // copy of cheapgi.c:cgiEncode except we are explicitly leaving '/' characters: + let splitVal = value.split('/'); + splitVal.forEach((ele, ix) => { + splitVal[ix] = encodeURIComponent(ele); + }); + return splitVal.join('/'); +} + +function cgiDecode(value) { + // decode an encoded value + return decodeURIComponent(value); +} + function generateApiKey() { let apiKeyInstr = document.getElementById("apiKeyInstructions"); let apiKeyDiv = document.getElementById("apiKey"); if (!document.getElementById("spinner")) { let spinner = document.createElement("i"); spinner.id = "spinner"; spinner.classList.add("fa", "fa-spinner", "fa-spin"); document.getElementById("generateApiKey").after(spinner); } let handleSuccess = function(reqObj) { apiKeyDiv.textContent = reqObj.apiKey; apiKeyInstr.style.display = "block"; let revokeDiv= document.getElementById("revokeDiv"); @@ -314,31 +328,31 @@ let viewBtn = document.getElementById("viewSelectedFiles"); selectedData = data; viewBtn.addEventListener("click", viewAllInGenomeBrowser); viewBtn.textContent = numSelected === 1 ? "View selected file in Genome Browser" : "View all selected files in Genome Browser"; let deleteBtn = document.getElementById("deleteSelectedFiles"); deleteBtn.addEventListener("click", deleteFileList); deleteBtn.textContent = numSelected === 1 ? "Delete selected file" : "Delete selected files"; } // set the visibility of the placeholder text and info text spanParentDiv.style.display = numSelected === 0 ? "none": "block"; let placeholder = document.getElementById("placeHolderInfo"); placeholder.style.display = numSelected === 0 ? "block" : "none"; } - function handleCheckboxSelect(ev, table, row) { + function handleCheckboxSelect(evtype, table, row) { // depending on the state of the checkbox, we will be adding information // to the div, or removing information. We will also be potentially checking/unchecking // all of the checkboxes if the selectAll box was clicked. The data variable // will hold all the information we want to keep visible in the info div let data = {}; // get all of the currently selected rows (may be more than just the one that // was most recently clicked) table.rows({selected: true}).data().each(function(row, ix) { data[ix] = row; }); updateSelectedFileDiv(data); } function createOneCrumb(table, dirName, dirFullPath, doAddEvent) { @@ -387,39 +401,44 @@ currBreadcrumb.appendChild(newSpan); }); } // search related functions: function clearSearch(table) { // clear any fixed searches so we can apply a new one let currSearches = table.search.fixed().toArray(); currSearches.forEach((name) => table.search.fixed(name, null)); } function dataTableShowTopLevel(table) { // show all the "root" files, which are files (probably mostly directories) // with no parentDir clearSearch(table); + // deselect any selected rows like Finder et al when moving into/upto a directory + table.rows({selected: true}).deselect(); table.search.fixed("showRoot", function(searchStr, rowData, rowIx) { return !rowData.parentDir; }); } function dataTableShowDir(table, dirName, dirFullPath) { // show the directory and all immediate children of the directory clearSearch(table); + // deselect any selected rows like Finder et al when moving into/upto a directory + table.rows({selected: true}).deselect(); table.draw(); + // NOTE that the below does not actually render until the next table.draw() call table.search.fixed("oneHub", function(searchStr, rowData, rowIx) { // calculate the fullPath of this rows parentDir in case the dirName passed // to this function has the same name as a parentDir further up in the // listing. For example, consider a test/test/tmp.txt layout, where "test" // is the parentDir of tmp.txt and the test subdirectory let parentDirFull = rowData.fullPath.split("/").slice(0,-1).join("/"); if (rowData.parentDir === dirName && parentDirFull === dirFullPath) { return true; } else if (rowData.fullPath === dirFullPath) { // also return the directory itself return true; } else { return false; } }); @@ -445,33 +464,36 @@ oldRowData = null; } } table.order([{name: "uploadTime", dir: "desc"}]); } else { // move the dirName row into the header, then the other files can // sort normally let row = table.row((idx,data) => data.fullPath === dirData.fullPath); let rowNode = row.node(); if (oldRowData) { // restore the previous row, which will be not displayed by the search anyways: table.row.add(oldRowData); oldRowData = null; } if (!rowNode) { - // if we are using the breadcrumb to jump back 2 directories, we won't - // have a rowNode because the row will not have been rendered yet + // if we are using the breadcrumb to jump back 2 directories or doing an upload + // while a subdirectory is opened, we won't have a rowNode because the row will + // not have been rendered yet. So draw the table with the oldRowData restored table.draw(); + // and now we can try again + row = table.row((idx,data) => data.fullPath === dirData.fullPath); rowNode = row.node(); } oldRowData = row.data(); // put the data in the header: let rowClone = rowNode.cloneNode(true); // match the background color of the normal rows: rowNode.style.backgroundColor = "#f9f9f9"; let thead = document.querySelector(".dt-scroll-headInner > table:nth-child(1) > thead:nth-child(1)"); if (thead.childNodes.length === 1) { thead.appendChild(rowClone); } else { thead.replaceChild(rowClone, thead.lastChild); } // remove the row row.remove(); @@ -620,63 +642,62 @@ // NOTE: we don't add the obj to the filesHash until after we're done // so we don't need to reparse all files each time we add one } } // show all the new rows we just added, note the double draw, we need // to have the new rows rendered to do the order because the order // will copy the actual DOM node parseFileListIntoHash(uiState.fileList); dataTableShowDir(table, hubDirData.fileName, hubDirData.fullPath); table.draw(); dataTableCustomOrder(table, hubDirData); table.draw(); } - function doRowSelect(ev, table, indexes) { + function doRowSelect(evtype, table, indexes) { let selectedRow = table.row(indexes); let rowTr = selectedRow.node(); if (rowTr) { let rowCheckbox = rowTr.childNodes[0].firstChild; let rowChildren = uiState.filesHash[selectedRow.data().fullPath].children; // we need to manually check the children if (rowChildren) { for (let child of rowChildren) { - if (ev.type === "select") { + if (evtype === "select") { table.row((idx,data) => data.fullPath === child.fullPath).select(); } else { table.row((idx,data) => data.fullPath === child.fullPath).deselect(); } } } - // lastly check the row itself - rowCheckbox.checked = ev.type === "select"; - handleCheckboxSelect(ev, table, rowTr); + handleCheckboxSelect(evtype, table, rowTr); } } function indentActionButton(rowTr, rowData) { let numIndents = "0px"; //data.parentDir !== "" ? data.fullPath.split('/').length - 1: 0; if (rowData.fileType !== "dir") { numIndents = "10px"; } rowTr.childNodes[1].style.textIndent = numIndents; } let tableInitOptions = { select: { items: 'row', + selector: 'td:first-child', style: 'multi+shift', // default to a single click is all that's needed }, pageLength: 25, scrollY: 600, scrollCollapse: true, // when less than scrollY height is needed, make the table shorter deferRender: true, // only draw into the DOM the nodes we need for each page orderCellsTop: true, // when viewing a subdirectory, the directory becomes a part of // the header, this option prevents those cells from being used to // sort the table layout: { top2Start: { div: { className: "", id: "breadcrumb", html: "My Data", @@ -808,34 +829,59 @@ return container; }); let table = new DataTable("#filesTable", tableInitOptions); if (uiState.isLoggedIn) { table.buttons(".uploadButton").enable(); document.getElementById("rootBreadcrumb").addEventListener("click", function(e) { dataTableShowTopLevel(table); dataTableCustomOrder(table); dataTableEmptyBreadcrumb(table); table.draw(); }); } else { table.buttons(".uploadButton").disable(); } table.on("select", function(e, dt, type, indexes) { - doRowSelect(e, dt, indexes); + indexes.forEach(function(i) { + doRowSelect(e.type, dt, i); + }); }); table.on("deselect", function(e, dt, type, indexes) { - doRowSelect(e, dt, indexes); + doRowSelect(e.type, dt, indexes); + }); + table.on("click", function(e) { + if (e.target.className !== "dt-select-checkbox") { + e.stopPropagation(); + // we've clicked somewhere not on the checkbox itself, we need to: + // 1. open the directory if the clicked row is a directory + // 2. select the file if the clicked row is a regular file + let row = table.row(e.target); + let data = row.data(); + if (data.children && data.children.length > 0) { + dataTableShowDir(table, data.fileName, data.fullPath); + dataTableCustomOrder(table, {"fullPath": data.fullPath}); + table.draw(); + } else { + if (row.selected()) { + row.deselect(); + doRowSelect("deselect", table, row.index()); + } else { + row.select(); + doRowSelect("select", table, row.index()); + } + } + } }); return table; } function init() { cart.setCgi('hgMyData'); cart.debug(debugCartJson); // TODO: write functions for // creating default trackDbs // editing trackDbs // get the state from the history stack if it exists if (typeof uiData !== 'undefined' && typeof uiState.userFiles !== 'undefined') { _.assign(uiState, uiData.userFiles); parseFileListIntoHash(uiState.fileList); } @@ -1086,61 +1132,61 @@ }, }; let tusOptions = { endpoint: getTusdEndpoint(), withCredentials: true, retryDelays: null, }; uppy.use(Uppy.Dashboard, uppyOptions); uppy.use(Uppy.Tus, tusOptions); uppy.use(BatchChangePlugin, {target: Uppy.Dashboard}); uppy.on('upload-success', (file, response) => { const metadata = file.meta; const d = new Date(metadata.lastModified); const now = new Date(Date.now()); newReqObj = { - "fileName": metadata.fileName, + "fileName": cgiEncode(metadata.fileName), "fileSize": metadata.fileSize, "fileType": metadata.fileType, "genome": metadata.genome, - "parentDir": metadata.parentDir, + "parentDir": cgiEncode(metadata.parentDir), "lastModified": d.toLocaleString(), "uploadTime": now.toLocaleString(), - "fullPath": metadata.parentDir + "/" + metadata.fileName, + "fullPath": cgiEncode(metadata.parentDir) + "/" + cgiEncode(metadata.fileName), }; // from what I can tell, any response we would create in the pre-finish hook // is completely ignored for some reason, so we have to fake the other files // we would have created with this one file and add them to the table if they // weren't already there: hubTxtObj = { "uploadTime": now.toLocaleString(), "lastModified": d.toLocaleString(), "fileName": "hub.txt", "fileSize": 0, "fileType": "hub.txt", "genome": metadata.genome, - "parentDir": metadata.parentDir, - "fullPath": metadata.parentDir + "/hub.txt", + "parentDir": cgiEncode(metadata.parentDir), + "fullPath": cgiEncode(metadata.parentDir) + "/hub.txt", }; parentDirObj = { "uploadTime": now.toLocaleString(), "lastModified": d.toLocaleString(), - "fileName": metadata.parentDir, + "fileName": cgiEncode(metadata.parentDir), "fileSize": 0, "fileType": "dir", "genome": metadata.genome, "parentDir": "", - "fullPath": metadata.parentDir + "/", + "fullPath": cgiEncode(metadata.parentDir), }; // package the three objects together as one "hub" and display it let hub = [parentDirObj, hubTxtObj, newReqObj]; addNewUploadedHubToTable(hub); }); uppy.on('complete', (result) => { history.replaceState(uiState, "", document.location.href); console.log("replace history with uiState"); }); } return { init: init, uiState: uiState, }; }());