0cdd15681f10789132dc9e88fcf4be23bd47ee4b max Wed Sep 9 09:14:37 2026 -0700 New Sessions page: Replace on the save card keeps the session's sharing level Confirming Replace re-saved through the save endpoint, which always writes a session as shared by link. Replacing a session that was in the public listing took it off the list, and replacing a private one made it loadable by anyone with the link. Replace now goes through the overwrite endpoint, which reads the row's sharing level and keeps it - the same endpoint the floppy button on each table row already uses. The description and the "only I can load it" box on the save card are still applied afterwards, refs #38311 diff --git src/hg/js/hgSession.js src/hg/js/hgSession.js index 0ed45401fa3..15160be5b58 100644 --- src/hg/js/hgSession.js +++ src/hg/js/hgSession.js @@ -1,913 +1,925 @@ // 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, titleTagToMouseover, addMouseover */ /* global htmlEncode, commify, gbShowTimingDialog */ // 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', failIfExists: 'hgS_failIfExists' }; 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() { // Auto-name convention for machine-generated session names: a leading "_" (which marks the name // as internally generated, and is kept verbatim by the short-link encoder) followed by 8 URL-safe // alphanumeric chars. This is the single source of the convention: the top-right "Share a link" // menu (topLinks.js) and hgSession.c both defer to a name generated here. var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; var s = ''; for (var i = 0; i < 8; i++) { s += chars.charAt(Math.floor(Math.random() * chars.length)); } return '_' + s; } function sessMbpPos(pos) { // Shorten a position to megabases to save horizontal space: // "chr7:155799529-155812871" -> "chr7:155.80-155.81 Mbp". Falls back to the raw string // if it doesn't parse. var m = /^(.+):([0-9,]+)-([0-9,]+)$/.exec(String(pos)); if (!m) { return String(pos); } var s = parseInt(m[2].replace(/,/g, ''), 10); var e = parseInt(m[3].replace(/,/g, ''), 10); if (isNaN(s) || isNaN(e)) { return String(pos); } return m[1] + ':' + (s / 1e6).toFixed(2) + '-' + (e / 1e6).toFixed(2) + ' Mbp'; } 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 = ''; 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( '
' + sessEnc(opts.title) + '
' + '
' + opts.bodyHtml + '
' + '
' + '' + '
'); $('#sessCfCancel').on('click', sessModalClose); $('#sessCfOk').on('click', function() { opts.onOk(); }); document.getElementById('sessCfOk').focus(); } // A notice with one button, for something the user has to see before the page reloads underneath // them and takes the status line with it. bodyHtml is caller-built safe HTML. function sessAlert(title, bodyHtml, onOk) { sessModalOpen( '
' + sessEnc(title) + '
' + '
' + bodyHtml + '
' + '
' + '
'); $('#sessAlertOk').on('click', function() { sessModalClose(); if (onOk) { onOk(); } }); document.getElementById('sessAlertOk').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; } function sessApplyTooltips() { // Runs after every table draw, because DataTables renders rows on demand (page two, a re-sort) // and the one-time conversion utils.js does at page load never sees those. Two jobs: // - give the plain title attributes in the new rows the same styled mouseovers as the rest of // the page, skipping whatever has already been converted; // - hand each info bubble its session description. The description is the user's own text and // the tooltip is inserted with innerHTML, so it is passed already escaped: markup in a // description then reads as the characters that were typed instead of being parsed as HTML. if (typeof titleTagToMouseover !== 'function' || typeof addMouseover !== 'function') { return; } var $table = $('#sessionAppTable'); $table.find('[title]').each(function() { if (this.title && this.getAttribute('mouseoverText') === null) { titleTagToMouseover(this); } }); $table.find('span.sessInfo[data-enc]').each(function() { var row = sessByEnc(this.getAttribute('data-enc')); if (row && row.description) { addMouseover(this, sessEnc(row.description)); } }); } // ---- 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 = ''; // Font Awesome "floppy-disk" (save) regular/outline path - reads more clearly as a save icon. var SESS_SAVE_SVG = ''; // Font Awesome "lock" solid path - marks a private (not-shared) session, since sharing is the default. var SESS_LOCK_SVG = ''; function sessActionsHtml(row) { // Order: Share, Edit, then the icon-only Overwrite (floppy) and Delete (trash). var e = sessEnc(row.encName); return '' + '' + '' + ''; } function sessNameCellHtml(row) { var html = '' + sessEnc(row.name) + ''; if (row.description) { // No title attribute here: the description is the user's own text and the tooltip machinery // in utils.js inserts its text with innerHTML, so a title would have markup in a description // parsed as HTML. sessApplyTooltips() attaches it, escaped, after the row is drawn. html += ' '; } // 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 += ' ' + SESS_LOCK_SVG + ''; } else if (row.shared >= 2) { html += ' Public'; } 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 ' + sessEnc(row.name) + ' 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 ' + sessEnc(row.name) + '? 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 = '
Edit session
' + '' + '' + '' + '' + '' + '' + '
' + '' + '
'; 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; // A rename moves the public-listing thumbnail, which can fail on its own (a mirror with // no ImageMagick); say so before the reload takes the message away. sessAjax(rp, function(resp) { if (resp && resp.warning) { sessAlert('Session renamed', sessEnc(resp.warning), finish); } else { 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 = '
Share “' + sessEnc(row.name) + '”
' + '
' + '' + '' + 'Email
' + '
Sessions are loadable by anyone with the ' + 'link by default; use Edit to make one private.
' + '' + '' + '
' + '
'; 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 g = document.getElementById('sessGalleryChk'); 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); // The listing itself worked; the picture for it may not have (e.g. a mirror without // ImageMagick convert). Show what the server said rather than a bare success. if (resp && resp.warning) { sessShareErr(resp.warning); } }, 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 random internal "_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 ' + sessEnc(rand) + '.

You can also create ' + 'these quick share links any time from the Share a link option at the top ' + 'right of every Genome Browser page.', okLabel: 'Save session', onOk: function() { sessModalClose(); sessDoSaveWithName(rand); } }); return; } sessDoSaveWithName(name); } -function sessDoSaveWithName(name, allowOverwrite) { +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; // Saving under a name you are already using replaces that session's contents, so ask first. // With failIfExists set the CGI answers {exists: true} instead of saving, which is how the // top-right "Share a link" menu handles the same collision. - if (!allowOverwrite) { p[SESS_P.failIfExists] = '1'; } + p[SESS_P.failIfExists] = '1'; // 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. // The session is saved by the time those run, so a failure in one of them has to be reported: a // silent reload would leave a session sitting there shared by link, or with no description, and // tell the user nothing. function reload() { window.location.reload(); } function partlySaved(problem) { sessAlert('Session saved, with a problem', 'Your session ' + sessEnc(name) + ' was saved, but ' + sessEnc(problem), reload); } function afterDesc() { if (!priv) { reload(); return; } var sp = {}; sp[SESS_ACT.share] = '1'; sp[SESS_P.oldName] = name; sp[SESS_P.share] = 0; sessAjax(sp, reload, function(m) { partlySaved('it could not be made private, so anyone with the link can still load it. ' + m); }); } function afterSave() { if (!desc) { afterDesc(); return; } var dp = {}; dp[SESS_ACT.describe] = '1'; dp[SESS_P.oldName] = name; dp[SESS_P.descr] = desc; sessAjax(dp, afterDesc, function(m) { partlySaved('the description could not be saved. ' + m); }); } + function replace() { + // Replace goes through the overwrite endpoint rather than saving again. A save always + // writes the session as shared by link, which would take a session out of the public + // gallery, or make a private one loadable by anyone holding the link. Overwrite keeps + // whatever sharing level the session already had, and the chained steps below still apply + // the description and the "only I can load it" box if the user set them. + var op = {}; + op[SESS_ACT.overwrite] = '1'; + op[SESS_P.oldName] = name; + sessAjax(op, afterSave); + } sessAjax(p, function(resp) { if (resp && resp.exists) { sessConfirm({ title: 'Replace this session?', bodyHtml: 'You already have a session named ' + sessEnc(name) + '. Replacing ' + 'it points that name at the view you are looking at now, and what the session ' + - 'held before is gone.', + 'held before is gone.' + + (priv ? '' : ' Who can load it stays as it is.'), okLabel: 'Replace it', - onOk: function() { sessModalClose(); sessDoSaveWithName(name, true); } + onOk: function() { sessModalClose(); replace(); } }); return; } afterSave(); }); } // ---- Advanced panel (navigation forms) ---------------------------------- function sessAdvancedHtml(C) { var sid = ''; var loadUser = ''; if (C.loggedIn) { loadUser = '
Load another user’s session' + '
' + sid + '' + '' + '
'; } var loadUrl = '
Load settings from a URL' + '
' + sid + '' + '
'; var loadFile = '
Load settings from a file' + '
' + sid + '' + '
'; var saveFile = '
Save settings to a file' + '
' + sid + '' + '' + '
'; var backup = '
Back up custom tracks' + '
' + sid + '
'; var other = '
Other' + '
'; return '
' + '
' + 'Advanced — load another user’s session, load from a URL or file, ' + 'save to a file, reset the browser
' + '
'; } // ---- 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 ' + sessEnc(C.userName) + ''; if (C.logoutUrl) { s += ' · Sign out'; } if (C.changePasswordUrl) { s += ' · Change password'; } return s; } */ if (!C.loggedIn && C.loginAvail && C.loginUrl) { return 'You are not signed in. Sign in 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 ? '' + loc + '' : ''; return '
' + '
' + 'Save the current view as a stable session link' + what + '
' + '
' + '' + '' + '' + '
' + '' + '
'; } 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 '
Most recently saved session: ' + sessEnc(recent.name) + ' (' + sessEnc(recent.lastUse) + ') ' + '
'; } function sessTableHtml(C) { if (!C.loggedIn) { return ''; } return '
Your saved sessions
' + '
'; } function sessionBuild() { sessData = hgSessionData; // When the page was loaded with &measureTiming=1 the C side attaches sessData.timing; time the // client-side render too so the dialog shows the full server+client picture. var tBuildStart = (sessData.timing && window.performance) ? performance.now() : 0; var C = sessData.config; var app = $('#sessionApp'); var intro = '
' + sessAccountHtml(C) + (sessAccountHtml(C) ? '
' : '') + '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 ' + 'Sessions User’s Guide and the ' + 'Session Gallery.
'; // 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 + '
' + 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 ' + sessEnc(recent.name) + ' 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(); } if (C.loggedIn) { sessApplyTooltips(); } // Timing report (only when loaded with &measureTiming=1): a pill that opens the shared dialog. if (sessData.timing) { var clientRows = [{ label: 'build page (JS)', ms: Math.round(performance.now() - tBuildStart) }]; var pill = document.createElement('button'); pill.type = 'button'; pill.className = 'gbPill'; pill.id = 'sessTimingBtn'; pill.innerHTML = '⏱ Timing'; pill.title = 'Show where this page spent its time (server and browser)'; pill.addEventListener('click', function() { gbShowTimingDialog(sessData.timing, clientRows); }); var bar = document.querySelector('#sessionApp .sessToolbar') || document.getElementById('sessionApp'); bar.appendChild(pill); // measureTiming=1 on the URL is an explicit request to see the numbers, so open the dialog // right away; the pill stays for reopening it after Close. gbShowTimingDialog(sessData.timing, clientRows); } } // ---- 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 ' + encs.length + ' 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: '', className: 'sessSelCol', data: null, orderable: false, searchable: false, visible: false, render: function(d, type, row) { return ''; } }, { 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'); // Region: band, locus (gene) and the position shortened to Mbp, so it all fits. var region = []; if (row.band) { region.push(sessEnc(row.band)); } if (row.locus) { region.push(sessEnc(row.locus)); } if (row.position) { region.push(sessEnc(sessMbpPos(row.position))); } if (region.length) { html += ' ' + region.join('   ') + ''; } return html; } }, { title: 'Created', data: 'createdEpoch', render: function(d, type, row) { return (type === 'display') ? '' + sessEnc(row.created) + '' : row.createdEpoch; } }, { title: 'Last used', data: 'lastUseEpoch', render: function(d, type, row) { return (type === 'display') ? '' + sessEnc(row.lastUseDate || row.lastUse) + '' : row.lastUseEpoch; } }, { 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) : ''; } } ] }); // Every redraw brings rows the tooltip conversion has not seen yet. $('#sessionAppTable').on('draw.dt', sessApplyTooltips); // 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', 'Last used': 'When the session was last saved or loaded', '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(); } });