diff --git a/core/modules/toolbar/js/toolbar.js b/core/modules/toolbar/js/toolbar.js index 344abe6..b323488 100644 --- a/core/modules/toolbar/js/toolbar.js +++ b/core/modules/toolbar/js/toolbar.js @@ -67,11 +67,12 @@ Drupal.behaviors.toolbar = { strings: options.strings }); - // Handle the resolution of Drupal.toolbar.setSubtrees(). + // Handle the resolution of Drupal.toolbar.setSubtrees. // This is handled with a deferred so that the function may be invoked // asynchronously. Drupal.toolbar.setSubtrees.done(function (subtrees) { menuModel.set('subtrees', subtrees); + localStorage.setItem('Drupal.toolbar.subtrees', JSON.stringify(subtrees)); }); // Attach a listener to the configured media query breakpoints. @@ -88,6 +89,14 @@ Drupal.behaviors.toolbar = { } } + // Trigger an initial attempt to load menu sub-items. This first attempt + // is made after the media query handlers have had an opportunity to + // process. The toolbar starts in the vertical position by default, unless + // the viewport is wide enough to accomodate a horizontal orientation. + // Thus we give the Toolbar a chance to determine if it should be set + // to horizontal before attempting to load menu subtrees. + Drupal.toolbar.views.toolbarVisualView.loadSubtrees(); + $(document) // Update the model when the viewport offset changes. .on('drupalViewportOffsetChange.toolbar', function (event, offsets) { @@ -196,6 +205,9 @@ Drupal.toolbar = { // Indicates whether the toolbar is positioned absolute (false) or fixed // (true). isFixed: false, + // Menu subtrees are loaded through an AJAX request only when the Toolbar + // is set to a vertical orientation. + areSubtreesLoaded: false, // If the viewport overflow becomes constrained, such as when the overlay // is open, isFixed must be true so that elements in the trays aren't // lost offscreen and impossible to get to. @@ -316,10 +328,18 @@ Drupal.toolbar = { /** * {@inheritdoc} */ - render: function (model) { + render: function (model, value) { this.updateTabs(); this.updateTrayOrientation(); this.updateBarAttributes(); + // Load the subtrees if the toolbar tray is in a vertical orientation + // and if they have been loaded yet. There is no other attribute in the + // model with the value of vertical, so just check for it and whether + // the subtrees have been loaded yet to know when to make an AJAX call + // to get them. + if (value && value === 'vertical') { + this.loadSubtrees(); + } // Trigger a recalculation of viewport displacing elements. Use setTimeout // to ensure this recalculation happens after changes to visual elements // have processed. @@ -485,6 +505,37 @@ Drupal.toolbar = { // the container for the trays. $trays.css('padding-top', this.$el.find('.toolbar-bar').outerHeight()); } + }, + + /** + * Calls the endpoint URI that will return rendered subtrees with JSONP. + */ + loadSubtrees: function () { + if(!this.model.get('areSubtreesLoaded')) { + var endpoint = drupalSettings.toolbar.subtreesPath; + var cachedEndpoint = localStorage.getItem('Drupal.toolbar.subtreesPath'); + var cachedSubtrees = JSON.parse(localStorage.getItem('Drupal.toolbar.subtrees')); + // If we have the subtrees in localStorage and the endpoint url -- + // including the hash of the subtrees -- has not changed, then use + // the cached data. + if (endpoint === cachedEndpoint && cachedSubtrees) { + Drupal.toolbar.setSubtrees.resolve(cachedSubtrees); + this.model.set('areSubtreesLoaded', true); + } + // Only make the call to get the subtrees if the orientation of the + // toolbar is vertical. + else if (this.model.get('orientation') === 'vertical') { + // Remove the cached menu information. + localStorage.removeItem('Drupal.toolbar.subtreesPath'); + localStorage.removeItem('Drupal.toolbar.subtrees'); + // The response from the server will call the resolve method of the + // Drupal.toolbar.setSubtrees Promise. + $.ajax(endpoint); + // Cached the endpoint to the subtrees locally. + localStorage.setItem('Drupal.toolbar.subtreesPath', endpoint); + this.model.set('areSubtreesLoaded', true); + } + } } }), diff --git a/core/modules/toolbar/lib/Drupal/toolbar/Tests/ToolbarAdminMenuTest.php b/core/modules/toolbar/lib/Drupal/toolbar/Tests/ToolbarAdminMenuTest.php new file mode 100644 index 0000000..273c7c0 --- /dev/null +++ b/core/modules/toolbar/lib/Drupal/toolbar/Tests/ToolbarAdminMenuTest.php @@ -0,0 +1,384 @@ + 'Toolbar admin menu', + 'description' => 'Tests the caching of secondary admin menu items.', + 'group' => 'Toolbar', + ); + } + + function setUp() { + parent::setUp(); + + $perms = array( + 'access toolbar', + 'access administration pages', + 'administer site configuration', + 'bypass node access', + 'administer themes', + 'administer nodes', + 'administer blocks', + 'administer menu', + 'administer modules', + 'administer permissions', + 'administer users', + 'access user profiles', + 'administer taxonomy', + 'administer languages', + ); + + // Create an administrative user and log it in. + $this->admin_user = $this->drupalCreateUser($perms); + $this->admin_user_2 = $this->drupalCreateUser($perms); + + $this->drupalLogin($this->admin_user); + + $this->drupalGet('test-page'); + $this->assertResponse(200); + + // Assert that the toolbar is present in the HTML. + $this->assertRaw('id="toolbar-administration"'); + + // Store the admin_user admin menu subtrees hash for comparison later. + $this->hash = $this->getSubtreesHash(); + } + + /** + * Exercises the toolbar_modules_enabled() and toolbar_modules_disabled() hook + * implementations. + */ + function testModuleStatusChangeSubtreesHashCacheClear() { + $edit = array(); + $edit['modules[Core][taxonomy][enable]'] = FALSE; + $this->drupalPost('admin/modules', $edit, t('Save configuration')); + $this->rebuildContainer(); + + // Assert that the subtrees hash has been altered because the subtrees + // structure changed. + $this->assertDifferentHash(); + + // Test enabling a module. + $edit = array(); + $edit['modules[Core][taxonomy][enable]'] = TRUE; + $this->drupalPost('admin/modules', $edit, t('Save configuration')); + $this->rebuildContainer(); + + // Assert that the subtrees hash has been altered because the subtrees + // structure changed. + $this->assertDifferentHash(); + } + + /** + * Exercises toolbar_menu_link_update() hook implementation. + */ + function testMenuLinkUpdateSubtreesHashCacheClear() { + // Get subtree items for the admin menu. + $query = \Drupal::entityQuery('menu_link'); + for ($i = 1; $i <= 3; $i++) { + $query->sort('p' . $i, 'ASC'); + } + $query->condition('menu_name', 'admin'); + $query->condition('depth', '2', '>='); + + // Build an ordered array of links using the query result object. + $links = array(); + if ($result = $query->execute()) { + $links = menu_link_load_multiple($result); + } + // Get the first link in the set. + $links = array_values($links); + $link = array_shift($links); + + // Disable the link. + $edit = array(); + $edit['enabled'] = FALSE; + $this->drupalPost("admin/structure/menu/item/" . $link['mlid'] . "/edit", $edit, t('Save')); + $this->assertResponse(200); + $this->assertText('The menu link has been saved.'); + + // Assert that the subtrees hash has been altered because the subtrees + // structure changed. + $this->assertDifferentHash(); + } + + /** + * Exercises the toolbar_user_role_update() and toolbar_user_update() hook + * implementations. + */ + function testUserRoleUpdateSubtreesHashCacheClear() { + // Find the new role ID. + $all_rids = $this->admin_user->getRoles(); + unset($all_rids[array_search(DRUPAL_AUTHENTICATED_RID, $all_rids)]); + $rid = reset($all_rids); + + $edit = array(); + $edit[$rid . '[administer taxonomy]'] = FALSE; + $this->drupalPost('admin/people/permissions', $edit, t('Save permissions')); + + // Assert that the subtrees hash has been altered because the subtrees + // structure changed. + $this->assertDifferentHash(); + + // Test that a user role change only affects a single user. + // Get the hash for a second user. + $this->drupalLogin($this->admin_user_2); + $this->drupalGet('test-page'); + $this->assertResponse(200); + + // Assert that the toolbar is present in the HTML. + $this->assertRaw('id="toolbar-administration"'); + + $admin_user_2_hash = $this->getSubtreesHash(); + + // Log in the first admin user again. + $this->drupalLogin($this->admin_user); + $this->drupalGet('test-page'); + $this->assertResponse(200); + + // Assert that the toolbar is present in the HTML. + $this->assertRaw('id="toolbar-administration"'); + + $this->hash = $this->getSubtreesHash(); + + $rid = $this->drupalCreateRole(array('administer content types',)); + + // Assign the role to the user. + $this->drupalPost('user/' . $this->admin_user->id() . '/edit', array("roles[$rid]" => $rid), t('Save')); + $this->assertText(t('The changes have been saved.')); + + // Assert that the subtrees hash has been altered because the subtrees + // structure changed. + $this->assertDifferentHash(); + + // Log in the second user again and assert that their subtrees hash did not + // change. + $this->drupalLogin($this->admin_user_2); + + // Request a new page to refresh the drupalSettings object. + $this->drupalGet('test-page'); + $this->assertResponse(200); + $new_subtree_hash = $this->getSubtreesHash(); + + // Assert that the old admin menu subtree hash and the new admin menu + // subtree hash are the same. + $this->assertTrue($new_subtree_hash, 'A valid hash value for the admin menu subtrees was created.'); + $this->assertEqual($admin_user_2_hash, $new_subtree_hash, 'The user-specific subtree menu hash has not been updated.'); + } + + /** + * Tests that the toolbar user cache is cleared with a cache tag for all + * languages that have cached admin menu subtrees. + */ + function testCacheClearByCacheTag() { + // Test that the toolbar admin menu subtrees cache is invalidated for a user + // across multiple languages. + $this->drupalLogin($this->admin_user); + $toolbarCache = $this->container->get('cache.toolbar'); + $admin_user_id = $this->admin_user->id(); + $admin_user_2_id = $this->admin_user_2->id(); + + // Assert that a cache tag in the toolbar cache under the key "user" exists + // for admin_user against the language "en". + $cache = $toolbarCache->get($admin_user_id . ':' . 'en'); + $this->assertEqual($cache->tags[0], 'user:' . $admin_user_id, 'A cache tag in the toolbar cache under the key "user" exists for admin_user against the language "en".'); + + // Asert that no toolbar cache exists for admin_user against the + // language "fr". + $cache = $toolbarCache->get($admin_user_id . ':' . 'fr'); + $this->assertFalse($cache, 'No toolbar cache exists for admin_user against the language "fr".'); + + // Install a second language. + $edit = array( + 'predefined_langcode' => 'fr', + ); + $this->drupalPost('admin/config/regional/language/add', $edit, 'Add language'); + + // Request a page in 'fr' to update the cache. + $this->drupalGet('fr/test-page'); + $this->assertResponse(200); + + // Asert that a cache tag in the toolbar cache under the key "user" exists + // for admin_user against the language "fr". + $cache = $toolbarCache->get($admin_user_id . ':' . 'fr'); + $this->assertEqual($cache->tags[0], 'user:' . $admin_user_id, 'A cache tag in the toolbar cache under the key "user" exists for admin_user against the language "fr".'); + + // Log in the admin_user_2 user. We will use this user as a control to + // verify that clearing a cache tag for admin_user does not clear the cache + // for admin_user_2. + $this->drupalLogin($this->admin_user_2); + + // Request a page in 'en' to create the cache. + $this->drupalGet('test-page'); + $this->assertResponse(200); + // Asert that a cache tag in the toolbar cache under the key "user" exists + // for admin_user against the language "en". + $cache = $toolbarCache->get($admin_user_2_id . ':' . 'en'); + $this->assertEqual($cache->tags[0], 'user:' . $admin_user_2_id, 'A cache tag in the toolbar cache under the key "user" exists for admin_user_2 against the language "en".'); + + // Request a page in 'en' to create the cache. + $this->drupalGet('fr/test-page'); + $this->assertResponse(200); + // Asert that a cache tag in the toolbar cache under the key "user" exists + // for admin_user against the language "fr". + $cache = $toolbarCache->get($admin_user_2_id . ':' . 'fr'); + $this->assertEqual($cache->tags[0], 'user:' . $admin_user_2_id, 'A cache tag in the toolbar cache under the key "user" exists for admin_user_2 against the language "fr".'); + + // Log in admin_user and clear the caches for this user using a tag. + $this->drupalLogin($this->admin_user); + + // Delete the toolbar cache for the admin_user based on a tag. + $toolbarCache->deleteTags(array('user' => array($admin_user_id))); + + // Asert that no toolbar cache exists for admin_user against the + // language "en". + $cache = $toolbarCache->get($admin_user_id . ':' . 'en'); + $this->assertFalse($cache, 'No toolbar cache exists for admin_user against the language "en".'); + + // Asert that no toolbar cache exists for admin_user against the + // language "fr". + $cache = $toolbarCache->get($admin_user_id . ':' . 'fr'); + $this->assertFalse($cache, 'No toolbar cache exists for admin_user against the language "fr".'); + + // Log in admin_user_2 and verify that this user's caches still exist. + $this->drupalLogin($this->admin_user_2); + + // Asert that a cache tag in the toolbar cache under the key "user" exists + // for admin_user_2 against the language "en". + $cache = $toolbarCache->get($admin_user_2_id . ':' . 'en'); + $this->assertEqual($cache->tags[0], 'user:' . $admin_user_2_id, 'A cache tag in the toolbar cache under the key "user" exists for admin_user_2 against the language "en".'); + + // Asert that a cache tag in the toolbar cache under the key "user" exists + // for admin_user_2 against the language "fr". + $cache = $toolbarCache->get($admin_user_2_id . ':' . 'fr'); + $this->assertEqual($cache->tags[0], 'user:' . $admin_user_2_id, 'A cache tag in the toolbar cache under the key "user" exists for admin_user_2 against the language "fr".'); + } + + /** + * Tests that changes to a user account by another user clears the changed + * account's toolbar cached, not the user's who took the action. + */ + function testNonCurrentUserAccountUpdates() { + $toolbarCache = $this->container->get('cache.toolbar'); + $admin_user_id = $this->admin_user->id(); + $admin_user_2_id = $this->admin_user_2->id(); + $this->hash = $this->getSubtreesHash(); + + // admin_user_2 will add a role to admin_user. + $this->drupalLogin($this->admin_user_2); + $rid = $this->drupalCreateRole(array('administer content types',)); + + // Get the subtree hash for admin_user_2 to check later that it has not + // changed. Request a new page to refresh the drupalSettings object. + $this->drupalGet('test-page'); + $this->assertResponse(200); + $admin_user_2_hash = $this->getSubtreesHash(); + + // Assign the role to the user. + $this->drupalPost('user/' . $admin_user_id . '/edit', array("roles[$rid]" => $rid), t('Save')); + $this->assertText(t('The changes have been saved.')); + + // Log in admin_user and assert that the subtrees hash has changed. + $this->drupalLogin($this->admin_user); + $this->assertDifferentHash(); + + // Log in admin_user_2 to check that its subtrees hash has not changed. + $this->drupalLogin($this->admin_user_2); + $new_subtree_hash = $this->getSubtreesHash(); + + // Assert that the old admin_user subtree hash and the new admin_user + // subtree hash are the same. + $this->assertTrue($new_subtree_hash, 'A valid hash value for the admin menu subtrees was created.'); + $this->assertEqual($admin_user_2_hash, $new_subtree_hash, 'The user-specific subtree menu hash has not been updated.'); + } + + /** + * Get the hash value from the admin menu subtrees route path. + * + * @return String + * The hash value from the admin menu subtrees route path. + */ + private function getSubtreesHash() { + $settings = $this->drupalGetSettings(); + // The toolbar module defines a route '/toolbar/subtrees/{hash}' that + // returns JSON for the rendered subtrees. This path is provided to the + // client in drupalSettings. To get just the hash value, remove the + // '/toolbar/subtrees/' portion of the path. + return substr($settings['toolbar']['subtreesPath'], 18); + } + + /** + * Asserts the subtrees hash on a fresh page GET is different from the hash + * from the previous page GET. + */ + private function assertDifferentHash() { + // Request a new page to refresh the drupalSettings object. + $this->drupalGet('test-page'); + $this->assertResponse(200); + $new_subtree_hash = $this->getSubtreesHash(); + + // Assert that the old admin menu subtree hash and the new admin menu + // subtree hash are different. + $this->assertTrue($new_subtree_hash, 'A valid hash value for the admin menu subtrees was created.'); + $this->assertNotEqual($this->hash, $new_subtree_hash, 'The user-specific subtree menu hash has been updated.'); + + // Save the new subtree hash as the original. + $this->hash = $new_subtree_hash; + } + +} diff --git a/core/modules/toolbar/toolbar.module b/core/modules/toolbar/toolbar.module index 43ad735..be08ccc 100644 --- a/core/modules/toolbar/toolbar.module +++ b/core/modules/toolbar/toolbar.module @@ -5,11 +5,14 @@ * Administration toolbar for quick access to top level administration items. */ +use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Language\Language; -use Symfony\Component\HttpFoundation\JsonResponse; use Drupal\Core\Template\Attribute; use Drupal\Component\Utility\Crypt; use Symfony\Component\HttpFoundation\Response; +use Drupal\menu_link\MenuLinkInterface; +use Drupal\user\RoleInterface; +use Drupal\user\UserInterface; /** * Implements hook_help(). @@ -437,11 +440,17 @@ function toolbar_toolbar() { ); // To conserve bandwidth, we only include the top-level links in the HTML. - // The subtrees are included in a JSONP script, cached by the browser. Here we - // add that JSONP script. We add it as an external script, because it's a - // Drupal path, not a file available via a stream wrapper. + // The subtrees are fetched through a JSONP script. Here we + // add that JSONP script url. We add it as a path because it is not a file + // available via a stream wrapper. + // @todo, cache the subtrees in the browser. // @see toolbar_subtrees_jsonp() - $menu['toolbar_administration']['#attached']['js'][url('toolbar/subtrees/' . _toolbar_get_subtree_hash())] = array('type' => 'external'); + $menu['toolbar_administration']['#attached']['js'][] = array( + 'type' => 'setting', + 'data' => array('toolbar' => array( + 'subtreesPath' => url('toolbar/subtrees/' . _toolbar_get_subtree_hash()), + )), + ); // The administration element has a link that is themed to correspond to // a toolbar tray. The tray contains the full administrative menu of the site. @@ -599,6 +608,7 @@ function toolbar_library_info() { array('system', 'jquery'), array('system', 'drupal'), array('system', 'drupalSettings'), + array('system', 'drupal.announce'), array('system', 'backbone'), array('system', 'matchmedia'), array('system', 'jquery.once'), @@ -628,17 +638,91 @@ function toolbar_library_info() { /** * Returns the hash of the per-user rendered toolbar subtrees. + * + * @return string + * The hash of the admin_menu subtrees. */ function _toolbar_get_subtree_hash() { - $user = Drupal::currentUser(); - $cid = $user->id() . ':' . language(Language::TYPE_INTERFACE)->id; + $uid = \Drupal::currentUser()->id();; + $cid = _toolbar_get_user_cid($uid); if ($cache = cache('toolbar')->get($cid)) { $hash = $cache->data; } else { $subtrees = toolbar_get_rendered_subtrees(); $hash = Crypt::hashBase64(serialize($subtrees)); - cache('toolbar')->set($cid, $hash); + // Cache using a tag 'user' so that we can invalidate all + // user-specific caches later based on the uer's ID regardless of language. + cache('toolbar')->set($cid, $hash, CacheBackendInterface::CACHE_PERMANENT, array('user' => array($uid))); } return $hash; } + +/** + * Implements hook_modules_enabled(). + */ +function toolbar_modules_enabled($modules) { + _toolbar_clear_user_cache(); +} + +/** + * Implements hook_modules_disabled(). + */ +function toolbar_modules_disabled($modules) { + _toolbar_clear_user_cache(); +} + +/** + * Implements hook_ENTITY_TYPE_update(). + */ +function toolbar_menu_link_update(MenuLinkInterface $menu_link) { + if ($menu_link->get('menu_name') === 'admin') { + _toolbar_clear_user_cache(); + } +} + +/** + * Implements hook_ENTITY_TYPE_update(). + */ +function toolbar_user_update(UserInterface $user) { + _toolbar_clear_user_cache($user->id()); +} + +/** + * Implements hook_ENTITY_TYPE_update(). + */ +function toolbar_user_role_update(RoleInterface $role) { + _toolbar_clear_user_cache(); +} + +/** + * Returns a cache ID from the user and language IDs. + * + * @param string $uid + * A user ID. + * + * @return string + * A unique cache ID for the user. + */ +function _toolbar_get_user_cid($uid) { + return $uid . ':' . \Drupal::languageManager()->getLanguage(Language::TYPE_INTERFACE)->id; +} + +/** + * Clears the Toolbar user cache. + * + * @param string $uid + * (optional) The user ID to clear. + */ +function _toolbar_clear_user_cache($uid = NULL) { + $cache = cache('toolbar'); + if (!$cache->isEmpty()) { + // Clear by the 'user' tag in order to delete all caches, in any language, + // associated with this user. + if (isset($uid)) { + $cache->deleteTags(array('user' => array($uid))); + } else { + $cache->deleteAll(); + } + } +}