From 98e98937590cacf46ef0eaafe20170c67f33944c Mon Sep 17 00:00:00 2001 From: Bilal El Houdaigui Date: Tue, 28 Apr 2026 16:06:31 +0100 Subject: [PATCH 1/3] attempt to improve account dropdown --- users/htdocs/components/10_Masthead_Users.js | 199 +++++++++++++----- .../Web/Document/Element/AccountLinks.pm | 53 +++-- 2 files changed, 187 insertions(+), 65 deletions(-) diff --git a/users/htdocs/components/10_Masthead_Users.js b/users/htdocs/components/10_Masthead_Users.js index 0d41f74339..d7dc6ed0ff 100644 --- a/users/htdocs/components/10_Masthead_Users.js +++ b/users/htdocs/components/10_Masthead_Users.js @@ -27,71 +27,166 @@ Ensembl.Panel.Masthead = Ensembl.Panel.Masthead.extend({ init: function () { this.base(); - this.elLk.accountHolder = this.el.find('div._account_holder'); + this.elLk.accountHolder = this.el.find('div._account_holder'); - this.accountsRefreshURL = ''; - this.accountsBookmarkData = ''; + this.accountsRefreshURL = ''; + this.accountsBookmarkData = ''; + this.accountsDropdownLoaded = false; + this.accountsDropdownLoading = false; + this.accountsDropdownCallbacks = []; - this.refreshAccountsDropdown(); + this.bindAccountsDropdown(); }, - refreshAccountsDropdown: function() { + cacheAccountsForm: function() { + var form = this.elLk.accountHolder.find('form'); + + if (form.length) { + this.accountsRefreshURL = form.attr('action'); + this.accountsBookmarkData = form.serialize(); + } + }, + + bindAccountsDropdown: function() { var panel = this; - - if (this.elLk.accountHolder.length && !this.elLk.accountHolder.find('._accounts_no_user').length) { - - var hideDropdown = function(e) { - if (!e.which || e.which === 1) { - panel.toggleAccountsDropdown(false); - $(document).off('click', hideDropdown); + + if (!this.elLk.accountHolder.length) { + return; + } + + this.cacheAccountsForm(); + + this.elLk.accountLink = this.elLk.accountHolder.find('._accounts_link').off('.accountsDropdown').on({ + 'click.accountsDropdown': function(event) { + event.preventDefault(); + + if (!$(this).hasClass('selected')) { + event.stopPropagation(); + panel.showAccountsDropdown(); + } + }, + 'focus.accountsDropdown': function() { + panel.loadAccountsDropdown(); + } + }); + + this.elLk.accountDropdown = this.elLk.accountHolder.find('._accounts_dropdown').off('.accountsDropdown').on({ + 'click.accountsDropdown': function(event) { + if (event.target.nodeName !== 'A' && event.target.parentNode.nodeName !== 'A') { + event.stopPropagation(); } } - - if (!this.accountsRefreshURL) { - var form = this.elLk.accountHolder.find('form'); - this.accountsRefreshURL = form.attr('action'); - this.accountsBookmarkData = form.serialize(); + }).find('a').off('.accountsDropdown').on('click.accountsDropdown', function(e) { + panel.hideAccountsDropdown(e); + }).end(); + + this.accountsDropdownLoaded = this.elLk.accountDropdown.length && $.trim(this.elLk.accountDropdown.html()).length ? true : false; + + this.elLk.accountHolder.find('._accounts_no_userdb').helptip().off('.accountsDropdown').on({ + 'click.accountsDropdown': function(event) { + event.preventDefault(); } - - $.ajax({ - 'url': this.accountsRefreshURL, - 'context': this, - 'data': this.accountsBookmarkData, - 'type': 'POST', - 'success': function(html) { + }); + }, + + loadAccountsDropdown: function(callback, force) { + if ($.isFunction(callback)) { + this.accountsDropdownCallbacks.push(callback); + } + + if (!this.elLk.accountHolder.length || (!force && this.elLk.accountHolder.find('._accounts_no_user').length)) { + this.accountsDropdownCallbacks = []; + return; + } + + if (this.accountsDropdownLoaded && !force) { + this.runAccountsDropdownCallbacks(); + return; + } + + if (this.accountsDropdownLoading) { + return; + } + + this.cacheAccountsForm(); + + if (!this.accountsRefreshURL) { + this.accountsRefreshURL = '/Ajax/accounts_dropdown'; + } + + this.accountsDropdownLoading = true; + + $.ajax({ + 'url': this.accountsRefreshURL, + 'context': this, + 'data': this.accountsBookmarkData, + 'type': 'POST', + 'success': function(html) { + var response = $('
').html(html); + var accountDropdown = response.find('._accounts_dropdown'); + + if (!force && accountDropdown.length && this.elLk.accountHolder.find('._accounts_link').length) { + this.elLk.accountHolder.find('._accounts_dropdown').replaceWith(accountDropdown); + } else { this.elLk.accountHolder.html(html); - this.elLk.accountLink = this.el.find('._accounts_link').on({ - 'click': function(event) { - event.preventDefault(); - if (!$(this).hasClass('selected')) { - event.stopPropagation(); - panel.toggleAccountsDropdown(true); - $(document).on('click', hideDropdown); - } - } - }); - - this.elLk.accountDropdown = this.el.find('._accounts_dropdown').on({ - 'click': function(event) { - if (event.target.nodeName !== 'A' && event.target.parentNode.nodeName !== 'A') { - event.stopPropagation(); - } - } - }).find('a').on('click', hideDropdown).end(); - - this.elLk.accountHolder.find('._accounts_no_userdb').helptip().on({ - 'click': function(event) { - event.preventDefault(); - } - }); - - }, - 'dataType': 'html' + this.elLk.accountHolder.toggleClass('_logged_in', this.elLk.accountHolder.find('._accounts_link').length ? true : false); + } + + this.bindAccountsDropdown(); + this.runAccountsDropdownCallbacks(); + }, + 'error': function() { + this.accountsDropdownCallbacks = []; + }, + 'complete': function() { + this.accountsDropdownLoading = false; + }, + 'dataType': 'html' + }); + }, + + refreshAccountsDropdown: function(callback) { + this.loadAccountsDropdown($.isFunction(callback) ? callback : null, true); + }, + + runAccountsDropdownCallbacks: function() { + var callback; + + while (this.accountsDropdownCallbacks.length) { + callback = this.accountsDropdownCallbacks.shift(); + callback.call(this); + } + }, + + showAccountsDropdown: function() { + var panel = this; + + if (!this.accountsDropdownLoaded) { + this.loadAccountsDropdown(function() { + panel.showAccountsDropdown(); }); + return; } + + this.toggleAccountsDropdown(true); + + $(document).off('click.accountsDropdown').on('click.accountsDropdown', function(e) { + panel.hideAccountsDropdown(e); + }); }, - + + hideAccountsDropdown: function(e) { + if (!e || !e.which || e.which === 1) { + this.toggleAccountsDropdown(false); + $(document).off('click.accountsDropdown'); + } + }, + toggleAccountsDropdown: function(flag) { + if (!this.elLk.accountLink.length || !this.elLk.accountDropdown.length) { + return; + } + this.elLk.accountLink.toggleClass('selected', flag); this.elLk.accountDropdown.toggle(flag); if (flag && !this.elLk.accountDropdown.data('initiated')) { diff --git a/users/modules/EnsEMBL/Web/Document/Element/AccountLinks.pm b/users/modules/EnsEMBL/Web/Document/Element/AccountLinks.pm index a71c4a56d9..651e1317b2 100644 --- a/users/modules/EnsEMBL/Web/Document/Element/AccountLinks.pm +++ b/users/modules/EnsEMBL/Web/Document/Element/AccountLinks.pm @@ -22,12 +22,13 @@ package EnsEMBL::Web::Document::Element::AccountLinks; use strict; ### TODO add shared bookmarks from group -### TODO limit total number of bookmarks shown ### TODO show bookmarks from current site only ### TODO order bookmarks by priority use HTML::Entities qw(encode_entities); +use constant ACCOUNT_DROPDOWN_BOOKMARK_LIMIT => 5; + sub init { my ($self, $controller) = @_; my $hub = $self->hub; @@ -42,11 +43,12 @@ sub init { sub content { my $self = shift; my $hub = $self->hub; + my $user = $hub->user; + my $html = $user + ? sprintf('%s
', $self->_account_link($user)) + : $self->_anonymous_link($hub->users_available); - return sprintf('', $hub->user - ? (' _logged_in', join('', map sprintf('', $_, encode_entities($self->{'_bookmark_data'}{$_})), keys %{$self->{'_bookmark_data'}})) - : ('', '') - ); + return sprintf('', $user ? ' _logged_in' : '', $html, $self->_bookmark_form); } sub content_ajax { @@ -55,6 +57,8 @@ sub content_ajax { my $users_available = $hub->users_available; my $user = $users_available ? $hub->user : undef; my $bookmarks = $user ? $user->bookmarks : []; + my @display_bookmarks = @$bookmarks; + splice @display_bookmarks, ACCOUNT_DROPDOWN_BOOKMARK_LIMIT if @display_bookmarks > ACCOUNT_DROPDOWN_BOOKMARK_LIMIT; my $manage_link; my $site = $hub->species_defs->ENSEMBL_ACCOUNTS_SITE; @@ -66,7 +70,7 @@ sub content_ajax { } return $user - ? sprintf(' + ? sprintf('%s

Bookmarks

%s
%s @@ -76,14 +80,13 @@ sub content_ajax {

Logout

', - $hub->PREFERENCES_PAGE, - $user->email, + $self->_account_link($user), join('', map { sprintf '

%2$s

', $hub->url({'type' => 'Account', 'action' => 'Bookmark', 'function' => 'Use', 'id' => $_->record_id, '__clear' => 1}), $_->name, $_->url - } @$bookmarks) || '

No bookmark added

', + } @display_bookmarks) || '

No bookmark added

', sprintf('

Bookmark this page

'. ( @$bookmarks ? @@ -100,11 +103,35 @@ sub content_ajax { $manage_link, $hub->url({qw(type Account action Logout)}) ) - : sprintf('', $users_available - ? (' modal_link', $self->hub->url({qw(type Account action Login)}), 'Login/Register') - : (' _accounts_no_userdb', '#', 'User accounts are temporarily unavailable.') - ) + : $self->_anonymous_link($users_available) ; } +sub _account_link { + my ($self, $user) = @_; + + return sprintf('', + $self->hub->PREFERENCES_PAGE, + encode_entities($user->email) + ); +} + +sub _anonymous_link { + my ($self, $users_available) = @_; + + return sprintf('', $users_available + ? (' modal_link', $self->hub->url({qw(type Account action Login)}), 'Login/Register') + : (' _accounts_no_userdb', '#', 'User accounts are temporarily unavailable.') + ); +} + +sub _bookmark_form { + my $self = shift; + my $bookmark_data = $self->{'_bookmark_data'} || {}; + + return sprintf('
%s
', + join('', map sprintf('', $_, encode_entities($bookmark_data->{$_})), keys %$bookmark_data) + ); +} + 1; From e6b15ece06f0d008dded539103c487b80e142e26 Mon Sep 17 00:00:00 2001 From: Bilal El Houdaigui Date: Tue, 28 Apr 2026 18:14:32 +0100 Subject: [PATCH 2/3] improve ajax tools tab logic --- tools/htdocs/components/10_Masthead_Tools.js | 54 +++++++++++++++++++- tools/htdocs/components/11_ContentTools.js | 3 ++ tools/htdocs/components/15_ToolsForm.js | 1 + 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/tools/htdocs/components/10_Masthead_Tools.js b/tools/htdocs/components/10_Masthead_Tools.js index 7d68135993..7974f80c1e 100644 --- a/tools/htdocs/components/10_Masthead_Tools.js +++ b/tools/htdocs/components/10_Masthead_Tools.js @@ -19,6 +19,14 @@ Ensembl.Panel.Masthead = Ensembl.Panel.Masthead.extend({ + toolsJobStateKey: 'ensembl.hasToolsJobs', + + constructor: function (id) { + this.base(id); + + Ensembl.EventManager.register('toolsRefreshMasthead', this, this.refreshToolsTab); + }, + init: function () { this.base(); @@ -28,11 +36,53 @@ Ensembl.Panel.Masthead = Ensembl.Panel.Masthead.extend({ this.recentJobs = $.makeArray(this.elLk.toolsDropdown.find('li a').map(function(i, el) { return (el.href.match(/tl\=([a-z0-9_\-]+)/i) || []).pop() || null; })); this.fetchURL ='/' + (Ensembl.species || 'Multi') + '/Ajax/tools_tab'; - if (this.elLk.toolsTabs.length) { + if (this.elLk.toolsTabs.length && this.shouldFetchToolsTab()) { this.fetchToolsTab(); } }, + + shouldFetchToolsTab: function() { + return this.hasToolsJobState() || this.hasRecentJobs() || this.hasCurrentToolsPage() || !!(Ensembl.coreParams && Ensembl.coreParams['tl']); + }, + + hasRecentJobs: function() { + return this.recentJobs && this.recentJobs.length; + }, + + hasCurrentToolsPage: function() { + return this.elLk.toolsTabs.filter('.final').length; + }, + + hasToolsJobState: function() { + try { + return window.localStorage && window.localStorage.getItem(this.toolsJobStateKey) === '1'; + } catch(e) { + return false; + } + }, + + setToolsJobState: function(flag) { + try { + if (window.localStorage) { + if (flag) { + window.localStorage.setItem(this.toolsJobStateKey, '1'); + } else { + window.localStorage.removeItem(this.toolsJobStateKey); + } + } + } catch(e) {} + }, + refreshToolsTab: function(force, hasJobs) { + if (typeof hasJobs === 'boolean') { + this.setToolsJobState(hasJobs); + } + + if (this.elLk.toolsTabs.length && (force || this.shouldFetchToolsTab())) { + this.fetchToolsTab(); + } + }, + fetchToolsTab: function() { var panel = this; @@ -51,10 +101,12 @@ Ensembl.Panel.Masthead = Ensembl.Panel.Masthead.extend({ populateToolsTab: function(response) { if (response.empty) { + this.setToolsJobState(false); this.elLk.toolsTabs.find('a:not(:first-child)').remove().end().find('.dropdown').removeClass('dropdown'); } else { + this.setToolsJobState(true); this.elLk.toolsTabs.filter(':not(.final)').addClass('final').find('a:first-child').html(response.caption).attr('href', response.url); this.elLk.toolsDropdown.find('ul.recent').remove().end().find('h4').first().html('Recent jobs').after($('
    ').append($.map(response.tools, function(details, tool) { diff --git a/tools/htdocs/components/11_ContentTools.js b/tools/htdocs/components/11_ContentTools.js index 564e226478..df3970898b 100644 --- a/tools/htdocs/components/11_ContentTools.js +++ b/tools/htdocs/components/11_ContentTools.js @@ -106,6 +106,9 @@ Ensembl.Panel.ContentTools = Ensembl.Panel.Content.extend({ if (methodName in this) { this[methodName].apply(this, json.panelMethod); json.panelMethod.unshift(methodName); + if (methodName === 'refresh') { + Ensembl.EventManager.trigger('toolsRefreshMasthead', true); + } return 'method_applied'; } diff --git a/tools/htdocs/components/15_ToolsForm.js b/tools/htdocs/components/15_ToolsForm.js index 0b1957ae0e..3fe75b9d5c 100644 --- a/tools/htdocs/components/15_ToolsForm.js +++ b/tools/htdocs/components/15_ToolsForm.js @@ -172,6 +172,7 @@ Ensembl.Panel.ToolsForm = Ensembl.Panel.ContentTools.extend({ /* * Method called once ticket is successfully submitted via AJAX */ + Ensembl.EventManager.trigger('toolsRefreshMasthead', true, true); Ensembl.EventManager.trigger('toolsRefreshActivitySummary', true, true, false); this.toggleForm(false, true); }, From 6956bae45b3388b0f1eafa9d92b831fdd64b73dd Mon Sep 17 00:00:00 2001 From: Bilal El Houdaigui Date: Tue, 28 Apr 2026 18:23:46 +0100 Subject: [PATCH 3/3] add comments explaining the changes --- tools/htdocs/components/10_Masthead_Tools.js | 4 ++++ tools/htdocs/components/11_ContentTools.js | 2 ++ tools/htdocs/components/15_ToolsForm.js | 2 ++ users/htdocs/components/10_Masthead_Users.js | 2 ++ users/modules/EnsEMBL/Web/Document/Element/AccountLinks.pm | 6 ++++++ 5 files changed, 16 insertions(+) diff --git a/tools/htdocs/components/10_Masthead_Tools.js b/tools/htdocs/components/10_Masthead_Tools.js index 7974f80c1e..d1a36f92c1 100644 --- a/tools/htdocs/components/10_Masthead_Tools.js +++ b/tools/htdocs/components/10_Masthead_Tools.js @@ -42,6 +42,8 @@ Ensembl.Panel.Masthead = Ensembl.Panel.Masthead.extend({ }, shouldFetchToolsTab: function() { + // The species-scoped */Ajax/tools_tab request checks private ticket state in + // the tools DB, so only make it when this browser is likely to have jobs. return this.hasToolsJobState() || this.hasRecentJobs() || this.hasCurrentToolsPage() || !!(Ensembl.coreParams && Ensembl.coreParams['tl']); }, @@ -62,6 +64,8 @@ Ensembl.Panel.Masthead = Ensembl.Panel.Masthead.extend({ }, setToolsJobState: function(flag) { + // The ajax response confirms whether this browser still needs eager tools tab + // refreshes on non-Tools pages. try { if (window.localStorage) { if (flag) { diff --git a/tools/htdocs/components/11_ContentTools.js b/tools/htdocs/components/11_ContentTools.js index df3970898b..5c345c5150 100644 --- a/tools/htdocs/components/11_ContentTools.js +++ b/tools/htdocs/components/11_ContentTools.js @@ -106,6 +106,8 @@ Ensembl.Panel.ContentTools = Ensembl.Panel.Content.extend({ if (methodName in this) { this[methodName].apply(this, json.panelMethod); json.panelMethod.unshift(methodName); + // Save/delete responses use refresh(), so re-check the masthead job tab + // state after they change ticket ownership or remove jobs. if (methodName === 'refresh') { Ensembl.EventManager.trigger('toolsRefreshMasthead', true); } diff --git a/tools/htdocs/components/15_ToolsForm.js b/tools/htdocs/components/15_ToolsForm.js index 3fe75b9d5c..7ee9c1bcf8 100644 --- a/tools/htdocs/components/15_ToolsForm.js +++ b/tools/htdocs/components/15_ToolsForm.js @@ -172,6 +172,8 @@ Ensembl.Panel.ToolsForm = Ensembl.Panel.ContentTools.extend({ /* * Method called once ticket is successfully submitted via AJAX */ + // Set the tools-tab hint immediately so later non-Tools pages can show Jobs + // without probing */Ajax/tools_tab for users who never submit jobs. Ensembl.EventManager.trigger('toolsRefreshMasthead', true, true); Ensembl.EventManager.trigger('toolsRefreshActivitySummary', true, true, false); this.toggleForm(false, true); diff --git a/users/htdocs/components/10_Masthead_Users.js b/users/htdocs/components/10_Masthead_Users.js index d7dc6ed0ff..ba94c1dfe4 100644 --- a/users/htdocs/components/10_Masthead_Users.js +++ b/users/htdocs/components/10_Masthead_Users.js @@ -108,6 +108,8 @@ Ensembl.Panel.Masthead = Ensembl.Panel.Masthead.extend({ return; } + // Keep /Ajax/accounts_dropdown off the initial page load. Normal use fetches + // it on first open; force is reserved for account modal updates. this.cacheAccountsForm(); if (!this.accountsRefreshURL) { diff --git a/users/modules/EnsEMBL/Web/Document/Element/AccountLinks.pm b/users/modules/EnsEMBL/Web/Document/Element/AccountLinks.pm index 651e1317b2..ba478f3bf3 100644 --- a/users/modules/EnsEMBL/Web/Document/Element/AccountLinks.pm +++ b/users/modules/EnsEMBL/Web/Document/Element/AccountLinks.pm @@ -44,6 +44,9 @@ sub content { my $self = shift; my $hub = $self->hub; my $user = $hub->user; + + ## Avoid a /Ajax/accounts_dropdown request on every page view. Render the stable + ## account link now, and let the JS fetch user-specific dropdown contents lazily. my $html = $user ? sprintf('%s
    ', $self->_account_link($user)) : $self->_anonymous_link($hub->users_available); @@ -57,6 +60,9 @@ sub content_ajax { my $users_available = $hub->users_available; my $user = $users_available ? $hub->user : undef; my $bookmarks = $user ? $user->bookmarks : []; + + ## The masthead only needs a short preview; the full list remains available from + ## the account page, and limiting it keeps the lazy endpoint cheap. my @display_bookmarks = @$bookmarks; splice @display_bookmarks, ACCOUNT_DROPDOWN_BOOKMARK_LIMIT if @display_bookmarks > ACCOUNT_DROPDOWN_BOOKMARK_LIMIT;