9f8d33c8b2b6bc61f6d02d781c4e02836f7099f9 max Fri Aug 21 02:05:42 2026 -0700 hgSession: new opt-in JavaScript "My Sessions" page; share gbModern.css with hgBlat. refs #38157 Applies the hgBlat facelift strategy (#37996) to hgSession: an opt-in, client-rendered "My Sessions" page gated by the sessionNewPage / sessionNewPageBanner hg.conf flags (mirroring blatNewForm / blatNewFormBanner), with a banner linking between the classic and new pages so neither is a one-way door. sessionNewPage also flips the site default. hgSession.c stays the data/action backend: it emits the session list and page config as an inline JSON global (hgSessionData) into an empty #sessionApp container, and the new hgSession.js builds the UI - a save-current-view card (name + optional description + "only I can load it"; empty name saves under a random share_ name), a "most recently saved session" one-click Update, a searchable/sortable/paged DataTable of sessions (assembly + position, created with last-used on hover, views, a lock icon on private sessions), inline Share (copy link / email / gallery), Edit (rename + description + private), Overwrite and Delete, and a bulk Select -> Delete-all-selected mode. The mutating actions POST to new JSON endpoints (hgS_doDeleteJson / doShareJson / doGalleryJson / doOverwriteJson / doDescribeJson) that run the same SQL as the classic full-page handlers and return JSON, so the table updates in place; loads, file up/downloads and custom-track backup stay as ordinary form submits/links. The Advanced panel keeps feature parity with the classic page (load another user's session, load from URL/file, save to file, back up custom tracks, reset), minus the login/ change-password links that now live in the top menu. Shared UCSC house-style components (design tokens, .gbPill, .gbCard, .gbStrip, .gbSection, .gbShareBox, .gbBanner, the .gbModal* dialog and a .gbTable) are factored into a new gbModern.css. hgBlat is migrated onto it: its generic .blat* classes are renamed to the shared .gb* names in hgBlat.css / hgBlat.js and the #blatResults / #blatFormBox containers get class="gbApp"; verified pixel-clean against the previous search form and results pages, including the rename modal. hgSession.css holds only session-specific layout. diff --git src/hg/js/hgSession.js src/hg/js/hgSession.js new file mode 100644 index 00000000000..1229d32ab16 --- /dev/null +++ src/hg/js/hgSession.js @@ -0,0 +1,792 @@ +// hgSession.js - the experimental client-rendered "My Sessions" page. +// +// An opt-in modern alternative to the classic server-rendered hgSession page, applying hgBlat's +// facelift strategy (#37996): hgSession.c emits the session list and page config as an inline JSON +// global (hgSessionData) into an empty #sessionApp container, and this file builds the UI - a +// save-current-view card, a searchable/sortable DataTable of saved sessions with inline +// Overwrite/Share/Edit/Delete, and an "Advanced" panel for loading and backup. +// +// The inline table actions POST to small JSON endpoints in hgSession.c (hgS_doDeleteJson, etc.) and +// update the table in place. Navigation actions (load a session, load from URL/file, save to file, +// reset) are ordinary form submits/links against the existing hgSession actions. +// +// Styling: shared house-style components in gbModern.css (.gbPill, .gbCard, .gbModal*, .gbTable, +// .gbBanner, .gbSection), session-specific layout in hgSession.css. + +/* global $, hgSessionData, convertTitleTagsToMouseovers, htmlEncode, commify */ + +// Cart action variables (must match the hgs* defines in hgSession.h; hgSessionPrefix is "hgS_"). +var SESS_ACT = { + save: 'hgS_doSaveSessionJson', + rename: 'hgS_doRenameSessionJson', + del: 'hgS_doDeleteJson', + share: 'hgS_doShareJson', + gallery: 'hgS_doGalleryJson', + overwrite: 'hgS_doOverwriteJson', + describe: 'hgS_doDescribeJson' +}; +var SESS_P = { + oldName: 'hgS_oldSessionName', + newName: 'hgS_newSessionName', + share: 'hgS_newSessionShare', + descr: 'hgS_newSessionDescription', + shareAnon:'hgS_shareAnon' +}; + +var sessData = null; // set in sessionBuild: {config, sessions} +var sessDt = null; // the DataTable API +var sessSelectMode = false; // bulk-select (checkbox column) shown? + +function sessEnc(s) { + // HTML-escape via the shared utils.js helper (escapes quotes too, so it is attribute-safe). + return (typeof htmlEncode === 'function') ? htmlEncode(String(s == null ? '' : s)) : String(s); +} + +function sessNum(n) { + return (typeof commify === 'function') ? commify(n) : String(n); +} + +function sessRandomShareName() { + // Mirror the server's auto/anonymous share-name convention ("share_" + 8 URL-safe alphanumeric + // chars). hgSession.c's doSaveSessionJson generates the same style server-side for the top-right + // "Share a link"; we generate it here so the confirm dialog can show the name before saving. + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + var s = ''; + for (var i = 0; i < 8; i++) { + s += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return 'share_' + s; +} + +function sessCommifyPos(pos) { + // Add thousands separators to each number in a position string ("chr7:155799529-155812871" -> + // "chr7:155,799,529-155,812,871") using utils.js commify. + if (typeof commify !== 'function') { return String(pos); } + return String(pos).replace(/\d+/g, function(n) { return commify(n); }); +} + +function sessMsg(text, cls) { + // Show a transient status line at the top of the app (cls: 'ok' | 'err' | ''). + var el = document.getElementById('sessMsg'); + if (!el) { return; } + el.className = 'sessMsg' + (cls ? ' ' + cls : ''); + el.innerHTML = text ? sessEnc(text) : ''; +} + +// ---- AJAX ---------------------------------------------------------------- +// Post an action to hgSession and get JSON back. Always carry the session id so the CGI loads the +// right cart. onOk(resp) is called for {success:true,...} or {name,url}; onErr(msg) for {error}. + +function sessAjax(params, onOk, onErr) { + var data = $.extend({}, params); + data[sessData.config.cartVar] = sessData.config.hgsid; + $.ajax({ + type: 'POST', + url: 'hgSession', + data: data, + dataType: 'json', + cache: false, + success: function(resp) { + if (resp && resp.error) { + if (onErr) { onErr(resp.error); } else { sessMsg(resp.error, 'err'); } + } else { + if (onOk) { onOk(resp); } + } + }, + error: function() { + var m = 'Sorry, that action could not be completed. Please try again.'; + if (onErr) { onErr(m); } else { sessMsg(m, 'err'); } + } + }); +} + +// ---- modal (generic) ----------------------------------------------------- +// One reusable overlay lives inside #sessionApp (so the gbModern tokens inherit into it). Open on +// backdrop click and Esc close, matching hgBlat's modal behavior. + +function sessModalEnsure() { + if (document.getElementById('sessModalBg')) { return; } + var bg = document.createElement('div'); + bg.id = 'sessModalBg'; + bg.className = 'gbModalBg'; + bg.style.display = 'none'; + bg.innerHTML = '<div class="gbModal" role="dialog" aria-modal="true" id="sessModal"></div>'; + document.getElementById('sessionApp').appendChild(bg); + $(bg).on('click', function(ev) { if (ev.target === this) { sessModalClose(); } }); + $(document).on('keydown.sessModal', function(ev) { + var b = document.getElementById('sessModalBg'); + if (b && b.style.display !== 'none' && ev.key === 'Escape') { sessModalClose(); } + }); +} + +function sessModalOpen(html) { + sessModalEnsure(); + document.getElementById('sessModal').innerHTML = html; + document.getElementById('sessModalBg').style.display = 'flex'; +} + +function sessModalClose() { + var bg = document.getElementById('sessModalBg'); + if (bg) { bg.style.display = 'none'; } +} + +// A confirmation dialog with Cancel + a primary/danger OK. opts: {title, bodyHtml, okLabel, +// okClass, onOk}. bodyHtml is caller-built safe HTML. The OK button is focused on open so the +// user can confirm with just the Enter key. +function sessConfirm(opts) { + var okClass = opts.okClass || 'primary'; + sessModalOpen( + '<div class="gbModalTitle">' + sessEnc(opts.title) + '</div>' + + '<div class="gbModalText">' + opts.bodyHtml + '</div>' + + '<div class="gbModalBtns">' + + '<button type="button" class="gbPill" id="sessCfCancel">Cancel</button>' + + '<button type="button" class="gbPill ' + okClass + '" id="sessCfOk">' + + sessEnc(opts.okLabel || 'OK') + '</button></div>'); + $('#sessCfCancel').on('click', sessModalClose); + $('#sessCfOk').on('click', function() { opts.onOk(); }); + document.getElementById('sessCfOk').focus(); +} + +// ---- session lookup / row helpers --------------------------------------- + +function sessByEnc(enc) { + var list = sessData.sessions; + for (var i = 0; i < list.length; i++) { + if (list[i].encName === enc) { return list[i]; } + } + return null; +} + +function sessRowByEnc(enc) { + // Return the DataTables row API for the session with this encName, or null. + var found = null; + sessDt.rows().every(function() { + if (this.data().encName === enc) { found = this; } + }); + return found; +} + +// ---- table cell rendering ------------------------------------------------ + +// Inline icons (Font Awesome solid paths, embedded as SVG so they do not depend on the site's Font +// Awesome version). fill:currentColor picks up the button's text/danger color. +var SESS_TRASH_SVG = '<svg class="sessIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"' + + ' aria-hidden="true"><path fill="currentColor" d="M135.2 17.7L128 32H32C14.3 32 0 46.3 0 64S14.3' + + ' 96 32 96H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H320l-7.2-14.3C307.4 6.8 296.3 0 284.2 0H163.8' + + 'c-12.1 0-23.2 6.8-28.6 17.7zM416 128H32L53.2 467c1.6 25.3 22.6 45 47.9 45H346.9c25.3 0 46.3-19.7' + + ' 47.9-45L416 128z"/></svg>'; +// Font Awesome "floppy-disk" (save) solid path. +var SESS_SAVE_SVG = '<svg class="sessIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"' + + ' aria-hidden="true"><path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64' + + ' 64 64H384c35.3 0 64-28.7 64-64V173.3c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7' + + ' 32H64zm0 96c0-17.7 14.3-32 32-32H288c17.7 0 32 14.3 32 32v64c0 17.7-14.3 32-32 32H96c-17.7 0-32' + + '-14.3-32-32V128zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/></svg>'; +// Font Awesome "lock" solid path - marks a private (not-shared) session, since sharing is the default. +var SESS_LOCK_SVG = '<svg class="sessLock" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"' + + ' aria-hidden="true"><path fill="currentColor" d="M144 144v48H304V144c0-44.2-35.8-80-80-80s-80' + + ' 35.8-80 80zM80 192V144C80 64.5 144.5 0 224 0s144 64.5 144 144v48h16c35.3 0 64 28.7 64 64V448c0' + + ' 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V256c0-35.3 28.7-64 64-64H80z"/></svg>'; + +function sessActionsHtml(row) { + // Order: Share, Edit, then the icon-only Overwrite (floppy) and Delete (trash). + var e = sessEnc(row.encName); + return '<button type="button" class="gbPill" data-act="share" data-enc="' + e + '" ' + + 'title="Copy a shareable link or change who can see this session">Share</button>' + + '<button type="button" class="gbPill" data-act="edit" data-enc="' + e + '" ' + + 'title="Rename this session or edit its description">Edit</button>' + + '<button type="button" class="gbPill" data-act="overwrite" data-enc="' + e + '" ' + + 'aria-label="Overwrite with current view" ' + + 'title="Overwrite this session with your current browser view">' + SESS_SAVE_SVG + '</button>' + + '<button type="button" class="gbPill danger" data-act="delete" data-enc="' + e + '" ' + + 'aria-label="Delete this session" title="Delete this session">' + SESS_TRASH_SVG + '</button>'; +} + +function sessNameCellHtml(row) { + var html = '<a href="' + sessEnc(row.shareUrl) + '" ' + + 'title="Load this session in the Genome Browser">' + sessEnc(row.name) + '</a>'; + if (row.description) { + html += ' <span class="sessInfo" title="' + sessEnc(row.description) + '">ⓘ</span>'; + } + // Sessions are shared by default; mark only the exceptions: a lock for private, a badge for the + // public gallery. A plain shared-by-link session gets no marker. + if (row.shared === 0) { + html += ' <span class="sessLockWrap" title="Private — only you can load this session">' + + SESS_LOCK_SVG + '</span>'; + } else if (row.shared >= 2) { + html += ' <span class="sessPublic" title="Listed in the Public Sessions gallery">Public</span>'; + } + return html; +} + +// ---- Overwrite ----------------------------------------------------------- + +function sessDoOverwrite(row) { + sessAjax(sessActParams(SESS_ACT.overwrite, row), function(resp) { + row.useCount = resp.useCount; + if (resp.created) { row.created = resp.created; } + if (resp.db) { row.db = resp.db; } + var r = sessRowByEnc(row.encName); + if (r) { r.data(row).draw(false); } + sessModalClose(); + sessMsg('Overwrote “' + row.name + '” with your current browser view.', 'ok'); + }); +} + +function sessOpenOverwrite(row) { + sessConfirm({ + title: 'Overwrite session', + bodyHtml: 'Replace the saved session <b>' + sessEnc(row.name) + '</b> with the view you are ' + + 'looking at now? The name and description stay the same; the previously saved view is lost.', + okLabel: 'Overwrite', + onOk: function() { sessDoOverwrite(row); } + }); +} + +// ---- Delete -------------------------------------------------------------- + +function sessOpenDelete(row) { + sessConfirm({ + title: 'Delete session', + bodyHtml: 'Delete the session <b>' + sessEnc(row.name) + '</b>? This cannot be undone. ' + + 'Any shared links to it will stop working.', + okLabel: 'Delete', + okClass: 'danger', + onOk: function() { + sessAjax(sessActParams(SESS_ACT.del, row), function() { + var r = sessRowByEnc(row.encName); + if (r) { r.remove().draw(false); } + var list = sessData.sessions; + for (var i = 0; i < list.length; i++) { + if (list[i].encName === row.encName) { list.splice(i, 1); break; } + } + sessModalClose(); + sessMsg('Deleted “' + row.name + '”.', 'ok'); + }); + } + }); +} + +// ---- Edit (rename + description) ---------------------------------------- + +function sessOpenEdit(row) { + var html = '<div class="gbModalTitle">Edit session</div>' + + '<label class="gbModalLabel" for="sessEditName">Name</label>' + + '<input id="sessEditName" class="gbModalInput" type="text" maxlength="255">' + + '<label class="gbModalLabel" for="sessEditDesc">Description ' + + '(shown in the Public Sessions gallery)</label>' + + '<textarea id="sessEditDesc" class="gbModalInput" rows="4"></textarea>' + + '<label class="gbModalLabel" style="font-weight:400;color:inherit;margin-top:12px">' + + '<input type="checkbox" id="sessEditPrivate"> Only I can load it ' + + '(others cannot load it, even with the link)</label>' + + '<div class="gbModalText err" id="sessEditErr" style="display:none"></div>' + + '<div class="gbModalBtns">' + + '<button type="button" class="gbPill" id="sessEditCancel">Cancel</button>' + + '<button type="button" class="gbPill primary" id="sessEditOk">Save</button></div>'; + sessModalOpen(html); + document.getElementById('sessEditName').value = row.name; + document.getElementById('sessEditDesc').value = row.description || ''; + document.getElementById('sessEditPrivate').checked = (row.shared === 0); + document.getElementById('sessEditName').focus(); + $('#sessEditCancel').on('click', sessModalClose); + $('#sessEditOk').on('click', function() { sessSaveEdit(row); }); +} + +function sessSaveEdit(row) { + var newName = document.getElementById('sessEditName').value.trim(); + var newDesc = document.getElementById('sessEditDesc').value; + if (!newName) { + var err = document.getElementById('sessEditErr'); + err.style.display = 'block'; + err.innerHTML = sessEnc('Please enter a name.'); + return; + } + var descChanged = (newDesc !== (row.description || '')); + var nameChanged = (newName !== row.name); + var wantPrivate = document.getElementById('sessEditPrivate').checked; + var privChanged = (wantPrivate !== (row.shared === 0)); + + // Order matters: apply description and privacy while the session still has its OLD name, then + // rename last (renaming changes the DB key the other endpoints look up by). + function finish() { + if (nameChanged) { + // encName changes on rename; reload for authoritative state. + window.location.reload(); + } else { + var r = sessRowByEnc(row.encName); + if (r) { r.data(row).draw(false); } + sessModalClose(); + sessMsg('Saved changes to “' + row.name + '”.', 'ok'); + } + } + function doRename() { + if (nameChanged) { + var rp = sessActParams(SESS_ACT.rename, row); + rp[SESS_P.newName] = newName; + sessAjax(rp, finish, function(m) { sessEditError(m); }); + } else { finish(); } + } + function doPriv() { + if (privChanged) { + var sp = sessActParams(SESS_ACT.share, row); + sp[SESS_P.share] = wantPrivate ? 0 : 1; + sessAjax(sp, function(resp) { row.shared = resp.shared; doRename(); }, + function(m) { sessEditError(m); }); + } else { doRename(); } + } + function doDesc() { + if (descChanged) { + var dp = sessActParams(SESS_ACT.describe, row); + dp[SESS_P.descr] = newDesc; + sessAjax(dp, function() { row.description = newDesc; doPriv(); }, + function(m) { sessEditError(m); }); + } else { doPriv(); } + } + doDesc(); +} + +function sessEditError(msg) { + var err = document.getElementById('sessEditErr'); + if (err) { err.style.display = 'block'; err.innerHTML = sessEnc(msg); } + else { sessMsg(msg, 'err'); } +} + +// ---- Share (copy link + sharing level) ---------------------------------- + +function sessOpenShare(row) { + var mailBody = encodeURIComponent('Here is a UCSC Genome Browser session I would like to ' + + 'share with you: ') + encodeURIComponent(row.shareUrl); + var mailto = 'mailto:?subject=' + encodeURIComponent('UCSC Genome Browser session ' + row.name) + + '&body=' + mailBody; + var html = '<div class="gbModalTitle">Share “' + sessEnc(row.name) + '”</div>' + + '<div class="gbShareBox" style="padding:0;border:0;background:none">' + + '<input id="sessShareUrl" class="gbShareInput" type="text" readonly>' + + '<button type="button" class="gbPill" id="sessShareCopy" ' + + 'title="Copy the link to the clipboard">Copy</button>' + + '<a class="gbPill" id="sessShareEmail" href="' + sessEnc(mailto) + '" ' + + 'title="Compose an email with this link">Email</a></div>' + + '<div class="gbModalText" style="margin-top:14px">Sessions are loadable by anyone with the ' + + 'link by default; use <b>Edit</b> to make one private.</div>' + + '<label class="gbModalLabel" style="font-weight:400;color:inherit">' + + '<input type="checkbox" id="sessGalleryChk"> List it in the ' + + '<a href="' + sessEnc(sessData.config.publicSessionsUrl) + '" target="_blank">' + + 'Public Sessions</a> gallery</label>' + + '<div class="gbModalText err" id="sessShareErr" style="display:none"></div>' + + '<div class="gbModalBtns">' + + '<button type="button" class="gbPill primary" id="sessShareClose">Done</button></div>'; + sessModalOpen(html); + var inp = document.getElementById('sessShareUrl'); + inp.value = row.shareUrl; + document.getElementById('sessGalleryChk').checked = (row.shared >= 2); + $('#sessShareClose').on('click', sessModalClose); + $('#sessShareCopy').on('click', function() { + inp.focus(); inp.select(); + if (navigator.clipboard) { navigator.clipboard.writeText(row.shareUrl); } + else { try { document.execCommand('copy'); } catch (e) { /* ignore */ } } + this.textContent = 'Copied'; + }); + $('#sessGalleryChk').on('change', function() { sessSetGallery(row, this.checked ? 1 : 0); }); +} + +function sessShareErr(msg) { + var err = document.getElementById('sessShareErr'); + if (err) { err.style.display = 'block'; err.innerHTML = sessEnc(msg); } +} + +function sessAfterSharedChange(row, newShared) { + row.shared = newShared; + var c = document.getElementById('sessShareChk'); + var g = document.getElementById('sessGalleryChk'); + if (c) { c.checked = (newShared >= 1); } + if (g) { g.checked = (newShared >= 2); } + var r = sessRowByEnc(row.encName); + if (r) { r.data(row).draw(false); } +} + +function sessSetGallery(row, want) { + var p = sessActParams(SESS_ACT.gallery, row); + p[SESS_P.share] = want; + sessAjax(p, function(resp) { sessAfterSharedChange(row, resp.shared); }, function(m) { + sessShareErr(m); + document.getElementById('sessGalleryChk').checked = (row.shared >= 2); + }); +} + +// Build the base params for an action on a given session (decoded name; the CGI re-encodes it). +function sessActParams(action, row) { + var p = {}; + p[action] = '1'; + p[SESS_P.oldName] = row.name; + return p; +} + +// ---- Save current view --------------------------------------------------- + +function sessDoSave() { + var name = document.getElementById('sessSaveName').value.trim(); + if (!name) { + // Empty name: offer to save under a server-style random "share_XXXXXXXX" name, after + // confirming the user really meant to leave it blank. + var rand = sessRandomShareName(); + sessConfirm({ + title: 'Save without a name?', + bodyHtml: 'You left the session name empty. Your session will be saved under the ' + + 'randomly generated name <b>' + sessEnc(rand) + '</b>.<br><br>You can also create ' + + 'these quick share links any time from the <b>Share a link</b> option at the top ' + + 'right of every Genome Browser page.', + okLabel: 'Save session', + onOk: function() { sessModalClose(); sessDoSaveWithName(rand); } + }); + return; + } + sessDoSaveWithName(name); +} + +function sessDoSaveWithName(name) { + var priv = document.getElementById('sessSavePrivate').checked; + var descEl = document.getElementById('sessSaveDesc'); + var desc = descEl ? descEl.value.trim() : ''; + var p = {}; + p[SESS_ACT.save] = '1'; + p[SESS_P.newName] = name; + // doSaveSessionJson always saves shared-by-link (the default); chain the optional description + // and, if the user asked for "only I can load it", make it private, then reload to show the row. + function afterDesc() { + if (priv) { + var sp = {}; + sp[SESS_ACT.share] = '1'; + sp[SESS_P.oldName] = name; + sp[SESS_P.share] = 0; + sessAjax(sp, function() { window.location.reload(); }, + function() { window.location.reload(); }); + } else { + window.location.reload(); + } + } + sessAjax(p, function() { + if (desc) { + var dp = {}; + dp[SESS_ACT.describe] = '1'; + dp[SESS_P.oldName] = name; + dp[SESS_P.descr] = desc; + sessAjax(dp, afterDesc, afterDesc); + } else { + afterDesc(); + } + }); +} + +// ---- Advanced panel (navigation forms) ---------------------------------- + +function sessAdvancedHtml(C) { + var sid = '<input type="hidden" name="' + sessEnc(C.cartVar) + '" value="' + sessEnc(C.hgsid) + '">'; + var loadUser = ''; + if (C.loggedIn) { + loadUser = + '<div class="sessAdvItem"><span class="lab">Load another user’s session</span>' + + '<form class="sessAdvRow" action="hgSession" method="POST">' + sid + + '<input class="sessAdvInput" type="text" name="hgS_otherUserName" placeholder="User">' + + '<input class="sessAdvInput" type="text" name="hgS_otherUserSessionName" placeholder="Session name">' + + '<button type="submit" class="gbPill" name="hgS_doOtherUser" value="submit" ' + + 'title="Load the named session belonging to another user">Load</button></form></div>'; + } + var loadUrl = + '<div class="sessAdvItem"><span class="lab">Load settings from a URL</span>' + + '<form class="sessAdvRow" action="hgSession" method="POST">' + sid + + '<input class="sessAdvInput" type="text" name="hgS_loadUrlName" placeholder="https://…">' + + '<button type="submit" class="gbPill" name="hgS_doLoadUrl" value="submit" ' + + 'title="Load browser settings from a session file at this URL">Load</button></form></div>'; + var loadFile = + '<div class="sessAdvItem"><span class="lab">Load settings from a file</span>' + + '<form class="sessAdvRow" action="hgSession" method="POST" enctype="multipart/form-data">' + sid + + '<input class="sessAdvInput" type="file" name="hgS_loadLocalFileName">' + + '<button type="submit" class="gbPill" name="hgS_doLoadLocal" value="submit" ' + + 'title="Load browser settings from a session file on your computer">Load</button></form></div>'; + var saveFile = + '<div class="sessAdvItem"><span class="lab">Save settings to a file</span>' + + '<form class="sessAdvRow" action="hgSession" method="POST">' + sid + + '<input class="sessAdvInput" type="text" name="hgS_saveLocalFileName" ' + + 'placeholder="File name (blank = show in browser)">' + + '<label class="sessSaveCheck"><input type="checkbox" name="hgS_saveLocalFileCompress" ' + + 'value="gzip"> gzip</label>' + + '<button type="submit" class="gbPill" name="hgS_doSaveLocal" value="submit" ' + + 'title="Download the current browser settings as a session file">Save</button></form></div>'; + var backup = + '<div class="sessAdvItem"><span class="lab">Back up custom tracks</span>' + + '<form class="sessAdvRow" action="hgSession" method="POST">' + sid + + '<button type="submit" class="gbPill" name="hgS_showDownload_" value="Submit" ' + + 'title="Download your custom tracks as a .tar.gz archive you can reload later">' + + 'Back up custom tracks (.tar.gz)</button></form></div>'; + var other = + '<div class="sessAdvItem"><span class="lab">Other</span>' + + '<div class="sessAdvLinks">' + + '<a href="' + sessEnc(C.resetUrl) + '" ' + + 'title="Reset all browser settings to their defaults">Reset the browser to defaults</a>' + + '</div></div>'; + + return '<div class="sessAdv">' + + '<div class="sessAdvHead" id="sessAdvHead"><span class="caret">▸</span>' + + '<span>Advanced — load another user’s session, load from a URL or file, ' + + 'save to a file, reset the browser</span></div>' + + '<div class="sessAdvBody" id="sessAdvBody" style="display:none">' + + loadUser + loadUrl + loadFile + saveFile + backup + other + '</div></div>'; +} + +// ---- build the whole page ----------------------------------------------- + +function sessAccountHtml(C) { + // The signed-in account line (Signed in as X · Sign out · Change password) now lives in the + // top-right menu bar, so it is intentionally not rendered here. Kept commented out so QA can + // add it back if wanted: + /* + if (C.loggedIn) { + var s = 'Signed in as <b>' + sessEnc(C.userName) + '</b>'; + if (C.logoutUrl) { s += ' · <a href="' + sessEnc(C.logoutUrl) + '">Sign out</a>'; } + if (C.changePasswordUrl) { + s += ' · <a href="' + sessEnc(C.changePasswordUrl) + '">Change password</a>'; + } + return s; + } + */ + if (!C.loggedIn && C.loginAvail && C.loginUrl) { + return 'You are not signed in. <a href="' + sessEnc(C.loginUrl) + + '">Sign in</a> to save and manage named sessions.'; + } + return ''; +} + +function sessSaveCardHtml(C) { + if (!C.loggedIn) { return ''; } + // Assembly and position separated by a colon (e.g. "hg38: chr7:1-1,000"); assembly is already + // the accession for hubs (trackHubSkipHubName on the server). + var loc = ''; + if (C.db && C.position) { loc = sessEnc(C.db) + ': ' + sessEnc(C.position); } + else if (C.db) { loc = sessEnc(C.db); } + else if (C.position) { loc = sessEnc(C.position); } + if (loc && C.trackCount) { + loc += ', ' + sessNum(C.trackCount) + ' track' + (C.trackCount === 1 ? '' : 's') + ' shown'; + } + var what = loc ? '<span class="sessSaveWhat">' + loc + '</span>' : ''; + return '<div class="sessSaveCard">' + + '<div class="sessSaveHead"><span class="sessSaveTitle">' + + 'Save the current view as a stable session link</span>' + what + '</div>' + + '<div class="sessSaveRow">' + + '<input id="sessSaveName" class="sessSaveInput" type="text" maxlength="255" ' + + 'placeholder="Session name — or leave empty to save with a randomly generated name">' + + '<label class="sessSaveCheck"><input type="checkbox" id="sessSavePrivate"> ' + + 'Only I can load it</label>' + + '<button type="button" class="gbPill primary" id="sessSaveBtn" ' + + 'title="Save your current browser view as a named session">Save session</button>' + + '</div>' + + '<input id="sessSaveDesc" class="sessSaveInput" type="text" maxlength="512" ' + + 'placeholder="Description (optional) — shown on hover and in the Public Sessions gallery">' + + '</div>'; +} + +function sessRecentHtml(recent) { + // A quick shortcut to re-save the session the user most recently saved, keeping its name and + // description. Hidden when there are no saved sessions. + if (!recent) { return ''; } + return '<div class="sessRecent">Most recently saved session: <b>' + sessEnc(recent.name) + + '</b> <span class="sessRecentTime">(' + sessEnc(recent.lastUse) + ')</span> ' + + '<button type="button" class="gbPill" id="sessUpdateNow" ' + + 'title="Overwrite this session with the currently active view; keeps the session name and ' + + 'description identical">Update now</button></div>'; +} + +function sessTableHtml(C) { + if (!C.loggedIn) { return ''; } + return '<div class="gbSection">Your saved sessions</div>' + + '<table id="sessionAppTable" class="gbTable" style="width:100%"></table>'; +} + +function sessionBuild() { + sessData = hgSessionData; + var C = sessData.config; + var app = $('#sessionApp'); + + var intro = '<div class="sessIntro">' + sessAccountHtml(C) + + (sessAccountHtml(C) ? '<br>' : '') + + 'A session is a stable link to a Genome Browser view that you can save, load later, share ' + + 'or copy into a manuscript. See the ' + + '<a href="' + sessEnc(C.helpUrl) + '" target="_blank">Sessions User’s Guide</a> and the ' + + '<a href="' + sessEnc(C.galleryUrl) + '" target="_blank">Session Gallery</a>.</div>'; + + // The session most recently saved/overwritten (max lastUse), for the one-click "Update now". + var recent = null; + (sessData.sessions || []).forEach(function(s) { + if (!recent || s.lastUseEpoch > recent.lastUseEpoch) { recent = s; } + }); + + app.html( + intro + + '<div id="sessMsg" class="sessMsg"></div>' + + sessRecentHtml(recent) + + sessSaveCardHtml(C) + + sessAdvancedHtml(C) + + sessTableHtml(C) + ); + + // Save current view. Enter in either the name or the description field saves. + $('#sessSaveBtn').on('click', sessDoSave); + $('#sessSaveName, #sessSaveDesc').on('keydown', function(ev) { + if (ev.key === 'Enter') { ev.preventDefault(); sessDoSave(); } + }); + + // One-click update of the most recently saved session. + if (recent) { + $('#sessUpdateNow').on('click', function() { + sessConfirm({ + title: 'Update session', + bodyHtml: 'Overwrite <b>' + sessEnc(recent.name) + '</b> with the view you are ' + + 'looking at now? The session name and description stay the same.', + okLabel: 'Update now', + onOk: function() { sessDoOverwrite(recent); } + }); + }); + } + + // Advanced toggle. + $('#sessAdvHead').on('click', function() { + var body = document.getElementById('sessAdvBody'); + var open = body.style.display !== 'none'; + body.style.display = open ? 'none' : 'grid'; + $(this).find('.caret').html(open ? '▸' : '▾'); + }); + + // Session table. + if (C.loggedIn) { sessBuildTable(); } + + if (typeof convertTitleTagsToMouseovers === 'function') { convertTitleTagsToMouseovers(); } +} + +// ---- bulk select / delete ------------------------------------------------ + +function sessToggleSelect() { + if (sessSelectMode) { sessDeleteSelected(); } else { sessSetSelectMode(true); } +} + +function sessSetSelectMode(on) { + sessSelectMode = on; + sessDt.column(0).visible(on); // the leading checkbox column + var b = document.getElementById('sessSelectBtn'); + if (on) { + b.textContent = 'Delete all selected'; + b.classList.add('primary'); + } else { + b.textContent = 'Select'; + b.classList.remove('primary'); + $('#sessionAppTable .sessSelChk, #sessSelAll').prop('checked', false); + } +} + +function sessDeleteSelected() { + var encs = []; + $('#sessionAppTable tbody .sessSelChk:checked').each(function() { + encs.push(this.getAttribute('data-enc')); + }); + if (encs.length === 0) { sessSetSelectMode(false); return; } // nothing picked: just exit + sessConfirm({ + title: 'Delete selected sessions', + bodyHtml: 'Delete <b>' + encs.length + '</b> selected session' + + (encs.length === 1 ? '' : 's') + '? This cannot be undone.', + okLabel: 'Delete ' + encs.length, + okClass: 'danger', + onOk: function() { + sessModalClose(); + var remaining = encs.length; + function done() { if (--remaining === 0) { window.location.reload(); } } + encs.forEach(function(enc) { + var row = sessByEnc(enc); + if (!row) { done(); return; } + sessAjax(sessActParams(SESS_ACT.del, row), done, done); + }); + } + }); +} + +function sessBuildTable() { + sessDt = $('#sessionAppTable').DataTable({ + data: sessData.sessions, + pageLength: 15, + lengthChange: true, + lengthMenu: [[15, 50, 100, -1], [15, 50, 100, 'All']], + order: [[3, 'desc']], // newest (Created) first by default + dom: '<"sessToolbar"fl>rt<"sessFoot"ip>', + language: { searchPlaceholder: 'Search sessions', search: '', lengthMenu: 'Show _MENU_', + emptyTable: 'No saved sessions yet.' }, + columns: [ + { title: '<input type="checkbox" id="sessSelAll" title="Select all on this page">', + className: 'sessSelCol', data: null, orderable: false, searchable: false, visible: false, + render: function(d, type, row) { + return '<input type="checkbox" class="sessSelChk" data-enc="' + + sessEnc(row.encName) + '">'; } }, + { title: 'Session', className: 'sessNameCol', data: 'name', + render: function(d, type, row) { + return (type === 'display') ? sessNameCellHtml(row) : row.name; } }, + { title: 'Assembly', data: 'db', + render: function(d, type, row) { + if (type !== 'display') { return row.db || ''; } + var html = sessEnc(row.db || 'n/a'); + if (row.position) { + html += ' <span class="sessPos">' + sessEnc(sessCommifyPos(row.position)) + + '</span>'; + } + return html; } }, + { title: 'Created', data: 'createdEpoch', + render: function(d, type, row) { + if (type !== 'display') { return row.createdEpoch; } + return '<span title="Last used: ' + sessEnc(row.lastUse) + '">' + + sessEnc(row.created) + '</span>'; } }, + { title: 'Views', data: 'useCount', + render: function(d, type) { return (type === 'display') ? sessNum(d) : d; } }, + { title: 'Actions', className: 'sessActionsCol', data: null, orderable: false, + searchable: false, + render: function(d, type, row) { return (type === 'display') ? sessActionsHtml(row) : ''; } } + ] + }); + + // Delegate the row action buttons. + $('#sessionAppTable tbody').on('click', 'button[data-act]', function() { + var act = this.getAttribute('data-act'); + var row = sessByEnc(this.getAttribute('data-enc')); + if (!row) { return; } + if (act === 'overwrite') { sessOpenOverwrite(row); } + else if (act === 'share') { sessOpenShare(row); } + else if (act === 'edit') { sessOpenEdit(row); } + else if (act === 'delete') { sessOpenDelete(row); } + }); + + // Bulk-select control: a "Select" button after the length dropdown reveals a checkbox column and + // becomes a primary "Delete all selected" button. + var selBtn = document.createElement('button'); + selBtn.type = 'button'; + selBtn.id = 'sessSelectBtn'; + selBtn.className = 'gbPill'; + selBtn.textContent = 'Select'; + selBtn.title = 'Select multiple sessions to delete them at once'; + document.querySelector('#sessionApp .sessToolbar').appendChild(selBtn); + $(selBtn).on('click', sessToggleSelect); + // Header "select all" toggles every checkbox on the current page. + $('#sessionAppTable').on('change', '#sessSelAll', function() { + $('#sessionAppTable tbody .sessSelChk').prop('checked', this.checked); + }); + + // Column-header tooltips (set title, then let utils.js convert to styled mouseovers). + var tips = { + 'Session': 'Click a name to load that session in the Genome Browser', + 'Assembly': 'The genome assembly this session was saved on', + 'Created': 'When the session was first saved', + 'Views': 'How many times this session has been loaded', + 'Actions': 'Overwrite with your current view, share, edit, or delete' + }; + $('#sessionAppTable thead th').each(function() { + var t = tips[$(this).text().trim()]; + if (t) { $(this).attr('title', t); } + }); +} + +$(document).ready(function() { + if (typeof hgSessionData !== 'undefined' && document.getElementById('sessionApp')) { + sessionBuild(); + } +});