From 089f650fc4ab6613f0822ab277558f5f021e59c8 Mon Sep 17 00:00:00 2001 From: adkinsrs Date: Mon, 20 Nov 2023 14:03:52 -0500 Subject: [PATCH] initial commit of new gene collection manager --- www/cgi/search_gene_carts.cgi | 64 +- www/css/gear-theme-purple.scss | 1 + www/css/gene_collection_manager.css | 14 + www/gene_collection_manager.html | 353 +++++++ www/js/common.v2.js | 117 ++- www/js/gene_collection_manager.js | 1361 +++++++++++++++++++++++++++ 6 files changed, 1888 insertions(+), 22 deletions(-) create mode 100644 www/css/gene_collection_manager.css create mode 100644 www/gene_collection_manager.html create mode 100644 www/js/gene_collection_manager.js diff --git a/www/cgi/search_gene_carts.cgi b/www/cgi/search_gene_carts.cgi index 79452729..9d2e9870 100755 --- a/www/cgi/search_gene_carts.cgi +++ b/www/cgi/search_gene_carts.cgi @@ -9,19 +9,20 @@ import cgi import json import os, sys import re +from math import ceil lib_path = os.path.abspath(os.path.join('..', '..', 'lib')) sys.path.append(lib_path) import geardb # limits the number of matches returned -DEFAULT_MAX_RESULTS = 200; +DEFAULT_MAX_RESULTS = 200 DEBUG_MODE = False def main(): cnx = geardb.Connection() cursor = cnx.get_cursor() - + form = cgi.FieldStorage() session_id = form.getvalue('session_id') custom_list = form.getvalue('custom_list') @@ -29,10 +30,25 @@ def main(): organism_ids = form.getvalue('organism_ids') date_added = form.getvalue('date_added') ownership = form.getvalue('ownership') + page = form.getvalue('page') # page starts at 1 + limit = form.getvalue('limit') sort_by = re.sub("[^[a-z]]", "", form.getvalue('sort_by')) user = geardb.get_user_from_session_id(session_id) if session_id else None result = {'success': 0, 'problem': '', 'gene_carts': []} + if page and not page.isdigit(): + raise ValueError("Page must be a number") + + if page and int(page) < 1: + raise ValueError("Page must be greater than 0") + + if limit and not limit.isdigit(): + raise ValueError("Limit must be a number") + + if limit and int(limit) < 1: + raise ValueError("Limit must be greater than 0") + + gene_carts = list() qry_params = [] @@ -69,7 +85,7 @@ def main(): else: wheres.append("AND (gc.is_public = 1 OR gc.user_id = %s)") qry_params.extend([user.id]) - + if search_terms: selects.append(' MATCH(gc.label, gc.ldesc) AGAINST("%s" IN BOOLEAN MODE) as rscore') wheres.append(' AND MATCH(gc.label, gc.ldesc) AGAINST("%s" IN BOOLEAN MODE)') @@ -100,12 +116,12 @@ def main(): orders_by.append(" g.user_name") else: orders_by.append(" gc.date_added DESC") - + # build query qry = """ SELECT {0} - FROM {1} - WHERE {2} + FROM {1} + WHERE {2} ORDER BY {3} """.format( ", ".join(selects), @@ -114,12 +130,21 @@ def main(): " ".join(orders_by) ) + # if a limit is defined, add it to the query + if int(limit): + qry += " LIMIT {0}".format(limit) + + # if a page is defined, add it to the query + if int(page): + offset = int(page) - 1 + qry += " OFFSET {0}".format(offset * int(limit)) + if DEBUG_MODE: ofh = open('/tmp/debug', 'wt') ofh.write("QRY:\n{0}\n".format(qry)) ofh.write("QRY_params:\n{0}\n".format(qry_params)) ofh.close() - + cursor.execute(qry, qry_params) for row in cursor: @@ -130,6 +155,31 @@ def main(): gc.organism = "{0} {1}".format(row[8], row[9]) gene_carts.append(gc) + # Get count of total results + qry_count = """ + SELECT COUNT(*) + FROM {0} + WHERE {1} + """.format( + ", ".join(froms), + " ".join(wheres) + ) + + # if search terms are defined, remove first qry_param (since it's in the SELECT statement) + if search_terms: + qry_params.pop(0) + + cursor.execute(qry_count, qry_params) + + # compile pagination information + result["pagination"] = {} + result["pagination"]['total_results'] = cursor.fetchone()[0] + result["pagination"]['current_page'] = page if page else 1 + result["pagination"]['limit'] = limit if limit else DEFAULT_MAX_RESULTS + result["pagination"]["total_pages"] = ceil(int(result["pagination"]['total_results']) / int(result["pagination"]['limit'])) + result["pagination"]["next_page"] = int(result["pagination"]['current_page']) + 1 if int(result["pagination"]['current_page']) < int(result["pagination"]['total_pages']) else None + result["pagination"]["prev_page"] = int(result["pagination"]['current_page']) - 1 if int(result["pagination"]['current_page']) > 1 else None + result['gene_carts'] = gene_carts result['success'] = 1 diff --git a/www/css/gear-theme-purple.scss b/www/css/gear-theme-purple.scss index e6aa1234..e015aff5 100644 --- a/www/css/gear-theme-purple.scss +++ b/www/css/gear-theme-purple.scss @@ -55,3 +55,4 @@ $input-radius: 5px; @import "../css/bulma/sass/components/_all.sass"; @import "../css/bulma/sass/layout/_all.sass"; +/* TODO: Figure out how to customize loader here. "light" for dark elements and "dark" for light ones */ \ No newline at end of file diff --git a/www/css/gene_collection_manager.css b/www/css/gene_collection_manager.css new file mode 100644 index 00000000..4db6f8e0 --- /dev/null +++ b/www/css/gene_collection_manager.css @@ -0,0 +1,14 @@ +/* Initial CSS for Floating UI (https://floating-ui.com/docs/migration#positioning-function-change) */ +.floating { + width: max-content; + position: absolute; + top: 0; + left: 0; +} + +hr.gc-list-element-divider { + width: 90%; + background-color: #000000; + margin-left: auto; + margin-right: auto; +} \ No newline at end of file diff --git a/www/gene_collection_manager.html b/www/gene_collection_manager.html new file mode 100644 index 00000000..911b4616 --- /dev/null +++ b/www/gene_collection_manager.html @@ -0,0 +1,353 @@ + + + + + + + + + gEAR gene collection manager + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ + +
+
+ +
+
+ +
+
+ + +
+ +
+ + +
+
+ + +
+

+ Ownership + + + +

+
    +
  • All
  • +
  • Your collections
  • +
  • Group-affiliated collections
  • +
  • Collections shared with you
  • +
  • Public collections
  • +
+
+ +
+

+ Organism + + + +

+
    +
  • All
  • + +
+
+ +
+

+ Date added + + + +

+
    +
  • Any time
  • +
  • Within last week
  • +
  • Within last month
  • +
  • Within last year
  • +
+
+ +
+ +
+ + + + + +
+
+
+
+
+ +
+
+ + +
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+ +
+
+ + +
+
+ +
+ + +
+ +
+ + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/js/common.v2.js b/www/js/common.v2.js index f9fb6f1c..5f14d34a 100644 --- a/www/js/common.v2.js +++ b/www/js/common.v2.js @@ -68,16 +68,26 @@ document.addEventListener('DOMContentLoaded', () => { checkForLogin(); }); -// Functions to open and close a modal -function openModal($el) { +/** + * Opens a modal by adding the 'is-active' class to the specified element. + * @param {HTMLElement} $el - The element to open as a modal. + */ +const openModal = ($el) => { $el.classList.add('is-active'); } -function closeModal($el) { +/** + * Closes the modal by removing the 'is-active' class from the specified element. + * @param {HTMLElement} $el - The element representing the modal. + */ +const closeModal = ($el) => { $el.classList.remove('is-active'); } -function closeAllModals() { +/** + * Closes all modals on the page. + */ +const closeAllModals = () => { (document.querySelectorAll('.modal') || []).forEach(($modal) => { closeModal($modal); }); @@ -105,6 +115,10 @@ document.getElementById('submit-logout').addEventListener('click', (event) => { window.location.replace('./index.html'); }); +/** + * Checks if the user is logged in and performs necessary UI updates. + * @returns {Promise} A promise that resolves once the login check is complete. + */ const checkForLogin = async () => { const session_id = Cookies.get('gear_session_id'); apiCallsMixin.sessionId = session_id; @@ -137,6 +151,12 @@ const checkForLogin = async () => { } } +/** + * Performs the login process. + * @async + * @function doLogin + * @returns {Promise} + */ const doLogin = async () => { const formdata = new FormData(document.getElementById("login-form")); const data = await apiCallsMixin.login(formdata); @@ -169,22 +189,37 @@ const doLogin = async () => { } } +/** + * Hides elements with the class 'logged-in'. + */ const hideLoggedInElements = () => { document.querySelectorAll('.logged-in').forEach(element => element.style.display = 'none'); } +/** + * Hides elements with the class 'not-logged-in'. + */ const hideNotLoggedInElements = () => { document.querySelectorAll('.not-logged-in').forEach(element => element.style.display = 'none'); } +/** + * Shows the logged-in elements by setting their display property to an empty string. + */ const showLoggedInElements = () => { document.querySelectorAll('.logged-in').forEach(element => element.style.display = ''); } +/** + * Shows the elements that are only visible when the user is not logged in. + */ const showNotLoggedInElements = () => { document.querySelectorAll('.not-logged-in').forEach(element => element.style.display = ''); } +/** + * Handles the UI updates for the login functionality. + */ const handleLoginUIUpdates = () => { // So that all elements don't initially show while login is checked, we // show/hide elements first then parent container @@ -204,16 +239,25 @@ const handleLoginUIUpdates = () => { End of login-related code *************************************************************************************/ -/* Generate a DocumentFragment based on an HTML template. Returns htmlCollection that can be appended to a parent HTML */ +/** + * Generates an element from the provided HTML string. + * + * @param {string} html - The HTML string to generate the element from. + * @returns {Element} - The generated element. + */ const generateElements = (html) => { const template = document.createElement('template'); template.innerHTML = html.trim(); - return template.content.children[0]; + return template.content.children[0]; // htmlCollection that can be appended to a parent HTML } -// Equivalent to jQuery "trigger" (https://youmightnotneedjquery.com/#trigger_native) +/** + * Triggers an event on the given element. + * @param {HTMLElement} el - The element on which to trigger the event. + * @param {string|Function|Event} eventType - The type of event to trigger, or a function to execute as an event handler, or a custom event object. + */ const trigger = (el, eventType) => { - + // Equivalent to jQuery "trigger" (https://youmightnotneedjquery.com/#trigger_native) if (typeof eventType === 'string' && typeof el[eventType] === 'function') { el[eventType](); } else if (typeof eventType === 'function') { @@ -227,27 +271,57 @@ const trigger = (el, eventType) => { } } +/** + * Logs the error details to the console. + * @param {Error} error - The error object. + */ const logErrorInConsole = (error) => { if (error.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx - console.error(error.response.data); - console.error(error.response.status); - console.error(error.response.headers); + console.error('Response Error:', { + data: error.response.data, + status: error.response.status, + headers: error.response.headers, + }); } else if (error.request) { // The request was made but no response was received // `error.request` is an instance of XMLHttpRequest in the browser and an instance of // http.ClientRequest in node.js - console.error(error.request); + console.error('Request Error:', error.request); } else { // Something happened in setting up the request that triggered an Error - console.error('Error', error.message); + console.error('Setup Error:', error.message); + } + + if (error.config) { + console.error('Config:', error.config); } - console.error(error.config); + + console.error('Stack Trace:', error); } +/** + * Copies the specified text to the clipboard. + * @param {string} text - The text to be copied. + * @returns {Promise} - A promise that resolves to true if the text was successfully copied, false otherwise. + */ +const copyToClipboard = async (text) => { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (ex) { + console.warn("Copy to clipboard failed.", ex); + return false; + } +} // Convert POST payload to FormData so that POSTing to CGI scripts that use cgi.FieldStorage will work +/** + * Converts an object into FormData. + * @param {Object} object - The object to be converted. + * @returns {FormData} - The converted FormData object. + */ const convertToFormData = (object) => { // Source -> https://stackoverflow.com/a/66611630 // NOTE: When using FormData do not set headers to application/json @@ -259,6 +333,11 @@ const convertToFormData = (object) => { return formData; } +/** + * Collapses a JS step element. + * + * @param {HTMLElement} stepElt - The step element to collapse. + */ const collapseJsStep = (stepElt) => { // Reset active step stepElt.classList.remove("step-active"); @@ -275,8 +354,16 @@ const collapseJsStep = (stepElt) => { // If "step" sections are clicked, expand that section and collapse the others const jsSteps = document.getElementsByClassName("js-step"); +/** + * Resets the steps and collapses the sections based on the event target. + * If the clicked area has no parentNode, the function will exit. + * If the clicked target is an existing section, it will collapse the section. + * If the clicked target is a collapsable content, it will do nothing. + * Otherwise, it will collapse all steps and make the clicked step active. + * + * @param {Event} event - The event object triggered by the click. + */ const resetSteps = (event) => { - // If clicked area has no parentNode (i.e. clicked target is destroyed and recreated), just leave be if (! event.target.parentNode) return; // ? Instead of adding and removing ".step-active", should we toggle ".is-hidden" on the collapsable parts? diff --git a/www/js/gene_collection_manager.js b/www/js/gene_collection_manager.js new file mode 100644 index 00000000..2915f6c2 --- /dev/null +++ b/www/js/gene_collection_manager.js @@ -0,0 +1,1361 @@ +"use strict"; + +let firstSearch = true; +const animationTime = 200; // in ms +let isAddFormOpen = false; +const resultsPerPage = 20; + +// TODO - Add transformation code for quick gene collection transformations +// TODO - Finish pagination code which appeared incomplete in the original code +// TODO - Add "labeled" gene collection type (i.e. marker genes) and integrate into other code snippets +// TODO - Add tooltips for each "action" button + +// Floating UI function alias. See https://floating-ui.com/docs/getting-started#umd +// Subject to change if we ever need these common names for other things. +const computePosition = window.FloatingUIDOM.computePosition; +const flip = window.FloatingUIDOM.flip; +const shift = window.FloatingUIDOM.shift; +const offset = window.FloatingUIDOM.offset; +const arrow = window.FloatingUIDOM.arrow; + +/** + * Adds event listeners for various actions related to gene collections. + * @function + * @returns {void} + */ +const addGeneCollectionEventListeners = () => { + + // Show genes when button is clicked & adjust button styling + for (const classElt of document.getElementsByClassName("js-gc-unweighted-gene-list-toggle")) { + classElt.addEventListener("click", (e) => { + const gcId = e.target.dataset.gcId; + const geneList = document.getElementById(`${gcId}_gene_list`); + + // see if the gene_list is visible and toggle + if (geneList.classList.contains("is-hidden")) { + geneList.classList.remove("is-hidden"); + e.target.classList.remove("is-outlined"); + } else { + geneList.classList.add("is-hidden"); + e.target.classList.add("is-outlined"); + } + + }); + } + + for (const classElt of document.getElementsByClassName("js-gc-weighted-gene-list-toggle")) { + classElt.addEventListener("click", async (e) => { + const gcId = e.target.dataset.gcId; + const geneTable = document.getElementById(`${gcId}_gene_table`); + const shareId = e.target.dataset.gcShareId; + + document.getElementById(`btn_gc_${gcId}_preview`).classList.add("is-hidden"); + document.getElementById(`btn_gc_${gcId}_loading`).classList.remove("is-hidden"); + + try { + const {data} = await axios.post('./cgi/get_weighted_gene_cart_preview.cgi', convertToFormData({ + 'share_id': shareId + })); + processWeightedGcList(gcId, data['preview_json']); + } catch (error) { + logErrorInConsole(error); + // TODO: Display error notification + } + }); + } + + // Hide gene collection view + for (const classElt of document.getElementsByClassName("js-gc-weighted-gene-list-hider")) { + classElt.addEventListener("click", (e) => { + const gcId = e.target.dataset.gcId; + document.getElementById(`${gcId}_gene_table`).classList.add("is-hidden"); // TODO: add animate CSS with fade out + document.getElementById(`btn_gc_${gcId}_hider`).classList.add("is-hidden"); + document.getElementById(`btn_gc_${gcId}_preview`).classList.remove("is-hidden"); + }); + } + + // Expand and collapse gene collection view + for (const classElt of document.getElementsByClassName("js-expand-box")) { + classElt.addEventListener("click", (e) => { + const gcId = e.currentTarget.dataset.gcId; + const selector = `#result_gc_id_${gcId} .js-expandable-view`; + const expandableViewElts = document.querySelectorAll(selector); + for (const classElt of expandableViewElts) { + classElt.classList.toggle("is-hidden"); + } + + // Toggle the icon + if (e.currentTarget.innerHTML === '') { + e.currentTarget.innerHTML = ''; + } else { + e.currentTarget.innerHTML = ''; + } + + + }); + } + + // Reformats the
    containing the gene symbols into a text file with one gene per row + for (const classElt of document.getElementsByClassName("js-download-gc")) { + classElt.addEventListener("click", (e) => { + const gcId = e.target.dataset.gcId; + const fileContents = document.getElementById(`${gcId}_gene_list`).innerText; + + const element = document.createElement("a"); + element.setAttribute( + "href", + `data:text/tab-separated-values;charset=utf-8,${encodeURIComponent(fileContents)}` + ); + + element.setAttribute("download", `gene_cart.${e.target.dataset.gcShareId}.tsv`); + element.style.display = "none"; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + }); + } + + for (const classElt of document.getElementsByClassName("js-share-gc")) { + classElt.addEventListener("click", (e) => { + + const shareId = e.target.value; + const currentUrl = window.location.href; + const currentPage = currentUrl.lastIndexOf("gene_collection_manager.html"); + const shareUrl = `${currentUrl.substring(0, currentPage)}p?c=${shareId}`; + const gcId = e.target.dataset.gcId; + + if (copyToClipboard(shareUrl)) { + showGcActionNote(gcId, "URL copied to clipboard"); + } else { + showGcActionNote(gcId, `Failed to copy to clipboard. URL: ${shareUrl}`); + } + + }); + } + + // Cancel button for editing a gene collection + for (const classElt of document.getElementsByClassName("js-edit-gc-cancel")) { + classElt.addEventListener("click", (e) => { + const gcId = e.target.dataset.gcId; + const selectorBase = `#result_gc_id_${gcId}`; + + // Show editable versions where there are some and hide the display versions + document.querySelector(`${selectorBase} .editable-version`).classList.add("is-hidden"); + document.querySelector(`${selectorBase} .is-editable`).classList.remove("is-hidden"); + + // Reset any unsaved/edited values + const visibility = document.querySelector(`${selectorBase}_editable_visibility`).dataset.originalVal; + document.querySelector(`${selectorBase}_editable_visibility`).value = visibility; + + const title = document.querySelector(`${selectorBase}_editable_title`).dataset.originalVal; + document.querySelector(`${selectorBase}_editable_title`).value = title; + + const orgId = document.querySelector(`${selectorBase}_editable_organism_id`).dataset.originalVal; + document.querySelector(`${selectorBase}_editable_organism_id`).value = orgId; + }); + } + + // Save button for editing a gene collection + for (const classElt of document.getElementsByClassName("js-edit-gc-save")) { + classElt.addEventListener("click", async (e) => { + const gcId = e.target.dataset.gcId; + const selectorBase = `#result_gc_id_${gcId}`; + const newVisibility = document.querySelector(`${selectorBase}_editable_visibility`).value; + const newTitle = document.querySelector(`${selectorBase}_editable_title`).value; + const newOrgId = document.querySelector(`${selectorBase}_editable_organism_id`).value; + const newLdesc = document.querySelector(`${selectorBase}_editable_ldesc`).value; + + try { + const {data} = await axios.post('./cgi/save_genecart_changes.cgi', convertToFormData({ + 'session_id': CURRENT_USER.session_id, + 'gc_id': gcId, + 'visibility': newVisibility, + 'title': newTitle, + 'organism_id': newOrgId, + 'ldesc': newLdesc + })); + + // Update the UI for the new values + document.querySelector(`${selectorBase}_editable_visibility`).dataset.isPublic = newVisibility; + if (newVisibility) { + document.querySelector(`${selectorBase}_display_visibility`).innerHTML = "Public gene collection"; + document.querySelector(`${selectorBase}_display_visibility`).classList.remove("is-danger"); + document.querySelector(`${selectorBase}_display_visibility`).classList.add("is-light"); + } else { + document.querySelector(`${selectorBase}_display_visibility`).innerHTML = "Private gene collection"; + document.querySelector(`${selectorBase}_display_visibility`).classList.remove("is-light"); + document.querySelector(`${selectorBase}_display_visibility`).classList.add("is-danger"); + } + + document.querySelector(`${selectorBase}_editable_title`).dataset.originalVal = newTitle; + document.querySelector(`${selectorBase}_display_title`).innerHTML = newTitle; + + document.querySelector(`${selectorBase}_editable_ldesc`).dataset.originalVal = newLdesc; + document.querySelector(`${selectorBase}_display_ldesc`).innerHTML = newLdesc; + + document.querySelector(`${selectorBase}_display_organism`).innerHTML = + document.querySelector(`${selectorBase}_editable_organism_id > option[value='${newOrgId}']`); + document.querySelector(`${selectorBase}_editable_organism_id`).dataset.originalVal = newOrgId; + + // Put interface back to view mode. + toggleEditableMode(true, selectorBase); + + } catch (error) { + logErrorInConsole(error); + // TODO: Display error notification + } + }); + } + + // Toggle editable mode when edit button is clicked for a gene collection + for (const classElt of document.getElementsByClassName("js-edit-gc")) { + classElt.addEventListener("click", async (e) => { + + const gcId = e.target.dataset.gcId; + const selectorBase = `#result_gc_id_${gcId}`; + + // copy the organism selection list for this row + const editableOrganismIdElt = document.getElementById(`${selectorBase}_editable_organism_id`); + editableOrganismIdElt.innerHTML = document.getElementById("new_cart_organism_id").innerHTML; + + // set the current value as selected + editableOrganismIdElt.value = editableOrganismIdElt.dataset.originalVal; + + const editableVisibilityElt = document.getElementById(`${selectorBase}_editable_visibility`); + + if (editableVisibilityElt.dataset.isPublic) { + editableVisibilityElt.prop('checked', true).change(); + } else { + editableVisibilityElt.prop('checked', false).change(); + } + + // Show editable versions where there are some and hide the display versions + toggleEditableMode(false, selectorBase); + + // Make sure the view is expanded + const expandableViewElt = document.querySelector(`${selectorBase} .js-expandable-view`); + if (expandableViewElt.classList.contains('is-hidden')) { + document.querySelector(`${selectorBase} span.js-expand-box`).click(); + } + + }); + } + + // Redirect to gene expression search + for (const classElt of document.getElementsByClassName("js-view-gc")) { + classElt.addEventListener("click", (e) => { + window.location = `./p?c=${e.target.value}`; + }); + } +} + + +/** + * Adds a gene collection to the gene collection display container in the DOM. + * @param {string} geneCollectionId - The ID of the gene collection container. + * @param {string} gctype - The type of gene collection to add. + * @param {string[]} genes - An array of genes to add to the gene collection. + * @returns {void} + */ +const addGeneListToGeneCollection = (geneCollectionId, gctype, genes) => { + // Add weighted or unweighted gene collection to DOM + const geneCollectionIdContainer = document.getElementById(`${geneCollectionId}_gene_container`); + if (gctype == "weighted-list") { + const weightedGeneListContainer = document.createElement("div"); + weightedGeneListContainer.id = `${geneCollectionId}_gene_table`; + geneCollectionIdContainer.appendChild(weightedGeneListContainer); + return; + } + const geneListContainer = document.createElement("ul"); + geneListContainer.classList.add("is-hidden"); + geneListContainer.id = `${geneCollectionId}_gene_list`; + geneCollectionIdContainer.appendChild(geneListContainer); + // append genes to gene collection + const geneCollectionIdGeneUlElt = document.getElementById(geneListContainer.id); + for (const gene of genes) { + const li = document.createElement("li"); + li.innerText = gene; + li.classList.add("mt-1"); + geneCollectionIdGeneUlElt.appendChild(li); + } +} + +/** + * Adds gene collection display elements to DOM. + * @param {string} geneCollectionId - The ID of the gene collection. + * @param {string} gctype - The type of gene collection. + * @param {string} shareId - The ID of the share. + * @param {number} geneCount - The number of genes. + */ +const addPreviewGenesToGeneCollection = (geneCollectionId, gctype, shareId, geneCount) => { + const geneCollectionPreviewGenesContainer = document.getElementById(`${geneCollectionId}_preview_genes_container`); + if (gctype === "weighted-list") { + const previewButton = document.createElement('button'); + previewButton.id = `btn_gc_${geneCollectionId}_preview`; + previewButton.className = 'button is-small is-dark is-outlined js-gc-weighted-gene-list-toggle'; + previewButton.title = 'Show gene collection'; + previewButton.innerHTML = ' Preview'; + previewButton.dataset.gcId = geneCollectionId; + previewButton.dataset.gcShareId = shareId; + + const loadingButton = document.createElement('button'); + loadingButton.id = `btn_gc_${geneCollectionId}_loading`; + loadingButton.className = 'button is-small is-outlined is-hidden is-loading'; + loadingButton.title = 'Loading'; + loadingButton.innerHTML = ' Loading'; + loadingButton.dataset.gcId = geneCollectionId; + + const hideButton = document.createElement('button'); + hideButton.id = `btn_gc_${geneCollectionId}_hider`; + hideButton.className = 'button is-small is-outlined js-gc-weighted-gene-list-hider is-hidden'; + hideButton.title = 'Hide gene collection'; + hideButton.innerHTML = ' Hide'; + hideButton.dataset.gcId = geneCollectionId; + + geneCollectionPreviewGenesContainer.append(previewButton, loadingButton, hideButton); + return; + } else if (gctype === "unweighted-list") { + const geneListButton = document.createElement('button'); + geneListButton.className = 'button is-small is-dark is-outlined js-gc-unweighted-gene-list-toggle'; + geneListButton.title = 'Show gene collection'; + geneListButton.innerHTML = ` ${geneCount} genes`; + geneListButton.dataset.gcId = geneCollectionId; + geneCollectionPreviewGenesContainer.append(geneListButton); + + } else if (gctype === "labeled-list") { + // Not implemented yet + } +} + +/** + * Adds public/private visibility information to a gene collection display container in the DOM. + * @param {string} geneCollectionId - The ID of the gene collection display container. + * @param {boolean} isPublic - A boolean indicating whether the gene collection is public or private. + * @returns {void} + */ +const addVisibilityInfoToGeneCollection = (geneCollectionId, isPublic) => { + // add gene collection public/private info to DOM + const geneCollectionDisplayContainer = document.getElementById(`${geneCollectionId}_display_container`); + const geneCollectionDisplaySpan = document.createElement("span"); + geneCollectionDisplaySpan.classList.add("tag"); + geneCollectionDisplaySpan.id = `result_gc_id_${geneCollectionId}_display_visibility`; + if (isPublic) { + geneCollectionDisplaySpan.classList.add("is-primary", "is-light"); + geneCollectionDisplaySpan.innerText = "Public gene collection"; + } else { + geneCollectionDisplaySpan.classList.add("is-danger"); + geneCollectionDisplaySpan.innerText = "Private gene collection"; + } + geneCollectionDisplayContainer.appendChild(geneCollectionDisplaySpan); +} + +/** + * Builds a comma-separated string of selected database values for a given group name. + * @param {string} groupName - The ID of the group to retrieve selected values from. + * @returns {string} A comma-separated string of selected database values. + */ +const buildFilterString = (groupName) => { + const selected = document.querySelectorAll(`#${groupName} ul li.js-selected :not(.js-all-selector)`); + const dbvals = []; + + for (const li of selected) { + dbvals.push(li.dataset.dbval); + } + + return dbvals.join(","); +} + +/** + * Creates a confirmation popover for deleting a gene collection. + */ +const createDeleteConfirmationPopover = () => { + const deleteButtons = document.getElementsByClassName("js-delete-gc"); + for (const button of deleteButtons) { + button.addEventListener('click', (e) => { + // remove existing popovers + const existingPopover = document.getElementById('delete_gc_popover'); + if (existingPopover) { + existingPopover.remove(); + } + + // Create popover content + const popoverContent = document.createElement('article'); + popoverContent.id = 'delete_gc_popover'; + popoverContent.classList.add("message", "is-danger"); + popoverContent.setAttribute("role", "tooltip"); + popoverContent.innerHTML = ` +
    +

    Remove collection

    +
    +
    +

    Are you sure you want to delete this gene collection?

    +
    +

    + +

    +

    + +

    +
    +
    +
    + `; + popoverContent.style.position = 'absolute'; // float above everything else to not disrupt layout + + // append element to DOM to get its dimensions + document.body.appendChild(popoverContent); + + const arrowElement = document.getElementById('arrow'); + + // Create popover (help from https://floating-ui.com/docs/tutorial) + const popover = computePosition(button, popoverContent, { + placement: 'top', + strategy: 'fixed', + middleware: [ + flip(), // flip to bottom if there is not enough space on top + shift(), // shift the popover to the right if there is not enough space on the left + offset(5), // offset relative to the button + arrow({ element: arrowElement }) // add an arrow pointing to the button + ], + }).then(({ x, y, placement, middlewareData }) => { + // Position the popover + Object.assign(popoverContent.style, { + left: `${x}px`, + top: `${y}px`, + }); + // Accessing the data + const { x: arrowX, y: arrowY } = middlewareData.arrow; + + // Position the arrow relative to the popover + const staticSide = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[placement.split('-')[0]]; + + // Set the arrow position + Object.assign(arrowElement.style, { + left: arrowX != null ? `${arrowX}px` : '', + top: arrowY != null ? `${arrowY}px` : '', + right: '', + bottom: '', + [staticSide]: '-4px', + }); + }); + + // Show popover + document.body.appendChild(popoverContent); + + // Store the gene collection ID to delete + const gcIdToDelete = e.target.value; + + // Add event listener to cancel button + document.getElementById('cancel_gc_delete').addEventListener('click', () => { + popoverContent.remove(); + }); + + // Add event listener to confirm button + document.getElementById('confirm_gc_delete').addEventListener('click', () => { + + try { + const {data} = axios.post('./cgi/remove_gene_cart.cgi', convertToFormData({ + 'session_id': CURRENT_USER.session_id, + 'gene_cart_id': gcIdToDelete + })); + + if (data['success'] == 1) { + const resultElement = document.getElementById('result_gc_id_' + gcIdToDelete); + resultElement.style.transition = 'opacity 1s'; + resultElement.style.opacity = 0; + + setTimeout(function() { + const resultCountElement = document.getElementById('result_count'); + resultCountElement.textContent = parseInt(resultCountElement.textContent) - 1; + resultElement.remove(); + }, 1000); + } else { + throw new Error(data['error']); + } + } catch (error) { + logErrorInConsole(error); + // TODO: Display error notification + } finally { + popoverContent.remove(); + } + }); + }); + } +} + +/** + * Creates a pagination button element. + * + * @param {number} page - The page number to display on the button. + * @param {string|null} icon - The icon class name to display on the button. If null, the page number will be displayed instead. + * @param {Function} clickHandler - The click event handler function for the button. + * @returns {HTMLLIElement} - The created pagination button element. + */ +const createPaginationButton = (page, icon = null, clickHandler) => { + const li = document.createElement("li"); + const button = document.createElement("button"); + button.className = "button is-small is-outlined is-dark pagination-link"; + if (icon) { + button.innerHTML = ``; + } else { + button.textContent = page; + } + button.addEventListener("click", clickHandler); + li.appendChild(button); + return li; +} + +/** + * Creates a pagination ellipsis element. + * @returns {HTMLLIElement} The created list item element containing the pagination ellipsis. + */ +const createPaginationEllipsis = () => { + const li = document.createElement("li"); + const span = document.createElement("span"); + span.className = "pagination-ellipsis"; + span.textContent = "…"; + li.appendChild(span); + return li; +} + +/** + * Creates a popover element and appends it to the body. + * @param {HTMLElement} referenceElement - The element that triggers the popover. + * @returns {void} + */ +const createPopover = (referenceElement) => { + // Create popover element + const popover = document.createElement('div'); + popover.className = 'message'; + popover.classList.add("is-hidden"); + + // Create message body + const messageBody = document.createElement('div'); + messageBody.className = 'message-body'; + messageBody.innerText = referenceElement.dataset.popoverContent; + + // Append message body to popover + popover.appendChild(messageBody); + + // Append popover to body + document.body.appendChild(popover); + + // Compute position + const { x, y } = computePosition(referenceElement, popover, { + placement: 'top', // Change this to your preferred placement + strategy: 'fixed', + middleware: [ + flip(), // flip to bottom if there is not enough space on top + shift(), // shift the popover to the right if there is not enough space on the left + offset(5), // offset relative to the button + ] + }).then(({ x, y }) => { + // Position the popover + Object.assign(popover.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + + const hideTooltip = () => { + popover.classList.add("is-hidden"); + } + + const showTooltip = () => { + popover.classList.remove("is-hidden"); + } + + [ + ['mouseenter', showTooltip], + ['mouseleave', hideTooltip], + ['focus', showTooltip], + ['blur', hideTooltip], + ].forEach(([event, listener]) => { + popover.addEventListener(event, listener); + }); +} + +/** + * Creates a tooltip element and appends it to the body. + * @param {HTMLElement} referenceElement - The element that the tooltip is referencing. + * @returns {void} + */ +const createTooltip = (referenceElement) => { + // Create tooltip element + const tooltip = document.createElement('div'); + tooltip.className = 'tooltip'; + tooltip.innerText = referenceElement.dataset.tooltipContent; + tooltip.classList.add("has-background-dark", "is-hidden"); + + // Append tooltip to body + document.body.appendChild(tooltip); + + // Compute position + const { x, y } = computePosition(referenceElement, tooltip, { + placement: 'top', // Change this to your preferred placement + strategy: 'fixed', + middleware: [ + flip(), // flip to bottom if there is not enough space on top + shift(), // shift the popover to the right if there is not enough space on the left + offset(5), // offset relative to the button + ] + }).then(({ x, y }) => { + // Position the popover + Object.assign(tooltip.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); + + const hideTooltip = () => { + tooltip.classList.add("is-hidden"); + } + + const showTooltip = () => { + tooltip.classList.remove("is-hidden"); + } + + [ + ['mouseenter', showTooltip], + ['mouseleave', hideTooltip], + ['focus', showTooltip], + ['blur', hideTooltip], + ].forEach(([event, listener]) => { + tooltip.addEventListener(event, listener); + }); + +} + +// Callbacks after attempting to save a gene collection +const geneCollectionFailure = (gc, message) => { + logErrorInConsole(message); +} + +const geneCollectionSaved = (gc) => { + document.getElementById("create_new_gene_collection").click(); + submitSearch(); + resetAddForm(); +} + + +/** + * Loads the list of organisms from the server and populates the organism choices and new cart organism ID select elements. + * @function + * @returns {void} + */ +const loadOrganismList = () => { + try { + const {data} = axios.get('./cgi/get_organism_list.cgi'); + const organismChoices = document.getElementById("organism_choices"); //
      element + organismChoices.innerHTML = ""; + for (const organism of data.organisms) { + const li = document.createElement("li"); + li.dataset.dbval = organism.id; + li.innerText = organism.label; + organismChoices.appendChild(li); + } + const newCartOrganismId = document.getElementById("new_cart_organism_id"); // + + +
      + +
      + + +
      +
      + + +
      Organism ${organism}
      + + +
      Owner ${userName}
      +
      Added ${dateAdded}
      +
      + +
      +
      +
      +
      +
      +
      +
      +
      +

      + +

      +

      + +

      +

      + +

      +

      + +

      +

      + +

      +

      + +

      +

      + +

      +
      +
      +
      Type ${gctype}
      +
      + + +
      + +
      + + + + + + + + +
      + ` + + // Append to results_container + resultsContainer.insertAdjacentHTML("beforeend", resultsViewTmpl); + + addVisibilityInfoToGeneCollection(geneCollectionId, isPublic); + + addPreviewGenesToGeneCollection(geneCollectionId, gctype, shareId, geneCount); + + addGeneListToGeneCollection(geneCollectionId, gctype, genes); + + // Add ldesc if it exists + const ldescContainer = document.getElementById(`${geneCollectionId}_ldesc_container`); + const ldescElt = document.createElement("p"); + ldescElt.id = `result_gc_id_${geneCollectionId}_display_ldesc`; + ldescElt.innerText = longDesc || "No description entered"; + ldescContainer.appendChild(ldescElt); + } + + updateGeneCollectionListButtons(); + + // Create tooltips for all elements with the data-tooltip-content attribute + for (const classElt of document.querySelectorAll("[data-tooltip-content]")) { + createTooltip(classElt); + } + + // Initiialize delete gene collection popover for each delete button + createDeleteConfirmationPopover(); + + // All event listeners for all gene collection elements + addGeneCollectionEventListeners(); +} + + +/** + * Toggles the editable mode of elements with the given selector base. + * @param {boolean} hideEditable - Whether to hide the editable elements or not. + * @param {string} [selectorBase=""] - The base selector to use for finding the editable and non-editable elements. + */ +const toggleEditableMode = (hideEditable, selectorBase="") => { + const editableElements = document.querySelectorAll(`${selectorBase} .js-editable-version`); + const nonEditableElements = document.querySelectorAll(`${selectorBase} .js-readonly-version`); + + editableElements.forEach(el => el.classList.toggle("is-hidden", hideEditable)); + nonEditableElements.forEach(el => el.classList.toggle("is-hidden", !hideEditable)); +} + +/** + * Processes the weighted GC list. + * @param {string} gcId - The ID of the GC. + * @param {Object} jdata - The preview JSON weighted data to show. + * @returns {void} + */ +const processWeightedGcList = (gcId, jdata) => { + document.getElementById(`btn_gc_${gcId}_loading`).classList.add("is-hidden"); + document.getElementById(`btn_gc_${gcId}_hider`).classList.remove("is-hidden"); + + // This creates a table with classes dataframe and weighted-list + document.getElementById(`${gcId}_gene_table`).innerHTML = jdata; + document.getElementById(`${gcId}_gene_table`).classList.add("is-hidden"); // TODO: add animate CSS with fade in +} + + +/** + * Resets the add form by removing the "is-hidden" class from the save button, + * adding it to the saving button, and resetting all form fields to their default values. + * + * Also removes the "has-background-primary" class from the unweighted and weighted headers, + * sets their color to black, and enables the paste and upload buttons. + * + * Finally, adds the "is-hidden" class to the form and pasted genes container, and sets isAddFormOpen to false. + */ +const resetAddForm = () => { + document.getElementById("btn_new_cart_save").classList.remove("is-loading"); + + document.getElementById("new_cart_label").value = ""; + document.getElementById("new_cart_ldesc").value = ""; + document.getElementById("new_cart_pasted_genes").value = ""; + document.getElementById("new_cart_file").value = ""; + document.getElementById("new_cart_is_public").checked = false; // bootstrap toggle off + + document.getElementById("new_cart_unweighted_header").classList.remove('has-background-primary'); + document.getElementById("new_cart_unweighted_header").style.color = 'black'; + + document.getElementById("new_cart_weighted_header").classList.remove('has-background-primary'); + document.getElementById("new_cart_weighted_header").style.color = 'black'; + + document.getElementById("btn_gc_paste_unweighted_list").removeAttribute('disabled'); + document.getElementById("btn_gc_upload_unweighted_list").removeAttribute('disabled'); + document.getElementById("btn_gc_upload_weighted_list").removeAttribute('disabled'); + + document.getElementById("new_cart_form_c").classList.add("is-hidden"); + document.getElementById("new_cart_pasted_genes_c").classList.add("is-hidden"); + isAddFormOpen = false; +} + +/** + * Updates the action note for a given gene collection. + * @param {string} gcId - The ID of the gene collection. + * @param {string} msg - The message to display in the action note. + */ +const showGcActionNote = (gcId, msg) => { + const noteSelector = `#result_gc_id_${gcId} span.js-gc-action-note`; + const noteSelecterElt = document.querySelector(noteSelector); + noteSelecterElt.innerHTML = msg; + noteSelecterElt.classList.remove("is-hidden"); + + setTimeout(() => { + noteSelecterElt.classList.add("is-hidden"); // TODO: add animate CSS with fade out + noteSelecterElt.innerHTML = ""; + }, 5000); +} + +const setupPagination = (pagination) => { + + // Update result count and label + document.getElementById("result_count").textContent = pagination.total_results; + document.getElementById("result_label").textContent = pagination.total_results == 1 ? " result" : " results"; + document.getElementById("gc_count_label_c").classList.remove("is-hidden"); + + const maxPageResults = Math.min(pagination.total_results, resultsPerPage); + + const firstResult = (pagination.current_page - 1) * resultsPerPage + 1; + const lastResult = firstResult + maxPageResults - 1; + document.getElementById("result_range").textContent = `${firstResult} - ${lastResult}`; + + // Update pagination buttons + for (const classElt of document.getElementsByClassName("pagination")) { + classElt.replaceChildren(); + classElt.classList.remove("is-hidden"); + + const paginationList = document.createElement("ul"); + paginationList.className = "pagination-list"; + classElt.appendChild(paginationList); + + + // Add previous button + if (pagination.current_page > 1) { + paginationList.appendChild(createPaginationButton(null, 'left', () => { + submitSearch(pagination.current_page - 1); + })); + } + + // Add page buttons but only show 3 at a time (1 ... 3 4 5 ... 10) + const startPage = Math.max(1, pagination.current_page - 1); + const endPage = Math.min(pagination.total_pages, pagination.current_page + 1); + + if (startPage > 1) { + paginationList.appendChild(createPaginationButton(1, null, () => { + submitSearch(1); + })); + } + + if (startPage > 2) { + paginationList.appendChild(createPaginationEllipsis()); + } + + for (let i = startPage; i <= endPage; i++) { + const li = paginationList.appendChild(createPaginationButton(i, null, () => { + submitSearch(i); + })); + if (i == pagination.current_page) { + li.firstChild.classList.add("is-current"); + li.firstChild.classList.remove("is-outlined"); + } + } + + if (endPage < pagination.total_pages - 1) { + paginationList.appendChild(createPaginationEllipsis()); + } + + if (endPage < pagination.total_pages) { + paginationList.appendChild(createPaginationButton(pagination.total_pages, null, () => { + submitSearch(pagination.total_pages); + })); + } + + // Add next button + if (pagination.current_page < pagination.total_pages) { + paginationList.appendChild(createPaginationButton(null, 'right', () => { + submitSearch(pagination.current_page + 1); + })); + } + } + +} + +/** + * Submits a search for gene collections based on the user's search terms and filter options. + * @function + * @returns {void} + */ +const submitSearch = async (page) => { + document.getElementById("results_container").replaceChildren(); + const searchTerms = document.getElementById("search_terms").value; + + // If this is the first time searching with terms, set the sort by to relevance + if (searchTerms && firstSearch) { + document.getElementById("sort_by").value = 'relevance'; + firstSearch = false; + } + + const searchCriteria = { + 'session_id': CURRENT_USER.session_id, + 'search_terms': searchTerms, + 'sort_by': document.getElementById("sort_by").value + }; + + // collect the filter options the user defined + searchCriteria.organism_ids = buildFilterString('controls_organism'); + searchCriteria.date_added = buildFilterString('controls_date_added'); + searchCriteria.ownership = buildFilterString('controls_ownership'); + searchCriteria.limit = resultsPerPage; + searchCriteria.page = page || 1; + + try { + const {data} = await axios.post('./cgi/search_gene_carts.cgi', convertToFormData(searchCriteria)); + processSearchResults(data); + + setupPagination(data.pagination); + } catch (error) { + logErrorInConsole(error); + // TODO: Display error notification + } +} + +/** + * Updates the visibility of edit and delete buttons for each gene collection in the result list based on the current user's ID. + * @function + * @returns {void} + */ +const updateGeneCollectionListButtons = () => { + const gcListElements = document.getElementsByClassName(".js-gc-list-element"); + + for (const classElt of gcListElements) { + + // The ability to edit and delete and dataset are currently paired + const deleteButton = classElt.querySelector("button.js-delete-gc"); + const editButton = classElt.querySelector("button.js-edit-gc"); + + // TODO: Alternative compare method since user_id is not passed in the JSON anymore + if (CURRENT_USER.id == deleteButton.dataset.ownerId) { + deleteButton.classList.remove("is-hidden"); + editButton.classList.remove("is-hidden"); + } else { + deleteButton.classList.add("is-hidden"); + editButton.classList.add("is-hidden"); + } + }; +} + +/* --- Entry point --- */ +const handlePageSpecificLoginUIUpdates = async (event) => { + + // User settings has no "active" state for the sidebar + document.querySelector("#header_bar .navbar-item").textContent = "Gene Collection Manager"; + for (const elt of document.querySelectorAll("#primary_nav .menu-list a.is-active")) { + elt.classList.remove("is-active"); + } + + document.querySelector("a[tool='manage_genes'").classList.add("is-active"); + + const sessionId = CURRENT_USER.session_id; + + if (! sessionId ) { + document.getElementById("not_logged_in_msg").classList.remove("is-hidden"); + return; + } + + // Initialize tooltips and popovers + //createPopover(document.getElementById("cart_upload_reqs")); + +}; + +// validate that #new_cart_label input has a value +document.getElementById("new_cart_label").addEventListener("blur", (e) => { + e.target.classList.remove("is-danger-dark"); + // Remove small helper text under input + const helperText = e.target.parentElement.querySelector("p.help"); + if (helperText) { + helperText.remove(); + } + + if (e.target.value) { + return; + } + e.target.classList.add("is-danger-dark"); + // Add small helper text under input + const newHelperElt = document.createElement("p"); + newHelperElt.classList.add("help", "is-danger-dark"); + newHelperElt.innerText = "Please enter a value"; + e.target.parentElement.appendChild(newHelperElt); +}); + +document.getElementById("search_clear").addEventListener("click", () => { + document.getElementById("search_terms").value = ""; + submitSearch(); +}); + +// Search for gene collections using the supplied search terms +const searchTermsElt = document.getElementById("search_terms"); +searchTermsElt.addEventListener("keyup", (event) => { + const searchTerms = searchTermsElt.value; + const searchClearElt = document.getElementById("search_clear"); + searchClearElt.classList.add("is-hidden"); + if (searchTerms) { + searchClearElt.classList.remove("is-hidden"); + } + if (event.key === "Enter") { + submitSearch(); + } +}); + +// Changing sort by criteria should update the search results +document.getElementById("sort_by").addEventListener("change", () => { + submitSearch(); +}); + +const btnCreateCartToggle = document.getElementById("create_new_gene_collection"); +btnCreateCartToggle.addEventListener("click", () => { + const createCollectionContainer = document.getElementById("create_collection_container"); + const gcViewport = document.getElementById("results_container"); + const viewControls = document.getElementById("view_controls"); + const newCartIsPublic = document.getElementById("new_cart_is_public"); + + if (createCollectionContainer.classList.contains("is-hidden")) { + createCollectionContainer.classList.remove("is-hidden"); // TODO: Add animation with fade in/out + gcViewport.classList.add("is-hidden"); + viewControls.classList.add("is-hidden"); + btnCreateCartToggle.textContent = "Cancel cart creation"; + newCartIsPublic.checked = false; // bootstrap toggle off + return; + } + createCollectionContainer.classList.add("is-hidden"); + gcViewport.classList.remove("is-hidden"); // TODO: Add animation with fade in/out + viewControls.classList.remove("is-hidden"); // TODO: Add animation with fade in/out + btnCreateCartToggle.textContent = "Create new cart"; + resetAddForm(); +}); + +// If an upload method is clicked, show the appropriate form and hide the others +document.getElementById("btn_gc_paste_unweighted_list").addEventListener("click", () => { + document.getElementById("new_cart_unweighted_header").classList.add('has-background-primary', "has-text-white"); + + document.getElementById("btn_gc_upload_unweighted_list").setAttribute('disabled', 'disabled'); + document.getElementById("btn_gc_upload_weighted_list").setAttribute('disabled', 'disabled'); + + document.getElementById("new_cart_form_c").classList.remove("is-hidden"); // TODO: Add animation with fade in/out + document.getElementById("new_cart_pasted_genes_c").classList.remove("is-hidden"); + + document.getElementById("new_cart_upload_type").value = "pasted_genes"; + document.getElementById("file_upload_c").classList.add("is-hidden"); + isAddFormOpen = true; +}); + +document.getElementById("btn_gc_upload_unweighted_list").addEventListener("click", () => { + if (isAddFormOpen) return; + document.getElementById("new_cart_unweighted_header").classList.add('has-background-primary', "has-text-white"); + + document.getElementById("btn_gc_paste_unweighted_list").setAttribute('disabled', 'disabled'); + document.getElementById("btn_gc_upload_weighted_list").setAttribute('disabled', 'disabled'); + + document.getElementById("new_cart_form_c").classList.remove("is-hidden"); // TODO: Add animation with fade in/out + document.getElementById("new_cart_pasted_genes_c").classList.remove("is-hidden"); + + document.getElementById("new_cart_upload_type").value = "uploaded-unweighted"; + document.getElementById("file_upload_c").classList.remove("is-hidden"); + isAddFormOpen = true; +}); + +document.getElementById("btn_gc_upload_weighted_list").addEventListener("click", () => { + if (isAddFormOpen) return; + document.getElementById("new_cart_weighted_header").classList.add('has-background-primary', "has-text-white"); + + document.getElementById("btn_gc_paste_unweighted_list").setAttribute('disabled', 'disabled'); + document.getElementById("btn_gc_upload_unweighted_list").setAttribute('disabled', 'disabled'); + + document.getElementById("new_cart_form_c").classList.remove("is-hidden"); // TODO: Add animation with fade in/out + document.getElementById("new_cart_pasted_genes_c").classList.remove("is-hidden"); + + document.getElementById("new_cart_upload_type").value = "uploaded-weighted"; + document.getElementById("file_upload_c").classList.remove("is-hidden"); + isAddFormOpen = true; +}); + +// If the cancel button is clicked, hide the form and show the upload buttons +document.getElementById("btn_new_cart_cancel").addEventListener("click", () => { + document.getElementById("create_new_gene_collection").click(); + document.getElementById("new_cart_pasted_genes_c").classList.add("is-hidden"); +}); + + +const btnNewCartSave = document.getElementById("btn_new_cart_save"); +btnNewCartSave.addEventListener("click", (e) => { + // disable button and show indicator that it's loading + btnNewCartSave.classList.add("is-loading"); + + // check required fields + const newCartLabel = document.getElementById("new_cart_label"); + if (! newCartLabel.value) { + newCartLabel.classList.add("is-danger-dark"); + // Add small helper text under input + const newHelperElt = document.createElement("p"); + newHelperElt.classList.add("help", "is-danger-dark"); + newHelperElt.innerText = "Please enter a value"; + newCartLabel.parentElement.appendChild(newHelperElt); + + btnNewCartSave.classList.remove("is-loading"); + return; + } + + newCartLabel.classList.remove("is-danger-dark"); + // Remove small helper text under input + const helperText = newCartLabel.parentElement.querySelector("p.help"); + if (helperText) { + helperText.remove(); + } + + const isPublic = document.getElementById("new_cart_is_public").checked ? 1 : 0; + + const formData = new FormData($(this)[0]); + formData.append('is_public', isPublic); + formData.append('session_id', CURRENT_USER.session_id); + + const gc = new GeneCart(); + gc.addCartToDbFromForm(formData, geneCollectionSaved, geneCollectionFailure); + btnNewCartSave.classList.remove("is-loading"); + +}); + +document.getElementById("btn_list_view_compact").addEventListener("click", () => { + document.getElementById("btn_arrangement_view").classList.remove('active'); + document.getElementById("btn_list_view_compact").classList.add('active'); + document.getElementById("btn_list_view_expanded").classList.remove('active'); + + document.getElementById("gc_list_c").classList.remove("is-hidden"); + + // find all elements with class 'js-expandable-view' and make sure they also have 'expanded-view-hidden' + for (const elt of document.querySelectorAll(".js-expandable-view")){ + elt.classList.add("expanded-view-hidden"); + }; +}); + +document.getElementById("btn_list_view_expanded").addEventListener("click", () => { + document.getElementById("btn_arrangement_view").classList.remove('active'); + document.getElementById("btn_list_view_compact").classList.remove('active'); + document.getElementById("btn_list_view_expanded").classList.add('active'); + + document.getElementById("gc_list_c").classList.remove("is-hidden"); + + // find all elements with class 'js-expandable-view' and make sure they also have 'expanded-view-hidden' + for (const elt of document.querySelectorAll(".js-expandable-view")){ + elt.classList.remove("expanded-view-hidden"); + }; +}); + + +// Generic function to handle all collapsable menus +// h.expandable_control is clicked and looks for plus/minus icons as siblings +// and an .expandable_target as a direct child + +for (const elt of document.querySelectorAll("h4.expandable_control")) { + elt.addEventListener("click", (e) => { + const exblock = e.target.nextElementSibling; + if (exblock.classList.contains("is-hidden")) { + e.target.querySelector(".mdi-plus").classList.add("is-hidden"); + e.target.querySelector(".mdi-minus").classList.remove("is-hidden"); + exblock.classList.remove("is-hidden"); // TODO: Add animation with fade in/out + + if (e.target.classList.contains("profile_control")) { + for (const elt of document.getElementsByClassName(".profile_control")) { + elt.classList.remove("is-hidden"); + document.getElementById("btn_arrangement_view").classList.remove("is-hidden"); + } + } + return; + } + e.target.querySelector(".mdi-plus").classList.remove("is-hidden"); + e.target.querySelector(".mdi-minus").classList.add("is-hidden"); + exblock.classList.add("is-hidden"); // TODO: Add animation with fade in/out + + if (e.target.classList.contains("profile_control")) { + for (const elt of document.getElementsByClassName(".profile_control")) { + elt.classList.add("is-hidden"); + document.getElementById("btn_arrangement_view").classList.add("is-hidden"); + } + } + } +)}; + +// Generic function to handle the facet selector choices +// For any ul.controls_filter_options the list elements can have a class="selected" +// The groups of
    • also have one/top li with class="all_selector" which +// toggles the rest of them off since no filter is applied. + +for (const elt of document.querySelectorAll("ul.controls_filter_options li")) { + elt.addEventListener("click", (e) => { + // if the one clicked is the all_selector then highlight it and unclick the rest + if (e.target.classList.contains("all_selector")) { + if (! e.target.classList.contains("selected")) { + e.target.classList.add("selected"); + } + + for (const elt of e.target.parentElement.children) { + elt.classList.remove("selected"); + } + } else if (e.target.classList.contains("selected")) { + // If turning off, make sure at least one other option is selected, else set + // set all_selector on + e.target.classList.remove("selected"); + + if (e.target.parentElement.querySelector("li.selected") == null) { + e.target.parentElement.querySelector("li.all_selector").classList.add("selected"); + } + } else { + // If turning on, make sure all_selector is off + e.target.parentElement.querySelector("li.all_selector").classList.remove("selected"); + + // If this selection group has the 'only_one' option deselect the rest + if (e.target.parentElement.classList.contains("only_one")) { + for (const elt of e.target.parentElement.children) { + elt.classList.remove("selected"); + } + } + + e.target.classList.add("selected"); + } + + submitSearch(); + }); +} \ No newline at end of file