.../Drupal/block/Tests/Views/DisplayBlockTest.php | 23 ++-
core/modules/contextual/contextual.js | 89 +++++++-----
core/modules/contextual/contextual.module | 150 ++++++++++++++++++--
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/edit.module | 12 +-
core/modules/edit/js/edit.js | 6 +-
core/modules/views/js/views-contextual.js | 9 +-
.../lib/Drupal/views/Tests/UI/DisplayTest.php | 21 ++-
core/modules/views/views.module | 35 +++--
core/modules/views_ui/views_ui.module | 16 ++-
15 files changed, 629 insertions(+), 123 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 617f33d..17bffaa 100644
--- a/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php
+++ b/core/modules/block/lib/Drupal/block/Tests/Views/DisplayBlockTest.php
@@ -153,9 +153,28 @@ public function testViewsBlockForm() {
*/
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'));
+ $block = $this->drupalPlaceBlock('views_block:test_view_block-block_1', array(), array('title' => 'test_view_block-block_1: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..d7f5cb0 100644
--- a/core/modules/contextual/contextual.module
+++ b/core/modules/contextual/contextual.module
@@ -6,6 +6,18 @@
*/
/**
+ * 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.
+ */
+function contextual_custom_theme() {
+ if (substr(current_path(), 0, 11) === 'contextual/') {
+ return ajax_base_page_theme();
+ }
+}
+
+/**
* Implements hook_toolbar().
*/
function contextual_toolbar() {
@@ -24,8 +36,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 +51,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 +141,7 @@ function contextual_library_info() {
array('system', 'jquery.once'),
array('system', 'drupal.tabbingmanager'),
array('system', 'drupal.announce'),
+ array('contextual', 'drupal.contextual-links')
),
);
@@ -125,6 +152,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 +173,11 @@ function contextual_element_info() {
/**
* Implements hook_preprocess().
*
- * @see contextual_pre_render_links()
+ * @see contextual_pre_render_placeholder()
+ * @see contextual_page_alter()
+ * @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 +193,41 @@ 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. The drupal.contextual-links library's JavaScript must
+ // only be loaded if the user has the 'access contextual links' 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 +281,74 @@ 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 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/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 3742c43..4788ed9 100644
--- a/core/modules/contextual/contextual.toolbar.js
+++ b/core/modules/contextual/contextual.toolbar.js
@@ -27,10 +27,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'),
@@ -42,27 +39,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.
@@ -97,11 +100,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,
// The set of elements that can be reached via the tab key 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..b454ea7
--- /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/edit.module b/core/modules/edit/edit.module
index 1b94b91..993ad45 100644
--- a/core/modules/edit/edit.module
+++ b/core/modules/edit/edit.module
@@ -39,16 +39,12 @@ function edit_permission() {
}
/**
- * Implements hook_contextual_links_view_alter().
+ * Implements hook_page_build().
*
- * In-place editing builds upon contextual.module, but doesn't actually add its
- * "Quick edit" contextual link in PHP (i.e. here) because:
- * - that would require to add a local task menu item in the menu system, which
- * doesn't make any sense, since there is no corresponding page;
- * - it should only work when JavaScript is enabled, because only then in-place
- * editing is possible.
+ * Adds the edit library to the page for any user who has the 'access in-place
+ * editing' permission.
*/
-function edit_contextual_links_view_alter(&$element, $items) {
+function edit_page_build(&$element) {
if (!user_access('access in-place editing')) {
return;
}
diff --git a/core/modules/edit/js/edit.js b/core/modules/edit/js/edit.js
index 61157c4..6deea13 100644
--- a/core/modules/edit/js/edit.js
+++ b/core/modules/edit/js/edit.js
@@ -122,7 +122,8 @@ $(document).on('drupalContextualLinkAdded', function (event, data) {
* @param Object contextualLink
* An object with the following properties:
* - entity: an Edit entity identifier, e.g. "node/1" or "custom_block/5".
- * - $el: a jQuery element pointing to the contextual links for this entity.
+ * - $el: a jQuery element pointing to the contextual links placeholder fo
+ * this entity.
* - $region: a jQuery element pointing to the contextual region for this
* entity.
*
@@ -163,8 +164,9 @@ Drupal.edit.setUpContextualLink = function (contextualLink) {
// The entity for the given contextual link contains at least one field that
// the current user may edit in-place; instantiate ContextualLinkView.
if (hasFieldWithPermission(editIDs)) {
+ var $links = contextualLink.$el.find('.contextual-links');
new Drupal.edit.views.ContextualLinkView({
- el: $('').prependTo(contextualLink.$el),
+ el: $('').prependTo($links),
model: Drupal.edit.app.model,
entity: contextualLink.entity
});
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/lib/Drupal/views/Tests/UI/DisplayTest.php b/core/modules/views/lib/Drupal/views/Tests/UI/DisplayTest.php
index 4e7f32d..2036314 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 23d2651..608de81 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']->value());
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/views_ui.module b/core/modules/views_ui/views_ui.module
index a5efa33..00cda92 100644
--- a/core/modules/views_ui/views_ui.module
+++ b/core/modules/views_ui/views_ui.module
@@ -55,16 +55,28 @@ function views_ui_menu() {
// views_ui_menu_local_tasks_alter().
$items['admin/structure/views/view/%'] = array(
'route_name' => 'views_ui.edit',
+ // @todo Remove this. Route access checking does not work for contextual
+ // links generation yet. @see http://drupal.org/node/1938960#comment-7201292
+ 'access callback' => 'user_access',
+ 'access arguments' => array('administer views'),
);
$items['admin/structure/views/view/%/edit'] = array(
'title' => 'Edit view',
'type' => MENU_DEFAULT_LOCAL_TASK,
'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+ // @todo Remove this. Route access checking does not work for contextual
+ // links generation yet. @see http://drupal.org/node/1938960#comment-7201292
+ 'access callback' => 'user_access',
+ 'access arguments' => array('administer views'),
);
$items['admin/structure/views/view/%/preview/%'] = array(
'route_name' => 'views_ui.preview',
'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ // @todo Remove this. Route access checking does not work for contextual
+ // links generation yet. @see http://drupal.org/node/1938960#comment-7201292
+ 'access callback' => 'user_access',
+ 'access arguments' => array('administer views'),
);
// Additional pages for acting on a View.
@@ -324,8 +336,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;
}
}