7206132fbc62b1d3b563b67aa067ae8d6ab76e2b chmalee Fri Jun 6 15:54:08 2025 -0700 Refactor hubSpace UI a little in order to make getting the initial filelist over async rather than with the CGI load, refs Max discussion diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js index 70a4e3719ff..e0ab03de997 100644 --- src/hg/js/hgMyData.js +++ src/hg/js/hgMyData.js @@ -162,30 +162,141 @@ 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 + +function getTusdEndpoint() { + // this variable is set by hgHubConnect and comes from hg.conf value + return tusdEndpoint; +} + +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, + class: "uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input", + onChange: e => { + onChange(e.target.value); + file.meta.fileType = hubCreate.detectFileType(e.target.value); + file.meta.name = e.target.value; + }, + required: required, + form: form, + } + ); + }, + }, + { + id: 'genome', + name: 'Genome', + render: ({value, onChange}, h) => { + // keep these as a variable so we can init the autocompleteCat + // code only after the elements have actually been rendered + // there are multiple rendering passes and only eventually + // do the elements actually make it into the DOM + let ret = h('div', { + class: "uppy-Dashboard-FileCard-label", + style: "display: inline-block; width: 78%" + }, + // first child of div + "Select from popular assemblies:", + // second div child + h('select', { + id: `${file.meta.name}DbSelect`, + style: "margin-left: 5px", + onChange: e => { + onChange(e.target.value); + file.meta.genome = e.target.value; + file.meta.genomeLabel = e.target.selectedOptions[0].label; + } + }, + hubCreate.makeGenomeSelectOptions(file.meta.genome, file.meta.genomeLabel).map( (genomeObj) => { + return h('option', { + value: genomeObj.value, + label: genomeObj.label, + selected: file.meta.genome !== null ? genomeObj.value === file.meta.genome : genomeObj.value === hubCreate.defaultDb() + }); + }) + ), + h('p', { + class: "uppy-Dashboard-FileCard-label", + style: "display: block; width: 78%", + }, "or search for your genome:"), + // third div child + h('input', { + id: `${file.meta.name}DbInput`, + type: 'text', + class: "uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input", + }), + h('input', { + id: `${file.meta.name}DbSearchButton`, + type: 'button', + value: 'search', + style: "margin-left: 5px", + }) + ); + let selectToChange = document.getElementById(`${file.meta.name}DbSelect`); + if (selectToChange) { + let justInitted = initAutocompleteForInput(`${file.meta.name}DbInput`, selectToChange); + if (justInitted) { + // only do this once per file + document.getElementById(`${file.meta.name}DbSearchButton`) + .addEventListener("click", (e) => { + let inp = document.getElementById(`${file.meta.name}DbInput`).value; + let selector = `[id='${file.meta.name}DbInput']`; + $(selector).autocompleteCat("search", inp); + }); + } + } + return ret; + } + }, + { + id: 'parentDir', + name: 'Hub Name', + }]; + return fields; + }, + doneButtonHandler: function() { + uppy.clear(); + }, +}; + // 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', 5000); doUpload = false; continue; @@ -207,46 +318,265 @@ 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; }, }); +// 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 = hubCreate.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 = "max-content minmax(0, 200px) max-content 1fr min-content"; + batchSelectDiv.style.gridTemplateRows = "repest(3, auto)"; + batchSelectDiv.style.margin = "10px auto"; // centers this div + batchSelectDiv.style.fontSize = "14px"; + batchSelectDiv.style.gap = "8px"; + 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, hubCreate.makeGenomeSelectOptions()); + batchDbSelect.id = "batchDbSelect"; + batchDbSelect.style.gridArea = "2 / 2 / 2 / 2"; + batchDbSelect.style.margin = "2px"; + let batchDbLabel = document.createElement("label"); + batchDbLabel.textContent = "Genome"; + batchDbLabel.for = "batchDbSelect"; + batchDbLabel.style.gridArea = "2 / 1 / 2 / 1"; + + // the search bar for db selection + let batchDbSearchBarLabel= document.createElement("label"); + batchDbSearchBarLabel.textContent = "or search for your genome:"; + batchDbSearchBarLabel.style.gridArea = "2 / 3 /2 / 3"; + batchDbSearchBarLabel.style.margin = "auto"; + + let batchDbGenomeSearchBar = document.createElement("input"); + batchDbGenomeSearchBar.classList.add("uppy-u-reset", "uppy-c-textInput"); + batchDbGenomeSearchBar.type = "text"; + batchDbGenomeSearchBar.id = "batchDbSearchBar"; + batchDbGenomeSearchBar.style.gridArea = "2 / 4 / 2 / 4"; + let batchDbGenomeSearchButton = document.createElement("input"); + batchDbGenomeSearchButton.type = "button"; + batchDbGenomeSearchButton.value = "search"; + batchDbGenomeSearchButton.id = "batchDbSearchBarButton"; + batchDbGenomeSearchButton.style.gridArea = "2 / 5 / 2 / 5"; + + // the batch change hub name + let batchParentDirLabel = document.createElement("label"); + batchParentDirLabel.textContent = "Hub Name"; + batchParentDirLabel.for = "batchParentDir"; + batchParentDirLabel.style.gridArea = "3 / 1 / 3 / 1"; + + let batchParentDirInput = document.createElement("input"); + batchParentDirInput.id = "batchParentDir"; + batchParentDirInput.value = hubCreate.uiState.hubNameDefault; + batchParentDirInput.style.gridArea = "3 / 2 / 3 / 2"; + batchParentDirInput.style.margin= "1px 1px auto"; + batchParentDirInput.classList.add("uppy-u-reset", "uppy-c-textInput"); + + // 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}); + this.uppy.setFileMeta(file.id, {genomeLabel: ev.target.selectedOptions[0].label}); + } + }); + 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(batchDbSearchBarLabel); + batchSelectDiv.appendChild(batchDbGenomeSearchBar); + batchSelectDiv.appendChild(batchDbGenomeSearchButton); + 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); + } + + // everything has to exist already for autocompleteCat to initialize + let justInitted = initAutocompleteForInput(batchDbGenomeSearchBar.id, batchDbSelect); + if (justInitted) { + // only do this once per batch setup + batchDbGenomeSearchButton.addEventListener("click", (e) => { + let inp = document.getElementById(batchDbSearchBar.id).value; + let selector = "[id='"+batchDbGenomeSearchBar.id+"']"; + $(selector).autocompleteCat("search", inp); + }); + } + } + } + + 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": hubCreate.defaultDb(), "fileType": hubCreate.detectFileType(file.name), "parentDir": hubCreate.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(); + } + }); + this.uppy.on("dashboard:file-edit-start", (file) => { + autocompletes[`${file.name}DbInput`] = false; + }); + + this.uppy.on("dashboard:file-edit-complete", (file) => { + // check the filename and hubname metadata and warn the user + // to edit them if they are wrong. unfortunately I cannot + // figure out how to force the file card to re-toggle + // and jump back into the editor from here + if (file) { + let fileNameMatch = file.meta.name.match(fileNameRegex); + let parentDirMatch = file.meta.parentDir.match(parentDirRegex); + const dash = uppy.getPlugin("Dashboard"); + 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', 5000); + } + if (!parentDirMatch || parentDirMatch[0] !== file.meta.parentDir) { + uppy.info(`Error: Hub name has special characters, please rename hub: '${file.meta.parentDir}' to only include alpha-numeric characters, period, dash, underscore, or plus.`, 'error', 5000); + } + } + }); + } + uninstall() { + // not really used because we aren't ever uninstalling the uppy instance + this.uppy.off("file-added"); + } +} + 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"], @@ -911,484 +1241,143 @@ } ], 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) { - indexes.forEach(function(i) { - doRowSelect(e.type, dt, i); - }); - }); - table.on("deselect", function(e, dt, type, indexes) { - indexes.forEach(function(i) { - doRowSelect(e.type, dt, i); - }); - }); - 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); - if (uiState.fileList) { - parseFileListIntoHash(uiState.fileList); - } - } - // first add the top level directories/files - let table = showExistingFiles(uiState.fileList); - table.columns.adjust().draw(); - // 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 = "max-content minmax(0, 200px) max-content 1fr min-content"; - batchSelectDiv.style.gridTemplateRows = "repest(3, auto)"; - batchSelectDiv.style.margin = "10px auto"; // centers this div - batchSelectDiv.style.fontSize = "14px"; - batchSelectDiv.style.gap = "8px"; - 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 = "2px"; - let batchDbLabel = document.createElement("label"); - batchDbLabel.textContent = "Genome"; - batchDbLabel.for = "batchDbSelect"; - batchDbLabel.style.gridArea = "2 / 1 / 2 / 1"; - - // the search bar for db selection - let batchDbSearchBarLabel= document.createElement("label"); - batchDbSearchBarLabel.textContent = "or search for your genome:"; - batchDbSearchBarLabel.style.gridArea = "2 / 3 /2 / 3"; - batchDbSearchBarLabel.style.margin = "auto"; - - let batchDbGenomeSearchBar = document.createElement("input"); - batchDbGenomeSearchBar.classList.add("uppy-u-reset", "uppy-c-textInput"); - batchDbGenomeSearchBar.type = "text"; - batchDbGenomeSearchBar.id = "batchDbSearchBar"; - batchDbGenomeSearchBar.style.gridArea = "2 / 4 / 2 / 4"; - let batchDbGenomeSearchButton = document.createElement("input"); - batchDbGenomeSearchButton.type = "button"; - batchDbGenomeSearchButton.value = "search"; - batchDbGenomeSearchButton.id = "batchDbSearchBarButton"; - batchDbGenomeSearchButton.style.gridArea = "2 / 5 / 2 / 5"; - - // the batch change hub name - let batchParentDirLabel = document.createElement("label"); - batchParentDirLabel.textContent = "Hub Name"; - batchParentDirLabel.for = "batchParentDir"; - batchParentDirLabel.style.gridArea = "3 / 1 / 3 / 1"; - - 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"; - batchParentDirInput.classList.add("uppy-u-reset", "uppy-c-textInput"); - - // 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}); - this.uppy.setFileMeta(file.id, {genomeLabel: ev.target.selectedOptions[0].label}); - } - }); - 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(batchDbSearchBarLabel); - batchSelectDiv.appendChild(batchDbGenomeSearchBar); - batchSelectDiv.appendChild(batchDbGenomeSearchButton); - 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); - } - - // everything has to exist already for autocompleteCat to initialize - let justInitted = initAutocompleteForInput(batchDbGenomeSearchBar.id, batchDbSelect); - if (justInitted) { - // only do this once per batch setup - batchDbGenomeSearchButton.addEventListener("click", (e) => { - let inp = document.getElementById(batchDbSearchBar.id).value; - let selector = "[id='"+batchDbGenomeSearchBar.id+"']"; - $(selector).autocompleteCat("search", inp); - }); + 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(); } + }; - 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(); + 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 { - // only open the file editor when there is one file - const dash = uppy.getPlugin("Dashboard"); - dash.toggleFileCard(true, file.id); + 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"}; } - }); - this.uppy.on("file-removed", (file) => { - // remove the batch change selects if now <2 files present - if (this.uppy.getFiles().length < 2) { - this.removeBatchSelectsFromDashboard(); + 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; }); - - 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(); - } + 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(); }); - 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(); + } else { + table.buttons(".uploadButton").disable(); } + table.on("select", function(e, dt, type, indexes) { + indexes.forEach(function(i) { + doRowSelect(e.type, dt, i); }); - this.uppy.on("dashboard:file-edit-start", (file) => { - autocompletes[`${file.name}DbInput`] = false; }); - - this.uppy.on("dashboard:file-edit-complete", (file) => { - // check the filename and hubname metadata and warn the user - // to edit them if they are wrong. unfortunately I cannot - // figure out how to force the file card to re-toggle - // and jump back into the editor from here - if (file) { - let fileNameMatch = file.meta.name.match(fileNameRegex); - let parentDirMatch = file.meta.parentDir.match(parentDirRegex); - const dash = uppy.getPlugin("Dashboard"); - 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', 5000); - } - if (!parentDirMatch || parentDirMatch[0] !== file.meta.parentDir) { - uppy.info(`Error: Hub name has special characters, please rename hub: '${file.meta.parentDir}' to only include alpha-numeric characters, period, dash, underscore, or plus.`, 'error', 5000); - } - } + table.on("deselect", function(e, dt, type, indexes) { + indexes.forEach(function(i) { + doRowSelect(e.type, dt, i); }); + }); + 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()); } - 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, - class: "uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input", - onChange: e => { - onChange(e.target.value); - file.meta.fileType = detectFileType(e.target.value); - file.meta.name = e.target.value; - }, - required: required, - form: form, } - ); - }, - }, - { - id: 'genome', - name: 'Genome', - render: ({value, onChange}, h) => { - // keep these as a variable so we can init the autocompleteCat - // code only after the elements have actually been rendered - // there are multiple rendering passes and only eventually - // do the elements actually make it into the DOM - let ret = h('div', { - class: "uppy-Dashboard-FileCard-label", - style: "display: inline-block; width: 78%" - }, - // first child of div - "Select from popular assemblies:", - // second div child - h('select', { - id: `${file.meta.name}DbSelect`, - style: "margin-left: 5px", - onChange: e => { - onChange(e.target.value); - file.meta.genome = e.target.value; - file.meta.genomeLabel = e.target.selectedOptions[0].label; } - }, - makeGenomeSelectOptions(file.meta.genome, file.meta.genomeLabel).map( (genomeObj) => { - return h('option', { - value: genomeObj.value, - label: genomeObj.label, - selected: file.meta.genome !== null ? genomeObj.value === file.meta.genome : genomeObj.value === defaultDb() - }); - }) - ), - h('p', { - class: "uppy-Dashboard-FileCard-label", - style: "display: block; width: 78%", - }, "or search for your genome:"), - // third div child - h('input', { - id: `${file.meta.name}DbInput`, - type: 'text', - class: "uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input", - }), - h('input', { - id: `${file.meta.name}DbSearchButton`, - type: 'button', - value: 'search', - style: "margin-left: 5px", - }) - ); - let selectToChange = document.getElementById(`${file.meta.name}DbSelect`); - if (selectToChange) { - let justInitted = initAutocompleteForInput(`${file.meta.name}DbInput`, selectToChange); - if (justInitted) { - // only do this once per file - document.getElementById(`${file.meta.name}DbSearchButton`) - .addEventListener("click", (e) => { - let inp = document.getElementById(`${file.meta.name}DbInput`).value; - let selector = `[id='${file.meta.name}DbInput']`; - $(selector).autocompleteCat("search", inp); }); + return table; } + + function handleGetFileList(jsonData, textStatus) { + _.assign(uiState, jsonData.userFiles); + if (uiState.fileList) { + parseFileListIntoHash(uiState.fileList); } - return ret; - } - }, - { - id: 'parentDir', - name: 'Hub Name', - }]; - return fields; - }, - doneButtonHandler: function() { - uppy.clear(); - }, - }; + + // first add the top level directories/files + let table = showExistingFiles(uiState.fileList); + table.columns.adjust().draw(); + uppy.use(Uppy.Dashboard, uppyOptions); + + // define this in init so globals are available at runtime 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 pad = (num) => String(num).padStart(2, '0'); const dFormatted = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; const now = new Date(Date.now()); const nowFormatted = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; let newReqObj, hubTxtObj, parentDirObj; newReqObj = { "fileName": cgiEncode(metadata.fileName), "fileSize": metadata.fileSize, "fileType": metadata.fileType, "genome": metadata.genome, @@ -1424,19 +1413,60 @@ "parentDir": "", "fullPath": cgiEncode(metadata.parentDir), }; // package the three objects together as one "hub" and display it let hub = [parentDirObj, newReqObj]; if (hubTxtObj) { hub.push(hubTxtObj); } addNewUploadedHubToTable(hub); }); uppy.on('complete', (result) => { history.replaceState(uiState, "", document.location.href); console.log("replace history with uiState"); }); } + + function checkJsonData(jsonData, callerName) { + // Return true if jsonData isn't empty and doesn't contain an error; + // otherwise complain on behalf of caller. + if (! jsonData) { + alert(callerName + ': empty response from server'); + } else if (jsonData.error) { + console.error(jsonData.error); + alert(callerName + ': error from server: ' + jsonData.error); + } else if (jsonData.warning) { + alert("Warning: " + jsonData.warning); + return true; + } else { + if (debugCartJson) { + console.log('from server:\n', jsonData); + } + return true; + } + return false; + } + + function handleRefreshState(jsonData, textStatus) { + if (checkJsonData(jsonData, 'handleRefreshState')) { + handleGetFileList(jsonData, true); + } + } + + function handleErrorState(jqXHR, textStatus) { + cart.defaultErrorCallback(jqXHR, textStatus); + } + + function init() { + cart.setCgi('hgHubConnect'); + cart.debug(debugCartJson); + // get the file list immediately upon page load + cart.send({ getHubSpaceUIState: {}}, handleRefreshState, handleErrorState); + cart.flush(); + } return { init: init, uiState: uiState, + defaultDb: defaultDb, + makeGenomeSelectOptions: makeGenomeSelectOptions, + detectFileType: detectFileType, }; }());