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 = '
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 =
'
';
}
// ---- 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 '
';
}
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();
}
});