core/modules/contextual/contextual.js | 30 +++-- core/modules/contextual/contextual.module | 73 +++++++++-- .../lib/Drupal/contextual/ContextualController.php | 17 +-- .../Tests/ContextualDynamicContextTest.php | 137 ++++++++++++++++++-- .../Drupal/contextual/Tests/ContextualUnitTest.php | 122 +++++++++++++++++ .../lib/Drupal/views/Tests/UI/DisplayTest.php | 21 ++- core/modules/views/views.module | 16 ++- core/modules/views/views_ui/views_ui.module | 4 +- 8 files changed, 360 insertions(+), 60 deletions(-) diff --git a/core/modules/contextual/contextual.js b/core/modules/contextual/contextual.js index 31e30e4..c06128c 100644 --- a/core/modules/contextual/contextual.js +++ b/core/modules/contextual/contextual.js @@ -3,7 +3,7 @@ * Attaches behaviors for the Contextual module. */ -(function ($, Drupal, document, window) { +(function ($, Drupal, drupalSettings) { "use strict"; @@ -49,22 +49,24 @@ Drupal.behaviors.contextual = { ids.push($(this).attr('data-contextual-id')); }); $.ajax({ - url: Drupal.url('contextual/render'), + 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)) { - var $contextual = $context - // Find the location for the current rendered contextual link. - .find('[data-contextual-id="' + id + '"]') - // Move it into the DOM. - .html(results[id]); - // Create a Drupal.contextual object and notify listeners of a new - // contextual link. - $(document).trigger('drupalContextualLinkAdded', { - contextual: new Drupal.contextual($contextual, $contextual.closest('.contextual-region')) - }); + for (var id in results) { + if (results.hasOwnProperty(id)) { + var $contextual = $context + // Find the location for the current rendered contextual link. + .find('[data-contextual-id="' + id + '"]') + // Move it into the DOM. + .html(results[id]); + // Create a Drupal.contextual object and notify listeners of a new + // contextual link. + $(document).trigger('drupalContextualLinkAdded', { + contextual: new Drupal.contextual($contextual, $contextual.closest('.contextual-region')) + }); + } } } }); @@ -241,4 +243,4 @@ Drupal.theme.contextualTrigger = function () { return ''; }; -})(jQuery, Drupal, document, window); +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module index ea5e439..d9a0461 100644 --- a/core/modules/contextual/contextual.module +++ b/core/modules/contextual/contextual.module @@ -174,17 +174,8 @@ function contextual_preprocess(&$variables, $hook) { // JavaScript to determine which contextual links should be rendered. // This div with data- attribute is added unconditionally, and thus does not // break the render cache. - // Examples of the data- attribute syntax: - // - node[node]:1 - // - views_ui[admin/structure/views/view]:frontpage - // - menu[admin/structure/menu/manage]:tools|block[admin/structure/block/manage]:bartik.tools - $id = ''; - foreach ($element['#contextual_links'] as $module => $args) { - if (drupal_strlen($id) > 0) { - $id .= '|'; - } - $id .= $module . '[' . $args[0] . ']:' . implode(':', $args[1]); - } + // @see _contextual_links_to_id() + $id = _contextual_links_to_id($element['#contextual_links']); $variables['title_suffix']['contextual_links']['#id'] = $id; $variables['title_suffix']['contextual_links']['#markup'] = '
'; } @@ -244,3 +235,63 @@ function contextual_pre_render_links($element) { return $element; } +/** + * Serializes #contextual_links property metadata to a "contextual id". + * + * 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 metadata is encoded as a + * query string + * + * @param array $contextual_links + * The $element['#contextual_links'] value for some render element. + * + * @return string + * A contextual id. + */ +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; +} + +/** + * Serializes a contextual id back to #contextual_links property metadata. + * + * The inverse operation of _contextual_links_to_id(). + * + * @see _contextual_links_to_id + * + * @param string $id + * A contextual id. + * + * @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/lib/Drupal/contextual/ContextualController.php b/core/modules/contextual/lib/Drupal/contextual/ContextualController.php index f776844..5c986b6 100644 --- a/core/modules/contextual/lib/Drupal/contextual/ContextualController.php +++ b/core/modules/contextual/lib/Drupal/contextual/ContextualController.php @@ -32,28 +32,15 @@ class ContextualController extends ContainerAware { public function render(Request $request) { $ids = $request->request->get('ids'); if (!isset($ids)) { - throw new BadRequestHttpException(); + throw new BadRequestHttpException(t('No contextual ids specified.')); } $rendered = array(); foreach ($ids as $id) { $element = array( '#type' => 'contextual_links', - '#contextual_links' => array(), + '#contextual_links' => _contextual_id_to_links($id), ); - - // Figure out which contextual links should be rendered. - $contexts = explode('|', $id); - foreach ($contexts as $context) { - $args = explode(':', $context); - $provider = array_shift($args); - $pos = strpos($provider, '['); - $module = drupal_substr($provider, 0, $pos); - $parent_path = drupal_substr($provider, $pos + 1, drupal_strlen($provider) - $pos - 2); - $element['#contextual_links'][$module] = array($parent_path, $args); - } - - // Render the contextual links. $rendered[$id] = drupal_render($element); } diff --git a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php index c657684..45f9fd6 100644 --- a/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php +++ b/core/modules/contextual/lib/Drupal/contextual/Tests/ContextualDynamicContextTest.php @@ -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', + ); + + // 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]], NULL); + $this->assertIdentical($json[$ids[2]], ''); + $this->assertIdentical($json[$ids[3]], NULL); + + // 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]], NULL); + $this->assertIdentical($json[$ids[1]], NULL); + $this->assertIdentical($json[$ids[2]], NULL); + $this->assertIdentical($json[$ids[3]], NULL); + + // 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/views/lib/Drupal/views/Tests/UI/DisplayTest.php b/core/modules/views/lib/Drupal/views/Tests/UI/DisplayTest.php index 15cb97a..0398d30 100644 --- a/core/modules/views/lib/Drupal/views/Tests/UI/DisplayTest.php +++ b/core/modules/views/lib/Drupal/views/Tests/UI/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/views.module b/core/modules/views/views.module index 698f32e..5c86dd6 100644 --- a/core/modules/views/views.module +++ b/core/modules/views/views.module @@ -484,7 +484,7 @@ 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']['#contextual_links']['views_ui'])) { $key = array_search('contextual-region', $variables['attributes']['class']->value()); if ($key !== FALSE) { unset($variables['attributes']['class'][$key]); @@ -605,12 +605,14 @@ 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['#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/views_ui/views_ui.module b/core/modules/views/views_ui/views_ui.module index 4c3ae95..d62eaa5 100644 --- a/core/modules/views/views_ui/views_ui.module +++ b/core/modules/views/views_ui/views_ui.module @@ -376,8 +376,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; } }