.../Drupal/block/Tests/Views/DisplayBlockTest.php | 25 +++- core/modules/contextual/contextual.js | 89 +++++++----- core/modules/contextual/contextual.module | 152 ++++++++++++++++++-- core/modules/contextual/contextual.routing.yml | 6 + core/modules/contextual/contextual.toolbar.js | 45 +++--- .../lib/Drupal/contextual/ContextualController.php | 50 +++++++ .../Plugin/views/field/ContextualLinks.php | 29 ++-- .../Tests/ContextualDynamicContextTest.php | 139 ++++++++++++++++-- .../Drupal/contextual/Tests/ContextualUnitTest.php | 122 ++++++++++++++++ core/modules/edit/js/edit.js | 7 +- .../menu/lib/Drupal/menu/Tests/MenuTest.php | 25 +++- core/modules/views/js/views-contextual.js | 9 +- core/modules/views/views.module | 35 +++-- .../lib/Drupal/views_ui/Tests/DisplayTest.php | 21 ++- core/modules/views_ui/views_ui.module | 4 +- 15 files changed, 638 insertions(+), 120 deletions(-) diff --git a/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php b/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php index 7178e6c..d88894d 100644 --- a/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php +++ b/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php @@ -152,10 +152,29 @@ public function testViewsBlockForm() { * Tests the contextual links on a Views block. */ public function testBlockContextualLinks() { - $this->drupalLogin($this->drupalCreateUser(array('administer views', 'access contextual links'))); - $this->drupalPlaceBlock('views_block:test_view_block-block_1', array(), array('title' => 'test_view_block-block_1:1')); + $this->drupalLogin($this->drupalCreateUser(array('administer views', 'access contextual links', 'administer blocks'))); + $block = $this->drupalPlaceBlock('views_block:test_view_block-block_1'); $this->drupalGet('test-page'); - $this->assertLinkByHref("admin/structure/views/view/test_view_block/edit"); + + $id = 'block:admin/structure/block/manage:' . $block->id() . ':|views_ui:admin/structure/views/view:test_view_block:location=block&name=test_view_block&display_id=block_1'; + // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:assertContextualLinkPlaceHolder() + $this->assertRaw('
', format_string('Contextual link placeholder with id @id exists.', array('@id' => $id))); + + // Get server-rendered contextual links. + // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:renderContextualLinks() + $post = urlencode('ids[0]') . '=' . urlencode($id); + $response = $this->curlExec(array( + CURLOPT_URL => url('contextual/render', array('absolute' => TRUE, 'query' => array('destination' => 'test-page'))), + CURLOPT_POST => TRUE, + CURLOPT_POSTFIELDS => $post, + CURLOPT_HTTPHEADER => array( + 'Accept: application/json', + 'Content-Type: application/x-www-form-urlencoded', + ), + )); + $this->assertResponse(200); + $json = drupal_json_decode($response); + $this->assertIdentical($json[$id], ''); } } diff --git a/core/modules/contextual/contextual.js b/core/modules/contextual/contextual.js index 2c696d5..2ccb2f7 100644 --- a/core/modules/contextual/contextual.js +++ b/core/modules/contextual/contextual.js @@ -17,31 +17,26 @@ var options = $.extend({ /** * Initializes a contextual link: updates its DOM, sets up model and views * - * @param DOM links - * A contextual links DOM element as rendered by the server. + * @param jQuery $contextual + * A contextual links placeholder DOM element, containing the actual + * contextual links as rendered by the server. */ -function initContextual (index, links) { - var $links = $(links); - var $region = $links.closest('.contextual-region'); +function initContextual ($contextual) { + var $region = $contextual.closest('.contextual-region'); var contextual = Drupal.contextual; - // Create a contextual links wrapper to provide positioning and behavior - // attachment context. - var $wrapper = $(Drupal.theme('contextualWrapper')) - .insertBefore($links) - // In the wrapper, first add the trigger element. - .append(Drupal.theme('contextualTrigger')) - // In the wrapper, then add the contextual links. - .append($links); + $contextual + // Use the placeholder as a wrapper with a specific class to provide + // positioning and behavior attachment context. + .addClass('contextual') + // Ensure a trigger element exists before the actual contextual links. + .prepend(Drupal.theme('contextualTrigger')) - // Create a model, add it to the collection. + // Create a model and the appropriate views. var model = new contextual.Model({ title: $region.find('h2:first').text().trim() }); - contextual.collection.add(model); - - // Create the appropriate views for this model. - var viewOptions = $.extend({ el: $wrapper, model: model }, options); + var viewOptions = $.extend({ el: $contextual, model: model }, options); contextual.views.push({ visual: new contextual.VisualView(viewOptions), aural: new contextual.AuralView(viewOptions), @@ -51,15 +46,20 @@ function initContextual (index, links) { $.extend({ el: $region, model: model }, options)) ); + // Add the model to the collection. This must happen after the views have been + // associated with it, otherwise collection change event handlers can't + // trigger the model change event handler in its views. + contextual.collection.add(model); + // Let other JavaScript react to the adding of a new contextual link. $(document).trigger('drupalContextualLinkAdded', { - $el: $links, + $el: $contextual, $region: $region, model: model }); // Fix visual collisions between contextual link triggers. - adjustIfNestedAndOverlapping($wrapper); + adjustIfNestedAndOverlapping($contextual); } /** @@ -68,7 +68,8 @@ function initContextual (index, links) { * This only deals with two levels of nesting; deeper levels are not touched. * * @param jQuery $contextual - * A contextual link. + * A contextual links placeholder DOM element, containing the actual + * contextual links as rendered by the server. */ function adjustIfNestedAndOverlapping ($contextual) { var $contextuals = $contextual @@ -110,7 +111,40 @@ function adjustIfNestedAndOverlapping ($contextual) { */ Drupal.behaviors.contextual = { attach: function (context) { - $(context).find('.contextual-links').once('contextual').each(initContextual); + var $context = $(context); + + // Find all contextual links placeholders, if any. + var $placeholders = $context.find('[data-contextual-id]').once('contextual-render'); + if ($placeholders.length === 0) { + return; + } + + // Collect the IDs for all contextual links placeholders. + var ids = []; + $placeholders.each(function () { + ids.push($(this).attr('data-contextual-id')); + }); + + // Perform an AJAX request to let the server render the contextual links for + // each of the placeholders. + $.ajax({ + url: Drupal.url('contextual/render') + '?destination=' + Drupal.encodePath(drupalSettings.currentPath), + type: 'POST', + data: { 'ids[]' : ids }, + dataType: 'json', + success: function (results) { + for (var id in results) { + if (results.hasOwnProperty(id)) { + // Update the placeholder to contain its rendered contextual links. + var $placeholder = $context.find('[data-contextual-id="' + id + '"]') + .html(results[id]); + + // Initialize the contextual link. + initContextual($placeholder); + } + } + } + }); } }; @@ -370,17 +404,6 @@ Drupal.contextual = { // A Backbone.Collection of Drupal.contextual.Model instances. Drupal.contextual.collection = new Backbone.Collection([], { model: Drupal.contextual.Model }); - -/** - * Wraps contextual links. - * - * @return String - * A string representing a DOM fragment. - */ -Drupal.theme.contextualWrapper = function () { - return '
'; -}; - /** * A trigger is an interactive element often bound to a click handler. * diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module index 51747c6..2505e77 100644 --- a/core/modules/contextual/contextual.module +++ b/core/modules/contextual/contextual.module @@ -6,6 +6,19 @@ */ /** + * Implements hook_custom_theme(). + * + * @todo Add an event subscriber to the Ajax system to automatically set the + * base page theme for all Ajax requests, and then remove this one off. + * See http://drupal.org/node/1954892. + */ +function contextual_custom_theme() { + if (substr(current_path(), 0, 11) === 'contextual/') { + return ajax_base_page_theme(); + } +} + +/** * Implements hook_toolbar(). */ function contextual_toolbar() { @@ -24,8 +37,6 @@ function contextual_toolbar() { 'role' => 'button', 'aria-pressed' => 'false', ), - // @todo remove this once http://drupal.org/node/1908906 lands. - '#options' => array('attributes' => array()), ), '#wrapper_attributes' => array( 'class' => array('element-hidden', 'contextual-toolbar-tab'), @@ -41,6 +52,22 @@ function contextual_toolbar() { } /** + * Implements hook_page_build(). + * + * Adds the drupal.contextual-links library to the page for any user who has the + * 'access contextual links' permission. + * + * @see contextual_preprocess() + */ +function contextual_page_build(&$page) { + if (!user_access('access contextual links')) { + return; + } + + $page['#attached']['library'][] = array('contextual', 'drupal.contextual-links'); +} + +/** * Implements hook_help(). */ function contextual_help($path, $arg) { @@ -115,6 +142,7 @@ function contextual_library_info() { array('system', 'jquery.once'), array('system', 'drupal.tabbingmanager'), array('system', 'drupal.announce'), + array('contextual', 'drupal.contextual-links') ), ); @@ -125,6 +153,10 @@ function contextual_library_info() { * Implements hook_element_info(). */ function contextual_element_info() { + $types['contextual_links_placeholder'] = array( + '#pre_render' => array('contextual_pre_render_placeholder'), + '#id' => NULL, + ); $types['contextual_links'] = array( '#pre_render' => array('contextual_pre_render_links'), '#theme' => 'links__contextual', @@ -142,14 +174,11 @@ function contextual_element_info() { /** * Implements hook_preprocess(). * - * @see contextual_pre_render_links() + * @see contextual_pre_render_placeholder() + * @see contextual_page_build() + * @see \Drupal\contextual\ContextualController::render() */ function contextual_preprocess(&$variables, $hook) { - // Nothing to do here if the user is not permitted to access contextual links. - if (!user_access('access contextual links')) { - return; - } - $hooks = theme_get_registry(FALSE); // Determine the primary theme function argument. @@ -165,18 +194,43 @@ function contextual_preprocess(&$variables, $hook) { } if (isset($element) && is_array($element) && !empty($element['#contextual_links'])) { - // Initialize the template variable as a renderable array. - $variables['title_suffix']['contextual_links'] = array( - '#type' => 'contextual_links', - '#contextual_links' => $element['#contextual_links'], - '#element' => $element, - ); // Mark this element as potentially having contextual links attached to it. $variables['attributes']['class'][] = 'contextual-region'; + + // Renders a contextual links placeholder unconditionally, thus not breaking + // the render cache. Although the empty placeholder is rendered for all + // users, contextual_page_build() only adds the drupal.contextual-links + // library for users with the 'access contextual links' permission, thus + // preventing unnecessary HTTP requests for users without that permission. + $variables['title_suffix']['contextual_links'] = array( + '#type' => 'contextual_links_placeholder', + '#id' => _contextual_links_to_id($element['#contextual_links']), + ); } } /** + * Pre-render callback: Renders a contextual links placeholder into #markup. + * + * Renders an empty (hence invisible) placeholder div with a data-attribute that + * contains an identifier ("contextual id"), which allows the JavaScript of the + * drupal.contextual-links library to dynamically render contextual links. + * + * @param $element + * A structured array with #id containing a "contextual id". + * + * @return + * The passed-in element with a contextual link placeholder in '#markup'. + * + * @see _contextual_links_to_id() + * @see contextual_element_info() + */ +function contextual_pre_render_placeholder($element) { + $element['#markup'] = '
'; + return $element; +} + +/** * Pre-render callback: Builds a renderable array for contextual links. * * @param $element @@ -230,3 +284,73 @@ function contextual_pre_render_links($element) { return $element; } +/** + * Implements hook_contextual_links_view_alter(). + * + * @see \Drupal\contextual\Plugin\views\field\ContextualLinks::render() + */ +function contextual_contextual_links_view_alter(&$element, $items) { + if (isset($element['#contextual_links']['contextual'])) { + $encoded_links = $element['#contextual_links']['contextual'][2]['contextual-views-field-links']; + $element['#links'] = drupal_json_decode(rawurldecode($encoded_links)); + } +} + +/** + * Serializes #contextual_links property value array to a string. + * + * Examples: + * - node:node:1: + * - views_ui:admin/structure/views/view:frontpage:location=page&view_name=frontpage&view_display_id=page_1 + * - menu:admin/structure/menu/manage:tools:|block:admin/structure/block/manage:bartik.tools: + * + * So, expressed in a pattern: + * ::: + * + * The (dynamic) path args are joined with slashes. The options are encoded as a + * query string. + * + * @param array $contextual_links + * The $element['#contextual_links'] value for some render element. + * + * @return string + * A serialized representation of a #contextual_links property value array for + * use in a data- attribute. + */ +function _contextual_links_to_id($contextual_links) { + $id = ''; + foreach ($contextual_links as $module => $args) { + $parent_path = $args[0]; + $path_args = implode('/', $args[1]); + $metadata = drupal_http_build_query((isset($args[2])) ? $args[2] : array()); + + if (drupal_strlen($id) > 0) { + $id .= '|'; + } + $id .= $module . ':' . $parent_path . ':' . $path_args . ':' . $metadata; + } + return $id; +} + +/** + * Unserializes the result of _contextual_links_to_id(). + * + * @see _contextual_links_to_id + * + * @param string $id + * A serialized representation of a #contextual_links property value array. + * + * @return array + * The value for a #contextual_links property. + */ +function _contextual_id_to_links($id) { + $contextual_links = array(); + $contexts = explode('|', $id); + foreach ($contexts as $context) { + list($module, $parent_path, $path_args, $metadata_raw) = explode(':', $context); + $path_args = explode('/', $path_args); + $metadata = drupal_get_query_array($metadata_raw); + $contextual_links[$module] = array($parent_path, $path_args, $metadata); + } + return $contextual_links; +} diff --git a/core/modules/contextual/contextual.routing.yml b/core/modules/contextual/contextual.routing.yml new file mode 100644 index 0000000..5dd4457 --- /dev/null +++ b/core/modules/contextual/contextual.routing.yml @@ -0,0 +1,6 @@ +contextual_render: + pattern: '/contextual/render' + defaults: + _controller: '\Drupal\contextual\ContextualController::render' + requirements: + _permission: 'access contextual links' diff --git a/core/modules/contextual/contextual.toolbar.js b/core/modules/contextual/contextual.toolbar.js index 635e745..0059868 100644 --- a/core/modules/contextual/contextual.toolbar.js +++ b/core/modules/contextual/contextual.toolbar.js @@ -23,10 +23,7 @@ var options = { */ function initContextualToolbar (context) { var contextualToolbar = Drupal.contextualToolbar; - var model = contextualToolbar.model = new contextualToolbar.Model({ - isViewing: true, - isVisible: false - }); + var model = contextualToolbar.model = new contextualToolbar.Model(); var viewOptions = $.extend({ el: $('.js .toolbar .bar .contextual-toolbar-tab'), @@ -38,27 +35,33 @@ function initContextualToolbar (context) { // Update the model based on overlay events. $(document).on({ 'drupalOverlayOpen.contextualToolbar': function () { - model.set('isVisible', false); + model.set('overlayIsOpen', true); }, 'drupalOverlayClose.contextualToolbar': function () { - model.set('isVisible', true); + model.set('overlayIsOpen', false); } }); // Show the edit tab while there's >=1 contextual link. - var collection = Drupal.contextual.collection; - var updateVisibility = function () { - model.set('isVisible', collection.length > 0); - }; - collection.on('reset remove add', updateVisibility); - updateVisibility(); - - // Whenever edit mode is toggled, update all contextual links. + var contextualCollection = Drupal.contextual.collection; + function trackContextualCount () { + model.set('contextualCount', contextualCollection.length); + } + contextualCollection.on('reset remove add', trackContextualCount); + trackContextualCount(); + + // Whenever edit mode is toggled, lock all contextual links. model.on('change:isViewing', function() { - collection.each(function (contextualModel) { + contextualCollection.each(function (contextualModel) { contextualModel.set('isLocked', !model.get('isViewing')); }); }); + // When a new contextual link is added and edit mode is enabled, lock it. + contextualCollection.on('add', function (contextualModel) { + if (!model.get('isViewing')) { + contextualModel.set('isLocked', true); + } + }); // Checks whether localStorage indicates we should start in edit mode // rather than view mode. @@ -93,11 +96,21 @@ Drupal.contextualToolbar = { defaults: { // Indicates whether the toggle is currently in "view" or "edit" mode. isViewing: true, - // Indicates whether the toggle should be visible or hidden. + // Indicates whether the toggle should be visible or hidden. Automatically + // calculated, depends on overlayIsOpen and contextualCount. isVisible: false, + // Indicates whether the overlay is open or not. + overlayIsOpen: false, + // Tracks how many contextual links exist on the page. + contextualCount: 0, // A TabbingContext object as returned by Drupal.TabbingManager: the set // of tabbable elements when edit mode is enabled. tabbingContext: null + }, + initialize: function () { + this.on('change:overlayIsOpen change:contextualCount', function (model) { + model.set('isVisible', !model.get('overlayIsOpen') && model.get('contextualCount') > 0); + }); } }), diff --git a/core/modules/contextual/lib/Drupal/contextual/ContextualController.php b/core/modules/contextual/lib/Drupal/contextual/ContextualController.php new file mode 100644 index 0000000..aca277d --- /dev/null +++ b/core/modules/contextual/lib/Drupal/contextual/ContextualController.php @@ -0,0 +1,50 @@ +request->get('ids'); + if (!isset($ids)) { + throw new BadRequestHttpException(t('No contextual ids specified.')); + } + + $rendered = array(); + foreach ($ids as $id) { + $element = array( + '#type' => 'contextual_links', + '#contextual_links' => _contextual_id_to_links($id), + ); + $rendered[$id] = drupal_render($element); + } + + return new JsonResponse($rendered); + } + +} diff --git a/core/modules/contextual/lib/Drupal/contextual/Plugin/views/field/ContextualLinks.php b/core/modules/contextual/lib/Drupal/contextual/Plugin/views/field/ContextualLinks.php index 86aa796..13f88a4 100644 --- a/core/modules/contextual/lib/Drupal/contextual/Plugin/views/field/ContextualLinks.php +++ b/core/modules/contextual/lib/Drupal/contextual/Plugin/views/field/ContextualLinks.php @@ -64,6 +64,9 @@ function pre_render(&$values) { /** * Render the contextual fields. + * + * @see contextual_preprocess() + * @see contextual_contextual_links_view_alter() */ function render($values) { $links = array(); @@ -92,19 +95,23 @@ function render($values) { } } + // Renders a contextual links placeholder. if (!empty($links)) { - $build = array( - '#prefix' => '
', - '#suffix' => '
', - '#theme' => 'links__contextual', - '#links' => $links, - '#attributes' => array('class' => array('contextual-links')), - '#attached' => array( - 'library' => array(array('contextual', 'contextual-links')), - ), - '#access' => user_access('access contextual links'), + $contextual_links = array( + 'contextual' => array( + '', + array(), + array( + 'contextual-views-field-links' => drupal_encode_path(drupal_json_encode($links)), + ) + ) ); - return drupal_render($build); + + $element = array( + '#type' => 'contextual_links_placeholder', + '#id' => _contextual_links_to_id($contextual_links), + ); + return drupal_render($element); } else { return ''; diff --git a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php index f84e9f4..c4f38e9 100644 --- a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php +++ b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php @@ -2,7 +2,7 @@ /** * @file - * Definition of Drupal\contextual\Tests\ContextualDynamicContextTest. + * Contains \Drupal\contextual\Tests\ContextualDynamicContextTest. */ namespace Drupal\contextual\Tests; @@ -19,7 +19,7 @@ class ContextualDynamicContextTest extends WebTestBase { * * @var array */ - public static $modules = array('contextual', 'node', 'views'); + public static $modules = array('contextual', 'node', 'views', 'views_ui'); public static function getInfo() { return array( @@ -31,16 +31,24 @@ public static function getInfo() { function setUp() { parent::setUp(); + $this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page')); $this->drupalCreateContentType(array('type' => 'article', 'name' => 'Article')); - $web_user = $this->drupalCreateUser(array('access content', 'access contextual links', 'edit any article content')); - $this->drupalLogin($web_user); + + $this->editor_user = $this->drupalCreateUser(array('access content', 'access contextual links', 'edit any article content')); + $this->authenticated_user = $this->drupalCreateUser(array('access content', 'access contextual links')); + $this->anonymous_user = $this->drupalCreateUser(array('access content')); } /** - * Tests contextual links on node lists with different permissions. + * Tests contextual links with different permissions. + * + * Ensures that contextual link placeholders always exist, even if the user is + * not allowed to use contextual links. */ - function testNodeLinks() { + function testDifferentPermissions() { + $this->drupalLogin($this->editor_user); + // Create three nodes in the following order: // - An article, which should be user-editable. // - A page, which should not be user-editable. @@ -49,11 +57,120 @@ function testNodeLinks() { $node2 = $this->drupalCreateNode(array('type' => 'page', 'promote' => 1)); $node3 = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1)); - // Now, on the front page, all article nodes should have contextual edit - // links. The page node in between should not. + // Now, on the front page, all article nodes should have contextual links + // placeholders, as should the view that contains them. + $ids = array( + 'node:node:' . $node1->nid . ':', + 'node:node:' . $node2->nid . ':', + 'node:node:' . $node3->nid . ':', + 'views_ui:admin/structure/views/view:frontpage:location=page&name=frontpage&display_id=page_1', + ); + + // Editor user: can access contextual links and can edit articles. + $this->drupalGet('node'); + for ($i = 0; $i < count($ids); $i++) { + $this->assertContextualLinkPlaceHolder($ids[$i]); + } + $this->renderContextualLinks(array(), 'node'); + $this->assertResponse(400); + $this->assertRaw('No contextual ids specified.'); + $response = $this->renderContextualLinks($ids, 'node'); + $this->assertResponse(200); + $json = drupal_json_decode($response); + $this->assertIdentical($json[$ids[0]], ''); + $this->assertIdentical($json[$ids[1]], ''); + $this->assertIdentical($json[$ids[2]], ''); + $this->assertIdentical($json[$ids[3]], ''); + + // Authenticated user: can access contextual links, cannot edit articles. + $this->drupalLogin($this->authenticated_user); $this->drupalGet('node'); - $this->assertRaw('node/' . $node1->nid . '/edit', 'Edit link for oldest article node showing.'); - $this->assertNoRaw('node/' . $node2->nid . '/edit', 'No edit link for page nodes.'); - $this->assertRaw('node/' . $node3->nid . '/edit', 'Edit link for most recent article node showing.'); + for ($i = 0; $i < count($ids); $i++) { + $this->assertContextualLinkPlaceHolder($ids[$i]); + } + $this->renderContextualLinks(array(), 'node'); + $this->assertResponse(400); + $this->assertRaw('No contextual ids specified.'); + $response = $this->renderContextualLinks($ids, 'node'); + $this->assertResponse(200); + $json = drupal_json_decode($response); + $this->assertIdentical($json[$ids[0]], ''); + $this->assertIdentical($json[$ids[1]], ''); + $this->assertIdentical($json[$ids[2]], ''); + $this->assertIdentical($json[$ids[3]], ''); + + // Anonymous user: cannot access contextual links. + $this->drupalLogin($this->anonymous_user); + $this->drupalGet('node'); + for ($i = 0; $i < count($ids); $i++) { + $this->assertContextualLinkPlaceHolder($ids[$i]); + } + $this->renderContextualLinks(array(), 'node'); + $this->assertResponse(403); + $this->renderContextualLinks($ids, 'node'); + $this->assertResponse(403); + } + + /** + * Asserts that a contextual link placeholder with the given id exists. + * + * @param string $id + * A contextual link id. + * + * @return bool + */ + protected function assertContextualLinkPlaceHolder($id) { + $this->assertRaw('
', format_string('Contextual link placeholder with id @id exists.', array('@id' => $id))); + } + + /** + * Asserts that a contextual link placeholder with the given id does not exist. + * + * @param string $id + * A contextual link id. + * + * @return bool + */ + protected function assertNoContextualLinkPlaceHolder($id) { + $this->assertNoRaw('
', format_string('Contextual link placeholder with id @id does not exist.', array('@id' => $id))); + } + + /** + * Get server-rendered contextual links for the given contextual link ids. + * + * @param array $ids + * An array of contextual link ids. + * @param string $current_path + * The Drupal path for the page for which the contextual links are rendered. + * + * @return string + * The response body. + */ + protected function renderContextualLinks($ids, $current_path) { + // Build POST values. + $post = array(); + for ($i = 0; $i < count($ids); $i++) { + $post['ids[' . $i . ']'] = $ids[$i]; + } + + // Serialize POST values. + foreach ($post as $key => $value) { + // Encode according to application/x-www-form-urlencoded + // Both names and values needs to be urlencoded, according to + // http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1 + $post[$key] = urlencode($key) . '=' . urlencode($value); + } + $post = implode('&', $post); + + // Perform HTTP request. + return $this->curlExec(array( + CURLOPT_URL => url('contextual/render', array('absolute' => TRUE, 'query' => array('destination' => $current_path))), + CURLOPT_POST => TRUE, + CURLOPT_POSTFIELDS => $post, + CURLOPT_HTTPHEADER => array( + 'Accept: application/json', + 'Content-Type: application/x-www-form-urlencoded', + ), + )); } } diff --git a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualUnitTest.php b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualUnitTest.php new file mode 100644 index 0000000..51e9a51 --- /dev/null +++ b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualUnitTest.php @@ -0,0 +1,122 @@ + 'Conversion to and from "contextual id"s (for placeholders)', + 'description' => 'Tests all edge cases of converting from #contextual_links to ids and vice versa.', + 'group' => 'Contextual', + ); + } + + /** + * Provides testcases for testContextualLinksToId() and + */ + function _contextual_links_id_testcases() { + // Test branch conditions: + // - one module. + // - one dynamic path argument. + // - no metadata. + $tests[] = array( + 'links' => array( + 'node' => array( + 'node', + array('14031991'), + array() + ), + ), + 'id' => 'node:node:14031991:', + ); + + // Test branch conditions: + // - one module. + // - multiple dynamic path arguments. + // - no metadata. + $tests[] = array( + 'links' => array( + 'foo' => array( + 'baz/in/ga', + array('bar', 'baz', 'qux'), + array() + ), + ), + 'id' => 'foo:baz/in/ga:bar/baz/qux:', + ); + + // Test branch conditions: + // - one module. + // - one dynamic path argument. + // - metadata. + $tests[] = array( + 'links' => array( + 'views_ui' => array( + 'admin/structure/views/view', + array('frontpage'), + array( + 'location' => 'page', + 'display' => 'page_1', + ) + ), + ), + 'id' => 'views_ui:admin/structure/views/view:frontpage:location=page&display=page_1', + ); + + // Test branch conditions: + // - multiple modules. + // - multiple dynamic path arguments. + $tests[] = array( + 'links' => array( + 'node' => array( + 'node', + array('14031991'), + array() + ), + 'foo' => array( + 'baz/in/ga', + array('bar', 'baz', 'qux'), + array() + ), + 'edge' => array( + 'edge', + array('20011988'), + array() + ), + ), + 'id' => 'node:node:14031991:|foo:baz/in/ga:bar/baz/qux:|edge:edge:20011988:', + ); + + return $tests; + } + + /** + * Tests _contextual_links_to_id(). + */ + function testContextualLinksToId() { + $tests = $this->_contextual_links_id_testcases(); + foreach ($tests as $test) { + $this->assertIdentical(_contextual_links_to_id($test['links']), $test['id']); + } + } + + /** + * Tests _contextual_id_to_links(). + */ + function testContextualIdToLinks() { + $tests = $this->_contextual_links_id_testcases(); + foreach ($tests as $test) { + $this->assertIdentical(_contextual_id_to_links($test['id']), $test['links']); + } + } +} diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js index 6899125..36c884f 100644 --- a/core/modules/edit/js/edit.js +++ b/core/modules/edit/js/edit.js @@ -271,9 +271,9 @@ function fetchMissingMetadata (callback) { * An object with the following properties: * - String entity: an Edit entity identifier, e.g. "node/1" or * "custom_block/5". - * - jQuery $el: element pointing to the contextual links for this entity. - * - jQuery $region: element pointing to the contextual region for this + * - DOM el: element pointing to the contextual links placeholder for this * entity. + * - DOM region: element pointing to the contextual region for this entity. * @return Boolean * Returns true when a contextual the given contextual link metadata can be * removed from the queue (either because the contextual link has been set up @@ -324,8 +324,9 @@ function initializeEntityContextualLink (contextualLink) { fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields); // Set up contextual link view. + var $links = $(contextualLink.el).find('.contextual-links'); var contextualLinkView = new Drupal.edit.ContextualLinkView($.extend({ - el: $('
  • ').prependTo(contextualLink.el), + el: $('
  • ').prependTo($links), model: entityModel, appModel: Drupal.edit.app.model }, options)); diff --git a/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php b/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php index f3a4391..6c3ed1a 100644 --- a/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php +++ b/core/modules/menu/lib/Drupal/menu/Tests/MenuTest.php @@ -306,11 +306,30 @@ function testSystemMenuRename() { * Tests the contextual links on a menu block. */ public function testBlockContextualLinks() { - $this->drupalLogin($this->drupalCreateUser(array('administer menu', 'access contextual links'))); + $this->drupalLogin($this->drupalCreateUser(array('administer menu', 'access contextual links', 'administer blocks'))); $this->addMenuLink(); - $this->drupalPlaceBlock('system_menu_block:menu-tools', array('label' => 'Tools', 'module' => 'system')); + $block = $this->drupalPlaceBlock('system_menu_block:menu-tools', array('label' => 'Tools', 'module' => 'system')); $this->drupalGet('test-page'); - $this->assertLinkByHref("admin/structure/menu/manage/tools/edit"); + + $id = 'block:admin/structure/block/manage:' . $block->id() . ':|menu:admin/structure/menu/manage:tools:'; + // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:assertContextualLinkPlaceHolder() + $this->assertRaw('
    ', format_string('Contextual link placeholder with id @id exists.', array('@id' => $id))); + + // Get server-rendered contextual links. + // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:renderContextualLinks() + $post = urlencode('ids[0]') . '=' . urlencode($id); + $response = $this->curlExec(array( + CURLOPT_URL => url('contextual/render', array('absolute' => TRUE, 'query' => array('destination' => 'test-page'))), + CURLOPT_POST => TRUE, + CURLOPT_POSTFIELDS => $post, + CURLOPT_HTTPHEADER => array( + 'Accept: application/json', + 'Content-Type: application/x-www-form-urlencoded', + ), + )); + $this->assertResponse(200); + $json = drupal_json_decode($response); + $this->assertIdentical($json[$id], ''); } /** diff --git a/core/modules/views/js/views-contextual.js b/core/modules/views/js/views-contextual.js index 3c3ae96..d10768e 100644 --- a/core/modules/views/js/views-contextual.js +++ b/core/modules/views/js/views-contextual.js @@ -8,10 +8,11 @@ Drupal.behaviors.viewsContextualLinks = { attach: function (context) { - // If there are views-related contextual links attached to the main page - // content, find the smallest region that encloses both the links and the - // view, and display it as a contextual links region. - $('.views-contextual-links-page', context).closest(':has(.view)').addClass('contextual-region'); + var id = $('body[data-views-page-contextual-id]') + .attr('data-views-page-contextual-id'); + $('[data-contextual-id="' + id + '"]') + .closest(':has(.view)') + .addClass('contextual-region'); } }; diff --git a/core/modules/views/views.module b/core/modules/views/views.module index 9075033..61826f8 100644 --- a/core/modules/views/views.module +++ b/core/modules/views/views.module @@ -468,6 +468,11 @@ function views_page_alter(&$page) { * Implements MODULE_preprocess_HOOK(). */ function views_preprocess_html(&$variables) { + // Early-return to prevent adding unnecessary JavaScript. + if (!user_access('access contextual links')) { + return; + } + // If the page contains a view as its main content, contextual links may have // been attached to the page as a whole; for example, by views_page_alter(). // This allows them to be associated with the page and rendered by default @@ -480,10 +485,11 @@ function views_preprocess_html(&$variables) { // page.tpl.php, so we can only find it using JavaScript. We therefore remove // the "contextual-region" class from the tag here and add // JavaScript that will insert it back in the correct place. - if (!empty($variables['page']['#views_contextual_links_info'])) { + if (!empty($variables['page']['#views_contextual_links'])) { $key = array_search('contextual-region', $variables['attributes']['class']); if ($key !== FALSE) { unset($variables['attributes']['class'][$key]); + $variables['attributes']['data-views-page-contextual-id'] = $variables['title_suffix']['contextual_links']['#id']; // Add the JavaScript, with a group and weight such that it will run // before modules/contextual/contextual.js. drupal_add_library('views', 'views.contextual-links'); @@ -492,18 +498,6 @@ function views_preprocess_html(&$variables) { } /** - * Implements hook_contextual_links_view_alter(). - */ -function views_contextual_links_view_alter(&$element, $items) { - // If we are rendering views-related contextual links attached to the overall - // page array, add a class to the list of contextual links. This will be used - // by the JavaScript added in views_preprocess_html(). - if (!empty($element['#element']['#views_contextual_links_info']) && !empty($element['#element']['#type']) && $element['#element']['#type'] == 'page') { - $element['#attributes']['class'][] = 'views-contextual-links-page'; - } -} - -/** * Adds contextual links associated with a view display to a renderable array. * * This function should be called when a view is being rendered in a particular @@ -612,12 +606,15 @@ function views_add_contextual_links(&$render_element, $location, ViewExecutable // If the link was valid, attach information about it to the renderable // array. if ($valid) { - $render_element['#contextual_links'][$module] = array($link['parent path'], $args); - $render_element['#views_contextual_links_info'][$module] = array( - 'location' => $location, - 'view' => $view, - 'view_name' => $view->storage->id(), - 'view_display_id' => $display_id, + $render_element['#views_contextual_links'] = TRUE; + $render_element['#contextual_links'][$module] = array( + $link['parent path'], + $args, + array( + 'location' => $location, + 'name' => $view->storage->id(), + 'display_id' => $display_id, + ) ); } } diff --git a/core/modules/views_ui/lib/Drupal/views_ui/Tests/DisplayTest.php b/core/modules/views_ui/lib/Drupal/views_ui/Tests/DisplayTest.php index 3c22792..b3ded3b 100644 --- a/core/modules/views_ui/lib/Drupal/views_ui/Tests/DisplayTest.php +++ b/core/modules/views_ui/lib/Drupal/views_ui/Tests/DisplayTest.php @@ -284,8 +284,27 @@ public function testPageContextualLinks() { $this->drupalLogin($this->drupalCreateUser(array('administer views', 'access contextual links'))); $view = entity_load('view', 'test_display'); $view->enable()->save(); + $this->drupalGet('test-display'); - $this->assertLinkByHref("admin/structure/views/view/{$view->id()}/edit/page_1"); + $id = 'views_ui:admin/structure/views/view:test_display:location=page&name=test_display&display_id=page_1'; + // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:assertContextualLinkPlaceHolder() + $this->assertRaw('
    ', format_string('Contextual link placeholder with id @id exists.', array('@id' => $id))); + + // Get server-rendered contextual links. + // @see \Drupal\contextual\Tests\ContextualDynamicContextTest:renderContextualLinks() + $post = urlencode('ids[0]') . '=' . urlencode($id); + $response = $this->curlExec(array( + CURLOPT_URL => url('contextual/render', array('absolute' => TRUE, 'query' => array('destination' => 'test-display'))), + CURLOPT_POST => TRUE, + CURLOPT_POSTFIELDS => $post, + CURLOPT_HTTPHEADER => array( + 'Accept: application/json', + 'Content-Type: application/x-www-form-urlencoded', + ), + )); + $this->assertResponse(200); + $json = drupal_json_decode($response); + $this->assertIdentical($json[$id], ''); } } diff --git a/core/modules/views_ui/views_ui.module b/core/modules/views_ui/views_ui.module index f169fd0..0d461be 100644 --- a/core/modules/views_ui/views_ui.module +++ b/core/modules/views_ui/views_ui.module @@ -339,8 +339,8 @@ function views_ui_contextual_links_view_alter(&$element, $items) { // Append the display ID to the Views UI edit links, so that clicking on the // contextual link takes you directly to the correct display tab on the edit // screen. - elseif (!empty($element['#links']['views-ui-edit']) && !empty($element['#element']['#views_contextual_links_info']['views_ui']['view_display_id'])) { - $display_id = $element['#element']['#views_contextual_links_info']['views_ui']['view_display_id']; + elseif (!empty($element['#links']['views-ui-edit'])) { + $display_id = $element['#contextual_links']['views_ui'][2]['display_id']; $element['#links']['views-ui-edit']['href'] .= '/' . $display_id; } }