85372b41a99caf09ee398ab8ea7fc6707e3b6a0c max Fri Sep 18 09:20:10 2026 -0700 hgSession: privacy wording, wider description box, info bubbles, backup button #Preview2 week - bugs introduced now will need a build patch to fix Save-card privacy checkbox now reads "Private: Session can only be loaded by myself"; the Share dialog's gallery checkbox is normal-size text instead of a small form label. The description box is 50% wider. Info bubbles next to the Session name and Description labels explain the auto-generated name and where the description shows up. "Back up custom tracks" is a button again, at the top of the Advanced panel, and named first in that panel's summary line. refs #38311 diff --git src/hg/js/hgSession.js src/hg/js/hgSession.js index d78c515b506..10ae2247231 100644 --- src/hg/js/hgSession.js +++ src/hg/js/hgSession.js @@ -1,1075 +1,1077 @@ // 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 custom-track backup link, a searchable/sortable DataTable of saved -// sessions with inline Overwrite/Share/Edit/Delete, and an "Advanced" panel split into a load -// column and a save column. +// save-current-view card, a searchable/sortable DataTable of saved sessions with inline +// Overwrite/Share/Edit/Delete, and an "Advanced" panel (custom-track backup, a load column and a +// save column). // // 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 sessByKey(key) { // Rows are keyed by uid, not by session name: the table also holds sessions merged in from our // other servers, and the same name can exist on more than one of them. var list = sessData.sessions; for (var i = 0; i < list.length; i++) { if (list[i].uid === key) { return list[i]; } } return null; } function sessRowByKey(key) { // Return the DataTables row API for the session with this uid, or null. var found = null; sessDt.rows().every(function() { if (this.data().uid === key) { 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-key]').each(function() { var row = sessByKey(this.getAttribute('data-key')); 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). if (row.remote) { // A session on another server can only be shown here: sharing, renaming, overwriting and // deleting all go through the cart and the session table of the server that holds it. So // the one action offered is to go over there, where the full row of buttons is waiting. return ''; } var e = sessEnc(row.uid); 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 = sessRowByKey(row.uid); 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 = sessRowByKey(row.uid); if (r) { r.remove().draw(false); } var list = sessData.sessions; for (var i = 0; i < list.length; i++) { if (list[i].uid === row.uid) { 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 = sessRowByKey(row.uid); 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.
' + - '