9f8d33c8b2b6bc61f6d02d781c4e02836f7099f9 max Fri Aug 21 02:05:42 2026 -0700 hgSession: new opt-in JavaScript "My Sessions" page; share gbModern.css with hgBlat. refs #38157 Applies the hgBlat facelift strategy (#37996) to hgSession: an opt-in, client-rendered "My Sessions" page gated by the sessionNewPage / sessionNewPageBanner hg.conf flags (mirroring blatNewForm / blatNewFormBanner), with a banner linking between the classic and new pages so neither is a one-way door. sessionNewPage also flips the site default. hgSession.c stays the data/action backend: it emits the session list and page config as an inline JSON global (hgSessionData) into an empty #sessionApp container, and the new hgSession.js builds the UI - a save-current-view card (name + optional description + "only I can load it"; empty name saves under a random share_ name), a "most recently saved session" one-click Update, a searchable/sortable/paged DataTable of sessions (assembly + position, created with last-used on hover, views, a lock icon on private sessions), inline Share (copy link / email / gallery), Edit (rename + description + private), Overwrite and Delete, and a bulk Select -> Delete-all-selected mode. The mutating actions POST to new JSON endpoints (hgS_doDeleteJson / doShareJson / doGalleryJson / doOverwriteJson / doDescribeJson) that run the same SQL as the classic full-page handlers and return JSON, so the table updates in place; loads, file up/downloads and custom-track backup stay as ordinary form submits/links. The Advanced panel keeps feature parity with the classic page (load another user's session, load from URL/file, save to file, back up custom tracks, reset), minus the login/ change-password links that now live in the top menu. Shared UCSC house-style components (design tokens, .gbPill, .gbCard, .gbStrip, .gbSection, .gbShareBox, .gbBanner, the .gbModal* dialog and a .gbTable) are factored into a new gbModern.css. hgBlat is migrated onto it: its generic .blat* classes are renamed to the shared .gb* names in hgBlat.css / hgBlat.js and the #blatResults / #blatFormBox containers get class="gbApp"; verified pixel-clean against the previous search form and results pages, including the rename modal. hgSession.css holds only session-specific layout. diff --git src/hg/js/hgSession.js src/hg/js/hgSession.js new file mode 100644 index 00000000000..1229d32ab16 --- /dev/null +++ src/hg/js/hgSession.js @@ -0,0 +1,792 @@ +// 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 */ + +// 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' +}; + +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() { + // Mirror the server's auto/anonymous share-name convention ("share_" + 8 URL-safe alphanumeric + // chars). hgSession.c's doSaveSessionJson generates the same style server-side for the top-right + // "Share a link"; we generate it here so the confirm dialog can show the name before saving. + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + var s = ''; + for (var i = 0; i < 8; i++) { + s += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return 'share_' + s; +} + +function sessCommifyPos(pos) { + // Add thousands separators to each number in a position string ("chr7:155799529-155812871" -> + // "chr7:155,799,529-155,812,871") using utils.js commify. + if (typeof commify !== 'function') { return String(pos); } + return String(pos).replace(/\d+/g, function(n) { return commify(n); }); +} + +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(); +} + +// ---- 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; +} + +// ---- 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) solid path. +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 += ' '; + } + // 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); }); + } 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) { + 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 server-style random "share_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) { + 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; + // 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. + function afterDesc() { + if (priv) { + 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(p, function() { + if (desc) { + var dp = {}; + dp[SESS_ACT.describe] = '1'; + dp[SESS_P.oldName] = name; + dp[SESS_P.descr] = desc; + sessAjax(dp, afterDesc, afterDesc); + } else { + afterDesc(); + } + }); +} + +// ---- 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; + 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(); } +} + +// ---- 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'); + if (row.position) { + html += ' ' + sessEnc(sessCommifyPos(row.position)) + + ''; + } + return html; } }, + { title: 'Created', data: 'createdEpoch', + render: function(d, type, row) { + if (type !== 'display') { return row.createdEpoch; } + return '' + + sessEnc(row.created) + ''; } }, + { 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) : ''; } } + ] + }); + + // 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', + '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(); + } +});