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 = '