e03fbe234ac861ea3b1831f01a18b36aea53a58d chmalee Tue Feb 25 11:58:38 2025 -0800 Make font-size of breadcrumb text larger, make the final breadcrumb not underlined since it cannot be clicked, make the final breadcrumb not clickable, refs ##31058 diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js index 152de77e97a..c531b3b323b 100644 --- src/hg/js/hgMyData.js +++ src/hg/js/hgMyData.js @@ -1,1143 +1,1146 @@ /* 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 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"); revokeDiv.style.display = "block"; document.getElementById("spinner").remove(); // remove the word 'already' from the message if we have just re-generated a key let refreshSpan = document.getElementById("removeOnGenerate"); if (refreshSpan) { refreshSpan.style.display = "none"; } }; let cartData = {generateApiKey: {}}; cart.setCgi("hgHubConnect"); cart.send(cartData, handleSuccess); cart.flush(); } function revokeApiKeys() { 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("revokeApiKeys").after(spinner); } let handleSuccess = function(req) { apiKeyInstr.style.display = "none"; document.getElementById("spinner").remove(); let generateDiv = document.getElementById("generateDiv"); generateDiv.style.display = "block"; let revokeDiv = document.getElementById("revokeDiv"); revokeDiv.style.display = "none"; }; let cartData = {revokeApiKey: {}}; cart.setCgi("hgHubConnect"); cart.send(cartData, handleSuccess); cart.flush(); } const fileNameRegex = /[0-9a-zA-Z._\-+]+/g; // allowed characters in file names const parentDirRegex = /[0-9a-zA-Z._\-+ ]+/g; // allowed characters in hub names, spaces allowed // make our Uppy instance: const uppy = new Uppy.Uppy({ debug: true, allowMultipleUploadBatches: false, onBeforeUpload: (files) => { // set all the fileTypes and genomes from their selects let doUpload = true; let thisQuota = 0; for (let [key, file] of Object.entries(files)) { let fileNameMatch = file.meta.name.match(fileNameRegex); let parentDirMatch = file.meta.parentDir.match(parentDirRegex); if (!fileNameMatch || fileNameMatch[0] !== file.meta.name) { uppy.info(`Error: File name has special characters, please rename file: ${file.meta.name} to only include alpha-numeric characters, period, dash, underscore or plus.`, 'error', 2000); doUpload = false; continue; } if (!parentDirMatch || parentDirMatch[0] !== file.meta.parentDir) { uppy.info(`Error: Hub name has special characters, please rename file: ${file.meta.parentDir} to only include alpha-numeric characters, period, dash, underscore, plus or space.`, 'error', 2000); doUpload = false; continue; } if (!file.meta.genome) { uppy.info(`Error: No genome selected for file ${file.meta.name}!`, 'error', 2000); doUpload = false; continue; } else if (!file.meta.fileType) { uppy.info(`Error: File type not supported, file: ${file.meta.name}!`, 'error', 2000); doUpload = false; continue; } uppy.setFileMeta(file.id, { fileName: file.meta.name, fileSize: file.size, lastModified: file.data.lastModified, }); thisQuota += file.size; } if (thisQuota + hubCreate.uiState.userQuota > hubCreate.uiState.maxQuota) { uppy.info(`Error: this file batch exceeds your quota. Please delete some files to make space or email genome-www@soe.ucsc.edu if you feel you need more space.`); doUpload = false; } return doUpload; }, }); var hubCreate = (function() { let uiState = { // our object for keeping track of the current UI and what to do userUrl: "", // the web accesible path where the uploads are stored for this user hubNameDefault: "", isLoggedIn: "", maxQuota: 0, userQuota: 0, userFiles: {}, // same as uiData.userFiles on page load filesHash: {}, // for each file, userFiles.fullPath is the key, and then the userFiles.fileList data as the value, with an extra key for the child fullPaths if the file is a directory }; function getTusdEndpoint() { // this variable is set by hgHubConnect and comes from hg.conf value return tusdEndpoint; } let extensionMap = { "bigBed": [".bb", ".bigbed"], "bam": [".bam"], "vcf": [".vcf"], "vcfTabix": [".vcf.gz", "vcf.bgz"], "bigWig": [".bw", ".bigwig"], "hic": [".hic"], "cram": [".cram"], "bigBarChart": [".bigbarchart"], "bigGenePred": [".bgp", ".biggenepred"], "bigMaf": [".bigmaf"], "bigInteract": [".biginteract"], "bigPsl": [".bigpsl"], "bigChain": [".bigchain"], "bamIndex": [".bam.bai", ".bai"], "tabixIndex": [".vcf.gz.tbi", "vcf.bgz.tbi"], "hub.txt": ["hub.txt"], "text": [".txt", ".text"], }; function detectFileType(fileName) { let fileLower = fileName.toLowerCase(); for (let fileType in extensionMap) { for (let ext of extensionMap[fileType]) { if (fileLower.endsWith(ext)) { return fileType; } } } //we could alert here but instead just explicitly set the value to null //and let the backend reject it instead, forcing the user to rename their //file //alert(`file extension for ${fileName} not found, please explicitly select it`); return null; } function defaultDb() { return cartDb.split(" ").slice(-1)[0]; } function makeGenomeSelectOptions() { // Returns an array of options for genomes let ret = []; let choices = ["Human hg38", "Human T2T", "Human hg19", "Mouse mm39", "Mouse mm10"]; let cartChoice = {}; cartChoice.id = cartDb; cartChoice.label = cartDb; cartChoice.value = cartDb.split(" ").slice(-1)[0]; if (cartChoice.value.startsWith("hub_")) { cartChoice.label = cartDb.split(" ").slice(0,-1).join(" "); // take off the actual db value } cartChoice.selected = true; ret.push(cartChoice); choices.forEach( (e) => { if (e === cartDb) {return;} // don't print the cart database twice let choice = {}; choice.id = e; choice.label = e; choice.value = e.split(" ")[1]; ret.push(choice); }); return ret; } function makeTypeSelectOptions() { let ret = []; let autoChoice = {}; autoChoice.label = "Auto-detect from extension"; autoChoice.value = "Auto-detect from extension"; autoChoice.selected = true; ret.push(autoChoice); let choices = ["bigBed", "bam", "vcf", "vcf (bgzip or gzip compressed)", "bigWig", "hic", "cram", "bigBarChart", "bigGenePred", "bigMaf", "bigInteract", "bigPsl", "bigChain"]; choices.forEach( (e) => { let choice = {}; choice.id = e; choice.label = e; choice.value = e; ret.push(choice); }); return ret; } function viewInGenomeBrowser(fname, ftype, genome, hubName) { // redirect to hgTracks with this track open in the hub if (typeof uiState.userUrl !== "undefined" && uiState.userUrl.length > 0) { if (ftype in extensionMap) { // TODO: tusd should return this location in it's response after // uploading a file and then we can look it up somehow, the cgi can // write the links directly into the html directly for prev uploaded files maybe? let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid() + "&db=" + genome + "&hubUrl=" + uiState.userUrl + hubName + "/hub.txt&" + trackHubFixName(fname) + "=pack"; window.location.assign(url); return false; } } } const regex = /[^A-Za-z0-9_-]/g; function trackHubFixName(trackName) { // replace everything but alphanumeric, underscore and dash with underscore return encodeURIComponent(trackName).replaceAll(regex, "_"); } // helper object so we don't need to use an AbortController to update // the data this function is using let selectedData = {}; function viewAllInGenomeBrowser(ev) { // redirect to hgTracks with these tracks/hubs open let data = selectedData; if (typeof uiState.userUrl !== "undefined" && uiState.userUrl.length > 0) { let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid(); let genome; // may be multiple genomes in list, just redirect to the first one // TODO: this should probably raise an alert to click through let hubsAdded = {}; _.forEach(data, (d) => { if (!genome) { genome = d.genome; url += "&db=" + genome; } if (d.fileType in extensionMap) { // TODO: tusd should return this location in it's response after // uploading a file and then we can look it up somehow, the cgi can // write the links directly into the html directly for prev uploaded files maybe? if (!(d.parentDir in hubsAdded)) { // NOTE: hubUrls get added regardless of whether they are on this assembly // or not, because multiple genomes may have been requested. If this user // switches to another genome we want this hub to be connected already url += "&hubUrl=" + uiState.userUrl + d.parentDir; if (d.parentDir.endsWith("/")) { url += "hub.txt"; } else { url += "/hub.txt"; } } hubsAdded[d.parentDir] = true; if (d.genome == genome) { // turn the track on if its for this db url += "&" + trackHubFixName(d.fileName) + "=pack"; } } else if (d.fileType === "hub.txt") { url += "&hubUrl=" + uiState.userUrl + d.fullPath; } }); window.location.assign(url); return false; } } function deleteFileSuccess(jqXhr, textStatus) { deleteFileFromTable(jqXhr.deletedList); updateSelectedFileDiv(null); } function deleteFileList(ev) { // same as deleteFile() but acts on the selectedData variable let data = selectedData; let cartData = {deleteFile: {fileList: []}}; cart.setCgi("hgHubConnect"); _.forEach(data, (d) => { cartData.deleteFile.fileList.push({ fileName: d.fileName, fileType: d.fileType, parentDir: d.parentDir, genome: d.genome, fullPath: d.fullPath, }); }); cart.send(cartData, deleteFileSuccess); cart.flush(); } function updateSelectedFileDiv(data) { // update the div that shows how many files are selected let numSelected = data !== null ? Object.entries(data).length : 0; let infoDiv = document.getElementById("selectedFileInfo"); let span = document.getElementById("numberSelectedFiles"); let spanParentDiv = span.parentElement; span.textContent = `${numSelected} ${numSelected > 1 ? "files" : "file"}`; if (numSelected > 0) { // (re) set up the handlers for the selected file info div: 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) { // 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) { + function createOneCrumb(table, dirName, dirFullPath, doAddEvent) { // make a new span that can be clicked to nav through the table let newSpan = document.createElement("span"); newSpan.id = dirName; newSpan.textContent = decodeURIComponent(dirName); newSpan.classList.add("breadcrumb"); + if (doAddEvent) { newSpan.addEventListener("click", function(e) { dataTableShowDir(table, dirName, dirFullPath); table.draw(); dataTableCustomOrder(table, {"fullPath": dirFullPath}); table.draw(); }); + } else { + // can't click the final crumb so don't underline it + newSpan.style.textDecoration = "unset"; + } return newSpan; } function dataTableEmptyBreadcrumb(table) { let currBreadcrumb = document.getElementById("breadcrumb"); currBreadcrumb.replaceChildren(currBreadcrumb.firstChild); - currBreadcrumb.firstChild.style.cursor = "unset"; - currBreadcrumb.firstChild.style.textDecoration = "unset"; } function dataTableCreateBreadcrumb(table, dirName, dirFullPath) { // Re-create the breadcrumb nav to move back through directories let currBreadcrumb = document.getElementById("breadcrumb"); // empty the node but leave the first "My Data" span if (currBreadcrumb.children.length > 1) { currBreadcrumb.replaceChildren(currBreadcrumb.firstChild); } - currBreadcrumb.firstChild.style.cursor = "pointer"; - currBreadcrumb.firstChild.style.textDecoration = "underline"; let components = dirFullPath.split("/"); + let numComponents = components.length; components.forEach(function(dirName, dirNameIx) { if (!dirName) { return; } + let doAddEvent = dirNameIx !== (numComponents - 1); let path = components.slice(0, dirNameIx+1); componentFullPath = path.join('/'); - let newSpan = createOneCrumb(table, dirName, componentFullPath); + let newSpan = createOneCrumb(table, dirName, componentFullPath, doAddEvent); currBreadcrumb.appendChild(document.createTextNode(" > ")); 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); 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); table.draw(); 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; } }); dataTableCreateBreadcrumb(table, dirName, dirFullPath); } // when we move into a new directory, we remove the row from the table // and add it's html into the header, keep the row object around so // we can add it back in later let oldRowData = null; function dataTableCustomOrder(table, dirData) { // figure out the order the rows of the table should be in // if dirData is null, sort on uploadTime first // if dirData exists, that is the first row, followed by everything else // in uploadTime order if (!dirData) { // make sure the old row can show up again in the table let thead = document.querySelector(".dt-scroll-headInner > table:nth-child(1) > thead:nth-child(1)"); if (thead.childNodes.length > 1) { let old = thead.removeChild(thead.lastChild); if (oldRowData) { table.row.add(oldRowData); 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 table.draw(); 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(); // now do a regular order table.order([{name: "uploadTime", dir: "desc"}]); } } function parseFileListIntoHash(fileList) { // Hash the uiState fileList by the fullPath, and also store the children // for each directory // first go through and copy all of the data and make the empty // children array for each directory fileList.forEach(function(fileData) { uiState.filesHash[fileData.fullPath] = fileData; if (fileData.fileType === "dir") { uiState.filesHash[fileData.fullPath].children = []; } }); // use a second pass to go through and set the children // since we may not have encountered them yet in the above loop fileList.forEach(function(fileData) { if (fileData.fileType !== "dir" || fileData.parentDir !== "") { // compute the key from the fullPath: let parts = fileData.fullPath.split("/"); let keyName = parts.slice(0,-1).join("/"); if (keyName in uiState.filesHash) { uiState.filesHash[keyName].children.push(fileData); } } }); } function getChildRows(dirFullPath, childRowArray) { // Recursively return all of the child rows for a given path let childRows = uiState.filesHash[dirFullPath].children; childRows.forEach(function(rowData) { if (rowData.fileType !== "dir") { childRowArray.push(rowData); } else { childRowArray.concat(getChildRows(rowData.fullPath, childRowArray)); } }); } function dataTablePrintSize(data, type, row, meta) { if (row.fileType !== "dir") { return prettyFileSize(data); } else { let childRows = []; getChildRows(row.fullPath, childRows); let sum = childRows.reduce( (accumulator, currentValue) => { return accumulator + currentValue.fileSize; }, 0); return prettyFileSize(sum); } } function dataTablePrintGenome(data, type, row, meta) { if (data.startsWith("hub_")) return data.split("_").slice(2).join("_"); return data; } function dataTablePrintAction(rowData) { /* Return a node for rendering the actions column */ if (rowData.fileType === "dir") { let folderIcon = document.createElement("i"); folderIcon.style.display = "inline-block"; folderIcon.style.backgroundImage = "url(\"../images/folderC.png\")"; folderIcon.style.backgroundPosition = "left center"; folderIcon.style.backgroundRepeat = "no-repeat"; folderIcon.style.width = "24px"; folderIcon.style.height = "24px"; folderIcon.classList.add("folderIcon"); folderIcon.addEventListener("click", function(e) { e.stopPropagation(); console.log("folder click"); let table = $("#filesTable").DataTable(); let trow = $(e.target).closest("tr"); let row = table.row(trow); dataTableShowDir(table, rowData.fileName, rowData.fullPath); dataTableCustomOrder(table, rowData); table.draw(); }); return folderIcon; } else { // only offer the button if this is a track file if (rowData.fileType !== "hub.txt" && rowData.fileType !== "text" && rowData.fileType in extensionMap) { let container = document.createElement("div"); let viewBtn = document.createElement("button"); viewBtn.textContent = "View in Genome Browser"; viewBtn.type = 'button'; viewBtn.addEventListener("click", function(e) { e.stopPropagation(); viewInGenomeBrowser(rowData.fileName, rowData.fileType, rowData.genome, rowData.parentDir); }); container.appendChild(viewBtn); return container; } else { return null; } } } function deleteFileFromTable(pathList) { // req is an object with properties of an uploaded file, make a new row // for it in the filesTable let table = $("#filesTable").DataTable(); let rows = table.rows((idx, data) => pathList.includes(data.fullPath)); rows.remove().draw(); let toKeep = (elem) => !pathList.includes(elem.fullPath); uiState.fileList = uiState.fileList.filter(toKeep); history.replaceState(uiState, "", document.location.href); } function addFileToHub(rowData) { // a file has been uploaded and a hub has been created, present a modal // to choose which hub to associate this track to // backend wise: move the file into the hub directory // update the hubSpace row with the hub name // frontend wise: move the file row into a 'child' of the hub row console.log(`sending addToHub req for ${rowData.fileName} to `); cart.setCgi("hgHubConnect"); cart.send({addToHub: {hubName: "", dataFile: ""}}); cart.flush(); } function addNewUploadedHubToTable(hub) { // hub is a list of objects representing the file just uploaded, the associated // hub.txt, and directory. Make a new row for each in the filesTable, except for // maybe the hub directory row and hub.txt which we may have already seen before let table = $("#filesTable").DataTable(); let justUploaded = {}; // hash of contents of hub but keyed by fullPath let hubDirData = {}; // the data for the parentDir of the uploaded file for (let obj of hub) { if (!obj.parentDir) { hubDirData = obj; } let rowObj; if (!(obj.fullPath in uiState.filesHash)) { justUploaded[obj.fullPath] = obj; rowObj = table.row.add(obj); uiState.fileList.push(obj); // 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) { 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") { 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); } } 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', 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", + html: "My Data", } }, topStart: { buttons: [ { text: 'Upload', action: function() {return;}, className: 'uploadButton', enabled: false, // disable by default in case user is not logged in }, ], quota: null, }, }, columnDefs: [ { orderable: false, targets: 0, render: DataTable.render.select(), }, { orderable: false, targets: 1, data: "action", title: "", render: function(data, type, row) { if (type === "display") { return dataTablePrintAction(row); } return ''; } }, { targets: 2, render: function(data, type, row, meta) { return decodeURIComponent(data); } }, { targets: 3, render: function(data, type, row, meta) { if (type === "display") { return dataTablePrintSize(data, type, row, meta); } return data; } }, { targets: 5, render: function(data, type, row) { if (type === "display") { return dataTablePrintGenome(data); } return data; } }, { // The upload time column targets: 8, visible: true, searchable: false, orderable: true, }, { targets: 9, visible: false, searchable: false, orderable: true, } ], columns: [ {data: "", }, {data: "", }, {data: "fileName", title: "File name"}, {data: "fileSize", title: "File size"}, {data: "fileType", title: "File type"}, {data: "genome", title: "Genome"}, {data: "parentDir", title: "Hubs"}, {data: "lastModified", title: "File Last Modified"}, {data: "uploadTime", title: "Upload Time", name: "uploadTime"}, {data: "fullPath", title: "fullPath", name: "fullPath"}, ], drawCallback: function(settings) { console.log("table draw"); }, rowCallback: function(row, data, displayNum, displayIndex, dataIndex) { // row is a tr element, data is the td values // a row can represent one of three things: // a 'folder', with no parents, but with children // a folder with parents and children (can only come from hubtools // a 'file' with no children, but with parentDir // we assign the appropriate classes which are used later to // collapse/expand and select rows for viewing or deletion if (!data.parentDir) { row.className = "topLevelRow"; } else { row.className = "childRow"; } if (data.fileType === "dir") { row.className += " parentRow"; } indentActionButton(row, data); }, initComplete: function(settings, json) { console.log("data loaded, only showing directories"); let table = new $.fn.dataTable.Api(settings); dataTableShowTopLevel(table); dataTableCustomOrder(table); table.draw(); } }; function showExistingFiles(d) { // Make the DataTable for each file // make buttons have the same style as other buttons $.fn.dataTable.Buttons.defaults.dom.button.className = 'button'; tableInitOptions.data = d; if (uiState.isLoggedIn) { tableInitOptions.language = {emptyTable: "Uploaded files will appear here. Click \"Upload\" to get started"}; } else { tableInitOptions.language = {emptyTable: "You are not logged in, please navigate to \"My Data\" > \"My Sessions\" and log in or create an account to begin uploading files"}; } DataTable.feature.register('quota', function(settings, opts) { let options = Object.assign({option1: false, option2: false}, opts); let container = document.createElement("div"); if (uiState.isLoggedIn) { container.textContent = `Using ${prettyFileSize(uiState.userQuota)} of ${prettyFileSize(uiState.maxQuota)}`; } 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); }); table.on("deselect", function(e, dt, type, indexes) { doRowSelect(e, dt, indexes); }); 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); } // first add the top level directories/files let table = showExistingFiles(uiState.fileList); // TODO: add event handlers for editing defaults, grouping into hub $("#newTrackHubDialog").dialog({ modal: true, autoOpen: false, title: "Create new track hub", closeOnEscape: true, minWidth: 400, minHeight: 120 }); // create a custom uppy plugin to batch change the type and db fields class BatchChangePlugin extends Uppy.BasePlugin { constructor(uppy, opts) { super(uppy, opts); this.id = "BatchChangePlugin"; this.type = "progressindicator"; this.opts = opts; } createOptsForSelect(select, opts) { opts.forEach( (opt) => { let option = document.createElement("option"); option.value = opt.value; option.label = opt.label; option.id = opt.id; option.selected = typeof opt.selected !== 'undefined' ? opt.selected : false; select.appendChild(option); }); } addSelectsForFile(file) { /* create two selects for the file object, to include the db and type */ const id = "uppy_" + file.id; let fileDiv = document.getElementById(id); // this might not exist yet depending on where we are in the render cycle if (fileDiv) { let dbSelectId = "db_select_" + file.id; if (!document.getElementById(dbSelectId)) { let dbSelect = document.createElement("select"); dbSelect.id = dbSelectId; let dbOpts = makeGenomeSelectOptions(); this.createOptsForSelect(dbSelect, dbOpts); fileDiv.appendChild(dbSelect); } } } removeBatchSelectsFromDashboard() { let batchSelectDiv = document.getElementById("batch-selector-div"); if (batchSelectDiv) { batchSelectDiv.remove(); } } addBatchSelectsToDashboard() { if (!document.getElementById("batch-selector-div")) { let batchSelectDiv = document.createElement("div"); batchSelectDiv.id = "batch-selector-div"; batchSelectDiv.style.display = "grid"; batchSelectDiv.style.width = "80%"; // the grid syntax is 2 columns, 3 rows batchSelectDiv.style.gridTemplateColumns = "50% 50%"; batchSelectDiv.style.gridTemplateRows = "25px 25px 25px"; batchSelectDiv.style.margin = "10px auto"; // centers this div if (window.matchMedia("(prefers-color-scheme: dark)").matches) { batchSelectDiv.style.color = "#eaeaea"; } // first just explanatory text: let batchSelectText = document.createElement("div"); batchSelectText.textContent = "Change options for all files:"; // syntax here is rowStart / columnStart / rowEnd / columnEnd batchSelectText.style.gridArea = "1 / 1 / 1 / 2"; // the batch change db select let batchDbSelect = document.createElement("select"); this.createOptsForSelect(batchDbSelect, makeGenomeSelectOptions()); batchDbSelect.id = "batchDbSelect"; batchDbSelect.style.gridArea = "2 / 2 / 2 / 2"; batchDbSelect.style.margin = "1px 1px auto"; let batchDbLabel = document.createElement("label"); batchDbLabel.textContent = "Genome"; batchDbLabel.for = "batchDbSelect"; batchDbLabel.style.gridArea = "2 / 1 / 2 / 1"; // the batch change hub name let batchParentDirInput = document.createElement("input"); batchParentDirInput.id = "batchParentDir"; batchParentDirInput.value = uiState.hubNameDefault; batchParentDirInput.style.gridArea = "3 / 2 / 3 / 2"; batchParentDirInput.style.margin= "1px 1px auto"; let batchParentDirLabel = document.createElement("label"); batchParentDirLabel.textContent = "Hub Name"; batchParentDirLabel.for = "batchParentDir"; batchParentDirLabel.style.gridArea = "3 / 1 / 3 / 1"; // add event handlers to change metadata, use an arrow function // because otherwise 'this' keyword will be the element instead of // our class batchDbSelect.addEventListener("change", (ev) => { let files = this.uppy.getFiles(); let val = ev.target.value; for (let [key, file] of Object.entries(files)) { this.uppy.setFileMeta(file.id, {genome: val}); } }); batchParentDirInput.addEventListener("change", (ev) => { let files = this.uppy.getFiles(); let val = ev.target.value; for (let [key, file] of Object.entries(files)) { this.uppy.setFileMeta(file.id, {parentDir: val}); } }); batchSelectDiv.appendChild(batchSelectText); batchSelectDiv.appendChild(batchDbLabel); batchSelectDiv.appendChild(batchDbSelect); batchSelectDiv.appendChild(batchParentDirLabel); batchSelectDiv.appendChild(batchParentDirInput); // append the batch changes to the bottom of the file list, for some reason // I can't append to the actual Dashboard-files, it must be getting emptied // and re-rendered or something let uppyFilesDiv = document.querySelector(".uppy-Dashboard-progressindicators"); if (uppyFilesDiv) { uppyFilesDiv.insertBefore(batchSelectDiv, uppyFilesDiv.firstChild); } } } install() { this.uppy.on("file-added", (file) => { // add default meta data for genome and fileType console.log("file-added"); this.uppy.setFileMeta(file.id, {"genome": defaultDb(), "fileType": detectFileType(file.name), "parentDir": uiState.hubNameDefault}); if (this.uppy.getFiles().length > 1) { this.addBatchSelectsToDashboard(); } else { // only open the file editor when there is one file const dash = uppy.getPlugin("Dashboard"); dash.toggleFileCard(true, file.id); } }); this.uppy.on("file-removed", (file) => { // remove the batch change selects if now <2 files present if (this.uppy.getFiles().length < 2) { this.removeBatchSelectsFromDashboard(); } }); this.uppy.on("dashboard:modal-open", () => { // check if there were already files chosen from before: if (this.uppy.getFiles().length > 2) { this.addBatchSelectsToDashboard(); } if (this.uppy.getFiles().length < 2) { this.removeBatchSelectsFromDashboard(); } }); this.uppy.on("dashboard:modal-closed", () => { if (this.uppy.getFiles().length < 2) { this.removeBatchSelectsFromDashboard(); } let allFiles = this.uppy.getFiles(); let completeFiles = this.uppy.getFiles().filter((f) => f.progress.uploadComplete === true); if (allFiles.length === completeFiles.length) { this.uppy.clear(); } }); } uninstall() { // not really used because we aren't ever uninstalling the uppy instance this.uppy.off("file-added"); } } let uppyOptions = { trigger: ".uploadButton", showProgressDetails: true, note: "The UCSC Genome Browser is not a HIPAA compliant data store. Do not upload patient information or other sensitive data files here, as anyone with the URL can view them.", meta: {"genome": null, "fileType": null}, restricted: {requiredMetaFields: ["genome"]}, closeModalOnClickOutside: true, closeAfterFinish: true, theme: 'auto', metaFields: (file) => { const fields = [{ id: 'name', name: 'File name', render: ({value, onChange, required, form}, h) => { return h('input', {type: "text", value: value, onChange: e => { onChange(e.target.value); file.meta.fileType = detectFileType(e.target.value); }, required: required, form: form, } ); }, }, { id: 'genome', name: 'Genome', render: ({value, onChange}, h) => { return h('select', { onChange: e => { onChange(e.target.value); file.meta.genome = e.target.value; } }, makeGenomeSelectOptions().map( (genomeObj) => { return h('option', { value: genomeObj.value, label: genomeObj.label, selected: file.meta.genome !== null ? genomeObj.value === file.meta.genome : genomeObj.value === defaultDb() }); }) ); }, }, { id: 'parentDir', name: 'Hub Name', render: ({value, onChange, required, form}, h) => { return h('input', {type: 'text', value: value, onChange: e => { onChange(e.target.value); }, required: required, form: form, } ); }, }]; return fields; }, doneButtonHandler: function() { uppy.clear(); }, }; 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, "fileSize": metadata.fileSize, "fileType": metadata.fileType, "genome": metadata.genome, "parentDir": metadata.parentDir, "lastModified": d.toLocaleString(), "uploadTime": now.toLocaleString(), "fullPath": metadata.parentDir + "/" + 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", }; parentDirObj = { "uploadTime": now.toLocaleString(), "lastModified": d.toLocaleString(), "fileName": metadata.parentDir, "fileSize": 0, "fileType": "dir", "genome": metadata.genome, "parentDir": "", "fullPath": 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, }; }());