fc8de100a3437b9fc33bdeb0f459ca2a93e2f318 max Wed Sep 9 08:14:32 2026 -0700 hgSession: address the code review of the new Sessions page Rename and unshare now keep the public listing's thumbnail with the session it belongs to. The picture's file name is built from the encoded session name, so renaming a listed session left the listing pointing at nothing and the old file behind, and dropping a session from the listing to a plain shared link kept the picture. The classic page had the same problem in a subtler form: it removed the thumbnail after the row had already been renamed, so the old file survived. Saving under a name that is already in use asks before it replaces that session, using the failIfExists reply that the top-right Share a link menu already relies on. The description and "only I can load it" steps that follow a save now report a failure instead of reloading in silence, and what thumbnailAdd has to say when it cannot build a picture reaches the user instead of being freed unread. A session description no longer travels through a title attribute. The tooltip machinery in utils.js inserts its text with innerHTML and an attribute is decoded on the way, so a description containing angle brackets was interpreted as markup rather than shown as typed. It is attached, escaped, after each table draw, which also gives the rows DataTables renders later the same styled mouseovers as the rest of the page. Also: the AJAX endpoints say so when there is no session by that name, instead of reporting a no-op as a success; the new page always offers its way back to the classic page, since the cart variable that got the user there sticks; and four unused CSS rules, a dead element lookup and a dead local are gone. hgConfCatalog cited the wrong ticket for the two sessionNewPage flags. refs #38180, refs #38157 diff --git src/hg/js/hgSession.js src/hg/js/hgSession.js index 1cba1a3abdd..0ed45401fa3 100644 --- src/hg/js/hgSession.js +++ src/hg/js/hgSession.js @@ -1,835 +1,913 @@ // 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, gbShowTimingDialog */ +/* 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' + 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) { - html += ' '; + // 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; - sessAjax(rp, finish, function(m) { sessEditError(m); }); + // 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 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) { + 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) { +function sessDoSaveWithName(name, allowOverwrite) { 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'; } + // 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) { + if (!priv) { reload(); return; } 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(sp, reload, function(m) { + partlySaved('it could not be made private, so anyone with the link can still load it. ' + + m); + }); } - sessAjax(p, function() { - if (desc) { + 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, afterDesc); - } else { - afterDesc(); + sessAjax(dp, afterDesc, function(m) { + partlySaved('the description could not be saved. ' + m); + }); + } + 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.', + okLabel: 'Replace it', + onOk: function() { sessModalClose(); sessDoSaveWithName(name, true); } + }); + 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(); } });