2dfd61094ec3e548f25f56e83379f85ec22cfab5 chmalee Thu Oct 31 16:48:26 2024 -0700 Start using Uppy instead of my own UI diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js index a9ad6f1..0e0b485 100644 --- src/hg/js/hgMyData.js +++ src/hg/js/hgMyData.js @@ -1,29 +1,47 @@ /* jshint esnext: true */ -debugCartJson = true; +var debugCartJson = true; function prettyFileSize(num) { if (!num) {return "n/a";} if (num < (1000 * 1024)) { return `${(num/1000).toFixed(1)}kb`; } else if (num < (1000 * 1000 * 1024)) { return `${((num/1000)/1000).toFixed(1)}mb`; } else { return `${(((num/1000)/1000)/1000).toFixed(1)}gb`; } } +// make our Uppy instance: +const uppy = new Uppy.Uppy({ + debug: true, + onBeforeUpload: (files) => { + // set all the fileTypes and genomes from their selects + for (let [key, file] of Object.entries(files)) { + if (!file.meta.genome || !file.meta.fileType) { + uppy.getPlugin("Dashboard").info("error!"); + } + uppy.setFileMeta(file.id, { + fileName: file.name, + fileSize: file.size, + lastModified: file.data.lastModified, + }); + } + }, +}); + var hubCreate = (function() { let uiState = { // our object for keeping track of the current UI and what to do toUpload: {}, // set of file objects keyed by name input: null, // the hidden input element pickedList: null, // the <div> for displaying files in toUpload pendingQueue: [], // our queue of pending [tus.Upload, file], kind of like the toUpload object fileList: [], // the files this user has uploaded, initially populated by the server // on page load, but gets updated as the user uploades/deletes files hubList: [], // the hubs this user has created/uploaded, initially populated by server // on page load, but gets updated as the user creates/deletes hubs userUrl: "", // the web accesible path where the uploads are stored for this user }; // We can use XMLHttpRequest if necessary or a mirror can't use tus var useTus = tus.isSupported && true; @@ -151,48 +169,44 @@ "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"], }; function detectFileType(fileName) { - let selectedType = document.getElementById(`${file.name}#typeInput`).selectedOptions[0].value; - if (selectedType === "Auto-detect from extension") { let fileLower = fileName.toLowerCase(); for (let fileType in extensionMap) { for (let extIx in extensionMap[fileType]) { let ext = extensionMap[fileType][extIx]; if (fileLower.endsWith(ext)) { return fileType; } } } //TODO: raise an error alert(`file extension for ${fileName} not found, please explicitly select it`); - } else { - return selectedType; - } } + /* function submitPickedFiles() { let tusdServer = getTusdEndpoint(); let onBeforeRequest = function(req) { let xhr = req.getUnderlyingObject(req); xhr.withCredentials = true; }; let onSuccess = function(req, metadata) { // remove the selected file from the input element and the ul list // FileList is a read only setting, so we have to make // a new one without this req delete uiState.toUpload[req.name]; let i, newReqObj = {}; @@ -214,41 +228,30 @@ // first remove the grid headers let headerEle = document.querySelectorAll(".fileListHeader"); headerEle.forEach( (header) => { if (header.style.display !== "none") { header.style.display = "none"; } }); //check if we can remove the batch change selects if (uiState.pendingQueue.length > 1) { document.querySelectorAll("[id^=batchChangeSelect]").forEach( (select) => { select.remove(); }); } $("#filePickerModal").dialog("close"); } - const d = new Date(req.lastModified); - newReqObj = { - "uploadTime": Date.now(), - "lastModified": d.toJSON(), - "fileName": metadata.fileName, - "fileSize": metadata.fileSize, - "fileType": metadata.fileType, - "genome": metadata.genome, - "hub": "" - }; - addNewUploadedFileToTable(newReqObj); }; let onError = function(metadata, err) { console.log("failing metadata:"); console.log(metadata); removeCancelAllButton(); if (err.originalResponse !== null) { alert(err.originalResponse._xhr.responseText); } else { alert(err); } }; let onProgress = function(bytesSent, bytesTotal) { this.updateProgress((bytesSent / bytesTotal) * 100); @@ -276,134 +279,131 @@ }; // TODO: get the uploadUrl from the tusd server // use a pre-create hook to validate the user // and get an uploadUrl let tusUpload = new tus.Upload(file, tusOptions); uiState.pendingQueue.push([tusUpload, file]); tusUpload.start(); } else { // make a new XMLHttpRequest for each file, if tusd-tusclient not supported new sendFile(file); } } addCancelAllButton(); return; } + */ + /* function clearPickedFiles() { while (uiState.pickedList.firstChild) { uiState.pickedList.removeChild(uiState.pickedList.firstChild); } - uiState.input = createInput(); - uiState.toUpload = {}; - } - - function makeGenomeSelect(fileName) { - let genomeInp = document.createElement("select"); - genomeInp.classList.add("genomePicker"); - genomeInp.name = `${fileName}#genomeInput`; - genomeInp.id = `${fileName}#genomeInput`; - let labelChoice = document.createElement("option"); - labelChoice.label = "Choose Genome"; - labelChoice.value = "Choose Genome"; - labelChoice.selected = true; - labelChoice.disabled = true; - genomeInp.appendChild(labelChoice); + //uiState.input = createInput(); + //uiState.toUpload = {}; + } + */ + + function defaultFileType(file) { + return detectFileType(file); + } + + 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 = document.createElement("option"); + let cartChoice = {}; cartChoice.id = cartDb; cartChoice.label = cartDb; - cartChoice.value = cartDb.split(" ").slice(-1); + 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; - genomeInp.appendChild(cartChoice); + ret.push(cartChoice); choices.forEach( (e) => { if (e === cartDb) {return;} // don't print the cart database twice - let choice = document.createElement("option"); + let choice = {}; choice.id = e; choice.label = e; choice.value = e.split(" ")[1]; - genomeInp.appendChild(choice); - }); - return genomeInp; - } - - function makeTypeSelect(fileName) { - let typeInp = document.createElement("select"); - typeInp.classList.add("typePicker"); - typeInp.name = `${fileName}#typeInput`; - typeInp.id = `${fileName}#typeInput`; - let labelChoice = document.createElement("option"); - labelChoice.label = "Choose File Type"; - labelChoice.value = "Choose File Type"; - labelChoice.disabled = true; - typeInp.appendChild(labelChoice); - let autoChoice = document.createElement("option"); + 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; - typeInp.appendChild(autoChoice); + 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 = document.createElement("option"); + let choice = {}; choice.id = e; choice.label = e; choice.value = e; - typeInp.appendChild(choice); + ret.push(choice); }); - return typeInp; + return ret; } function createTypeAndDbDropdown(fileName) { typeInp = makeTypeSelect(fileName); - genomeInp = makeGenomeSelect(fileName); + genomeInp = makeGenomeSelectOptions(fileName); return [typeInp, genomeInp]; } + /* function deletePickedFile(eventInst) { // called when the trash icon has been clicked to remove a file // the sibling text content is the file name, which we use to // find all the other elements to delete let trashIconDiv = eventInst.currentTarget; fname = trashIconDiv.nextSibling.textContent; document.querySelectorAll("[id^='" + fname + "']").forEach( (sib) => { sib.remove(); }); trashIconDiv.remove(); delete uiState.toUpload[fname]; // if there are no file rows left (1 row for the header) in the picker hide the headers: let container = document.getElementById("fileList"); if (getComputedStyle(container).getPropertyValue("grid-template-rows").split(" ").length === 1) { let headerEle = document.querySelectorAll(".fileListHeader"); headerEle.forEach( (header) => { if (header.style.display !== "none") { header.style.display = "none"; } }); } // if there is only one file picked hide the batch change selects, we may or may not have // hidden the headers yet, so we should have 3 or less rows if (getComputedStyle(container).getPropertyValue("grid-template-rows").split(" ").length <= 3) { let batchChange = document.querySelectorAll("[id^=batchChangeSelect]"); batchChange.forEach( (select) => { select.remove(); }); } } + */ function listPickedFiles() { // displays the users chosen files in a grid: if (uiState.input.files.length === 0) { console.log("not input"); return; } else { let displayList = document.getElementById("fileList"); let deleteEle = document.createElement("div"); deleteEle.classList.add("deleteFileIcon"); deleteEle.innerHTML = "<svg xmlns='http://www.w3.org/2000/svg' height='0.8em' viewBox='0 0 448 512'><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d='M135.2 17.7C140.6 6.8 151.7 0 163.8 0H284.2c12.1 0 23.2 6.8 28.6 17.7L320 32h96c17.7 0 32 14.3 32 32s-14.3 32-32 32H32C14.3 96 0 81.7 0 64S14.3 32 32 32h96l7.2-14.3zM32 128H416V448c0 35.3-28.7 64-64 64H96c-35.3 0-64-28.7-64-64V128zm96 64c-8.8 0-16 7.2-16 16V432c0 8.8 7.2 16 16 16s16-7.2 16-16V208c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16V432c0 8.8 7.2 16 16 16s16-7.2 16-16V208c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16V432c0 8.8 7.2 16 16 16s16-7.2 16-16V208c0-8.8-7.2-16-16-16z'/></svg>"; for (let file of uiState.input.files ) { if (file.name in uiState.toUpload) { continue; } @@ -429,31 +429,31 @@ // finally add it for us uiState.toUpload[file.name] = file; } let headerEle = document.querySelectorAll(".fileListHeader"); headerEle.forEach( (header) => { if (header.style.display !== "block") { header.style.display = "block"; } }); if (Object.keys(uiState.toUpload).length > 1) { // put up inputs to batch change all file inputs and dbs let batchType = document.createElement("div"); batchType = makeTypeSelect("batchChangeSelectType"); let batchDb = document.createElement("div"); - batchDb = makeGenomeSelect("batchChangeSelectDb"); + batchDb = makeGenomeSelectOptions("batchChangeSelectDb"); // place into the grid in the right spot: batchType.classList.add('batchTypeSelect'); batchDb.classList.add('batchDbSelect'); // update each files select on change batchType.addEventListener("change", function(e) { let newVal = e.currentTarget.selectedOptions[0].value; document.querySelectorAll("[id$=typeInput]").forEach( (i) => { if (i === e.currentTarget) { return; } i.value = newVal; }); }); @@ -560,86 +560,87 @@ fileType: "hub", fileName: jqXhr.hubName, genome: jqXhr.db, fileSize: null, hub: jqXhr.hubName }); } function createHub(db, hubName) { // send a request to hgHubConnect to create a hub for this user cart.setCgi("hgHubConnect"); cart.send({createHub: {db: db, name: hubName}}, createHubSuccess, null); cart.flush(); } + /* function startUploadDialog() { // put up a dialog to walk a user through uploading data files and setting up a track hub console.log("create a hub button clicked!"); hubUploadButtons = { "Start": function() { submitPickedFiles(); let currBtns = $("#filePickerModal").dialog("option", "buttons"); // add a cancel button to stop current uploads if (!("Cancel" in currBtns)) { currBtns.Cancel = function() { clearPickedFiles(); $(this).dialog("close"); }; $("#filePickerModal").dialog("option", "buttons", currBtns); } }, - /* "Cancel": function() { clearPickedFiles(); $(this).dialog("close"); }, - */ "Close": function() { // delete everything that isn't the headers, which we set to hide: let fileList = document.getElementById("fileList"); let headers = document.querySelectorAll(".fileListHeader"); fileList.replaceChildren(...headers); headers.forEach( (header) => { header.style.display = "none"; }); uiState.input = createInput(); $(this).dialog("close"); } }; $("#filePickerModal").dialog({ modal: true, - buttons: hubUploadButtons, + //buttons: hubUploadButtons, minWidth: $("#filePickerModal").width(), width: (window.innerWidth * 0.8), height: (window.innerHeight * 0.55), title: "Upload track data", open: function(e, ui) { $(e.target).parent().css("position", "fixed"); $(e.target).parent().css("top", "10%"); }, }); $("#filePickerModal").dialog("open"); } + */ let tableInitOptions = { layout: { topStart: { buttons: [ { text: 'Upload', - action: startUploadDialog, + action: function() {return;}, + className: 'uploadButton', enabled: false, // disable by default in case user is not logged in }, ] } }, columnDefs: [ { orderable: false, targets: 0, title: "<input type=\"checkbox\"></input>", render: function(data, type, row) { return "<input type=\"checkbox\"></input>"; } }, { orderable: false, targets: 1, @@ -799,73 +800,258 @@ } let pickedFiles = document.getElementById("fileList"); let inputBtn = document.getElementById("btnForInput"); if (pickedFiles !== null) { // this element should be an empty div upon loading the page uiState.pickedList = pickedFiles; if (pickedFiles.children.length === 0) { let para = document.createElement("p"); para.textContent = "No files chosen yet"; para.classList.add("noFiles"); pickedFiles.parentNode.appendChild(para); } } else { // TODO: graceful handle of leaving the page and coming back? } + /* let parent = document.getElementById("chooseAndSendFilesRow"); let input = createInput(); uiState.input = input; inputBtn.parentNode.appendChild(input); + */ if (typeof cartJson !== "undefined") { if (typeof cartJson.warning !== "undefined") { alert("Warning: " + cartJson.warning); } var urlParts = {}; if (debugCartJson) { console.log('from server:\n', cartJson); } _.assign(uiState,cartJson); saveHistory(cartJson, urlParts, true); } else { // no cartJson object means we are coming to the page for the first time: //cart.send({ getUiState: {} }, handleRefreshState); //cart.flush(); // TODO: initialize buttons, check if there are already files // TODO: write functions for // after picking files // choosing file types // creating default trackDbs // editing trackDbs let fileDiv = document.getElementById('filesDiv'); if (typeof userFiles !== 'undefined' && Object.keys(userFiles).length > 0) { uiState.fileList = userFiles.fileList; uiState.hubList = userFiles.hubList; uiState.userUrl = userFiles.userUrl; } showExistingFiles(uiState.fileList.filter((row) => row.fileType !== "hub")); - inputBtn.addEventListener("click", (e) => uiState.input.click()); + //inputBtn.addEventListener("click", (e) => uiState.input.click()); // TODO: add event handlers for editing defaults, grouping into hub // TODO: display quota somewhere } $("#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); + } + let typeSelectId = "type_select_" + file.id; + if (!document.getElementById(typeSelectId)) { + let typeSelect = document.createElement("select"); + typeSelect.id = typeSelectId; + let typeOpts = makeTypeSelectOptions(); + this.createOptsForSelect(typeSelect, typeOpts); + fileDiv.appendChild(typeSelect); + } + } + } + + 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"; + let batchDbSelect = document.createElement("select"); + let batchTypeSelect = document.createElement("select"); + this.createOptsForSelect(batchDbSelect, [{id: "batchChangeDb", name: "batchChangeDb"}]); + this.createOptsForSelect(batchTypeSelect, [{id: "batchChangeType", name: "batchChangeType"}]); + batchSelectDiv.textContent = "Change options for all files"; + batchSelectDiv.appendChild(batchDbSelect); + batchSelectDiv.appendChild(batchTypeSelect); + batchSelectDiv.style.display = "flex"; + batchSelectDiv.style.justifyContent = "center"; + let titleBarText = document.querySelector(".uppy-DashboardContent-title"); + if (titleBarText) { + batchSelectDiv.style.color = getComputedStyle(titleBarText).color; + } + // 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": defaultFileType(file.name)}); + if (this.uppy.getFiles().length > 1) { + this.addBatchSelectsToDashboard() + } + }); + 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-close", () => { + if (this.uppy.getFiles().length < 2) { + this.removeBatchSelectsFromDashboard(); + } + }); + } + uninstall() { + // not really used because we aren't ever uninstalling the uppy instance + this.uppy.off("file-added"); + } + } + let uppyOptions = { + //target: "#filePickerModal", // this seems nice but then the jquery css interferes with + // the uppy css + trigger: ".uploadButton", + showProgressDetails: true, + note: "Example text in the note field", + meta: {"genome": null, "fileType": null}, + metaFields: (file) => { + const fields = [{id: 'name', name: 'File name'}]; + fields.push({ + id: 'genome', + name: 'Genome', + render: ({value, onChange}, h) => { + return h('select', + {onChange: e => onChange(e.target.value)}, + makeGenomeSelectOptions().map( (genomeObj) => { + return h('option', genomeObj, genomeObj.label); + }) + ); + }, + }); + fields.push({ + id: 'fileType', + name: 'File Type', + render: ({value, onChange}, h) => { + return h( 'select', + {onChange: e => { + if (e.target.value === "Auto-detect from extension") { + onChange(detectFileType(file.name)); + } else { + onChange(e.target.value); + } + }}, + makeTypeSelectOptions().map( (typeObj) => { + return h('option', typeObj, typeObj.label); + }) + ); + }, + }); + return fields; + }, + restricted: {requiredMetaFields: ["genome", "fileType"]}, + closeModalOnClickOutside: true, + closeAfterFinish: true, + theme: 'auto', + }; + 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); + newReqObj = { + "uploadTime": Date.now(), + "lastModified": d.toJSON(), + "fileName": metadata.fileName, + "fileSize": metadata.fileSize, + "fileType": metadata.fileType, + "genome": metadata.genome, + "hub": "" + }; + addNewUploadedFileToTable(newReqObj); + }); } return { init: init, uiState: uiState, }; - }()); + // when a user reaches this page from the back button we can display our saved state // instead of sending another network request window.onpopstate = function(event) { event.preventDefault(); hubCreate.updateStateAndPage(event.state, false); };