Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,15 @@
# Schema for the views plugins of the Contextual module.
views.field.contextual_links:
type: views_field
label: 'Contextual link'
mapping:
fields:
type: sequence
label: 'Fields'
sequence:
type: string
label: 'Link'
destination:
type: boolean
label: 'Include destination'

View File

@ -0,0 +1,42 @@
<?php
/**
* @file
* Hooks provided by Contextual module.
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Alter a contextual links element before it is rendered.
*
* This hook is invoked by
* \Drupal\contextual\Element\ContextualLinks::preRenderLinks(). The renderable
* array of #type 'contextual_links', containing the entire contextual links
* data that is passed in by reference. Further links may be added or existing
* links can be altered.
*
* @param array $element
* A renderable array representing the contextual links.
* @param array $items
* An associative array containing the original contextual link items, as
* generated by
* \Drupal\Core\Menu\ContextualLinkManagerInterface::getContextualLinksArrayByGroup(),
* which were used to build $element['#links'].
*
* @see hook_contextual_links_alter()
* @see hook_contextual_links_plugins_alter()
* @see \Drupal\contextual\Element\ContextualLinks::preRenderLinks()
*/
function hook_contextual_links_view_alter(&$element, $items) {
// Add another class to all contextual link lists to facilitate custom
// styling.
$element['#attributes']['class'][] = 'custom-class';
}
/**
* @} End of "addtogroup hooks".
*/

View File

@ -0,0 +1,5 @@
name: 'Contextual Links'
type: module
description: 'Provides contextual links to directly access tasks related to page elements.'
package: Core
version: VERSION

View File

@ -0,0 +1,34 @@
drupal.contextual-links:
version: VERSION
js:
# Ensure to run before contextual/drupal.context-toolbar.
js/contextual.js: { weight: -2 }
css:
component:
css/contextual.module.css: {}
theme:
css/contextual.theme.css: {}
css/contextual.icons.theme.css: {}
dependencies:
- core/jquery
- core/drupal
- core/drupal.ajax
- core/drupalSettings
- core/once
- core/drupal.touchevents-test
drupal.contextual-toolbar:
version: VERSION
js:
js/toolbar/contextualToolbarModelView.js: {}
js/contextual.toolbar.js: {}
css:
component:
css/contextual.toolbar.css: {}
dependencies:
- core/jquery
- contextual/drupal.contextual-links
- core/drupal
- core/once
- core/drupal.tabbingmanager
- core/drupal.announce

View File

@ -0,0 +1,77 @@
<?php
/**
* @file
*/
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Language\LanguageInterface;
/**
* Serializes #contextual_links property value array to a string.
*
* Examples:
* - node:node=1:langcode=en
* - views_ui_edit:view=frontpage:location=page&view_name=frontpage&view_display_id=page_1&langcode=en
* - menu:menu=tools:langcode=en|block:block=olivero.tools:langcode=en
*
* So, expressed in a pattern:
* <group>:<route parameters>:<metadata>
*
* The route parameters and options are encoded as query strings.
*
* @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) {
$ids = [];
$langcode = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId();
foreach ($contextual_links as $group => $args) {
$route_parameters = UrlHelper::buildQuery($args['route_parameters']);
$args += ['metadata' => []];
// Add the current URL language to metadata so a different ID will be
// computed when URLs vary by language. This allows to store different
// language-aware contextual links on the client side.
$args['metadata'] += ['langcode' => $langcode];
$metadata = UrlHelper::buildQuery($args['metadata']);
$ids[] = "{$group}:{$route_parameters}:{$metadata}";
}
return implode('|', $ids);
}
/**
* Unserializes the result of _contextual_links_to_id().
*
* Note that $id is user input. Before calling this method the ID should be
* checked against the token stored in the 'data-contextual-token' attribute
* which is passed via the 'tokens' request parameter to
* \Drupal\contextual\ContextualController::render().
*
* @param string $id
* A serialized representation of a #contextual_links property value array.
*
* @return array
* The value for a #contextual_links property.
*
* @see _contextual_links_to_id()
* @see \Drupal\contextual\ContextualController::render()
*/
function _contextual_id_to_links($id): array {
$contextual_links = [];
$contexts = explode('|', $id);
foreach ($contexts as $context) {
[$group, $route_parameters_raw, $metadata_raw] = explode(':', $context);
parse_str($route_parameters_raw, $route_parameters);
$metadata = [];
parse_str($metadata_raw, $metadata);
$contextual_links[$group] = [
'route_parameters' => $route_parameters,
'metadata' => $metadata,
];
}
return $contextual_links;
}

View File

@ -0,0 +1,2 @@
access contextual links:
title: 'Use contextual links'

View File

@ -0,0 +1,15 @@
<?php
/**
* @file
* Post update functions for Contextual Links.
*/
/**
* Implements hook_removed_post_updates().
*/
function contextual_removed_post_updates(): array {
return [
'contextual_post_update_fixed_endpoint_and_markup' => '9.0.0',
];
}

View File

@ -0,0 +1,6 @@
contextual.render:
path: '/contextual/render'
defaults:
_controller: '\Drupal\contextual\ContextualController::render'
requirements:
_permission: 'access contextual links'

View File

@ -0,0 +1,2 @@
parameters:
contextual.skip_procedural_hook_scan: true

View File

@ -0,0 +1,49 @@
/*
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/3084859
* @preserve
*/
/**
* @file
* Styling for contextual module icons.
*/
/**
* Toolbar tab icon.
*/
.toolbar-bar .toolbar-icon-edit::before {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%23bebebe' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%23bebebe' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%23bebebe' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
}
.toolbar-bar .toolbar-icon-edit:active::before,
.toolbar-bar .toolbar-icon-edit.is-active::before {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%23ffffff' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%23ffffff' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%23ffffff' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
}
/**
* Contextual trigger.
*/
.contextual .trigger {
/* Override the .focusable height: auto */
width: 26px !important;
/* Override the .focusable height: auto */
height: 26px !important;
text-indent: -9999px;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%23bebebe' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%23bebebe' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%23bebebe' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: center center;
background-size: 16px 16px;
}
.contextual .trigger:hover {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%23787878' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%23787878' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%23787878' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
}
.contextual .trigger:focus {
outline: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3e%3cg%3e%3cpath fill='%235181C6' d='M14.545 3.042l-1.586-1.585c-.389-.389-1.025-.389-1.414 0l-1.293 1.293 3 3 1.293-1.293c.389-.389.389-1.026 0-1.415z'/%3e%3crect fill='%235181C6' x='5.129' y='3.8' transform='matrix(-.707 -.707 .707 -.707 6.189 20.064)' width='4.243' height='9.899'/%3e%3cpath fill='%235181C6' d='M.908 14.775c-.087.262.055.397.316.312l2.001-.667-1.65-1.646-.667 2.001z'/%3e%3c/g%3e%3c/svg%3e");
}

View File

@ -0,0 +1,39 @@
/**
* @file
* Styling for contextual module icons.
*/
/**
* Toolbar tab icon.
*/
.toolbar-bar .toolbar-icon-edit::before {
background-image: url(../../../misc/icons/bebebe/pencil.svg);
}
.toolbar-bar .toolbar-icon-edit:active::before,
.toolbar-bar .toolbar-icon-edit.is-active::before {
background-image: url(../../../misc/icons/ffffff/pencil.svg);
}
/**
* Contextual trigger.
*/
.contextual .trigger {
/* Override the .focusable height: auto */
width: 26px !important;
/* Override the .focusable height: auto */
height: 26px !important;
text-indent: -9999px;
background-image: url(../../../misc/icons/bebebe/pencil.svg);
background-repeat: no-repeat;
background-position: center center;
background-size: 16px 16px;
}
.contextual .trigger:hover {
background-image: url(../../../misc/icons/787878/pencil.svg);
}
.contextual .trigger:focus {
outline: none;
background-image: url(../../../misc/icons/5181c6/pencil.svg);
}

View File

@ -0,0 +1,18 @@
/**
* @file
* Generic base styles for contextual module.
*/
.contextual-region {
position: relative;
}
.contextual .trigger:focus {
/* Override the .focusable position: static */
position: relative !important;
}
.contextual-links {
display: none;
}
.contextual.open .contextual-links {
display: block;
}

View File

@ -0,0 +1,117 @@
/**
* @file
* Styling for contextual module.
*/
/**
* Contextual links wrappers.
*/
.contextual {
position: absolute;
z-index: 500;
top: 6px;
right: 0; /* LTR */
}
[dir="rtl"] .contextual {
right: auto;
left: 0;
}
.contextual.open {
z-index: 501;
}
/**
* Contextual region.
*/
.contextual-region.focus {
outline: 1px dashed #d6d6d6;
outline-offset: 1px;
}
/**
* Contextual trigger.
*/
.contextual .trigger {
position: relative;
right: 6px; /* LTR */
float: right; /* LTR */
overflow: hidden;
margin: 0;
padding: 0 2px;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 13px;
background-color: #fff;
background-attachment: scroll;
}
[dir="rtl"] .contextual .trigger {
right: auto;
left: 6px;
float: left;
}
.contextual.open .trigger {
z-index: 2;
border: 1px solid #ccc;
border-bottom-color: transparent;
border-radius: 13px 13px 0 0;
box-shadow: none;
}
/**
* Contextual links.
*
* The following selectors are heavy to discourage theme overriding.
*/
.contextual-region .contextual .contextual-links {
position: relative;
top: -1px;
right: 6px; /* LTR */
float: right; /* LTR */
clear: both;
margin: 0;
padding: 0.25em 0;
text-align: left; /* LTR */
white-space: nowrap;
border: 1px solid #ccc;
border-radius: 4px 0 4px 4px; /* LTR */
background-color: #fff;
}
[dir="rtl"] .contextual-region .contextual .contextual-links {
right: auto;
left: 6px;
float: left;
text-align: right;
border-radius: 0 4px 4px 4px;
}
.contextual-region .contextual .contextual-links li {
margin: 0;
padding: 0;
list-style: none;
list-style-image: none;
border: none;
background-color: #fff;
line-height: 100%;
}
.contextual-region .contextual .contextual-links a {
display: block;
margin: 0.25em 0;
padding: 0.4em 0.6em;
color: #333;
background-color: #fff;
font-family: sans-serif;
font-size: small;
font-weight: normal;
line-height: 0.8em;
}
.touchevents .contextual-region .contextual .contextual-links a {
font-size: large;
}
.contextual-region .contextual .contextual-links a,
.contextual-region .contextual .contextual-links a:hover {
text-decoration: none;
}
.no-touchevents .contextual-region .contextual .contextual-links li a:hover {
color: #000;
background: #f7fcff;
}

View File

@ -0,0 +1,23 @@
/**
* @file
* Styling for contextual module's toolbar tab.
*/
/* Tab appearance. */
.toolbar .toolbar-bar .contextual-toolbar-tab.toolbar-tab {
float: right; /* LTR */
}
[dir="rtl"] .toolbar .toolbar-bar .contextual-toolbar-tab.toolbar-tab {
float: left;
}
.toolbar .toolbar-bar .contextual-toolbar-tab .toolbar-item {
margin: 0;
}
.toolbar .toolbar-bar .contextual-toolbar-tab .toolbar-item.is-active {
background-image: linear-gradient(rgb(78, 159, 234) 0%, rgb(69, 132, 221) 100%);
}
/* @todo get rid of this declaration by making toolbar.module's CSS less specific */
.toolbar .toolbar-bar .contextual-toolbar-tab.toolbar-tab.hidden {
display: none;
}

View File

@ -0,0 +1,19 @@
---
label: 'Using contextual links'
related:
- core.ui_components
- block.overview
---
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Use contextual links to access administrative tasks without navigating the administrative menu.{% endtrans %}</p>
<h2>{% trans %}What are contextual links?{% endtrans %}</h2>
<p>{% trans %}<em>Contextual links</em> give users with the <em>Use contextual links</em> permission quick access to administrative tasks related to areas of non-administrative pages. For example, if a page on your site displays a block, the block would have a contextual link that would allow users with permission to configure the block. If the block contains a menu or a view, it would also have a contextual link for editing the menu links or the view. Clicking a contextual link takes you to the related administrative page directly, without needing to navigate through the administrative menu system.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}Make sure that the core Contextual Links module is installed, and that you have a role with the <em>Use contextual links</em> permission. Optionally, make sure that a toolbar module is installed (either the core Toolbar module or a contributed module replacement).{% endtrans %}</li>
<li>{% trans %}Visit a non-administrative page on your site, such as the home page.{% endtrans %}</li>
<li>{% trans %}Locate a block or another area on the page that you want to edit or configure.{% endtrans %}</li>
<li>{% trans %}Make the contextual links button visible by hovering your mouse over that area in the page. In most themes, this button looks like a pencil and is placed in the upper right corner of the page area (upper left for right-to-left languages), and hovering will also temporarily outline the affected area. Alternatively, click the contextual links toggle button on the right end of the toolbar (left end for right-to-left languages), which will make all contextual link buttons on the page visible until it is clicked again.{% endtrans %}</li>
<li>{% trans %}While the contextual links button for the area of interest is visible, click the button to display the list of links for that area. Click a link in the list to visit the corresponding administrative page.{% endtrans %}</li>
<li>{% trans %}Complete your administrative task and save your settings, or cancel the action. You should be returned to the page you started from.{% endtrans %}</li>
</ol>

View File

@ -0,0 +1,537 @@
/**
* @file
* Attaches behaviors for the Contextual module.
*/
(function ($, Drupal, drupalSettings, JSON, storage) {
const options = $.extend(
drupalSettings.contextual,
// Merge strings on top of drupalSettings so that they are not mutable.
{
strings: {
open: Drupal.t('Open'),
close: Drupal.t('Close'),
},
},
);
// Clear the cached contextual links whenever the current user's set of
// permissions changes.
const cachedPermissionsHash = storage.getItem(
'Drupal.contextual.permissionsHash',
);
const { permissionsHash } = drupalSettings.user;
if (cachedPermissionsHash !== permissionsHash) {
if (typeof permissionsHash === 'string') {
Object.keys(storage).forEach((key) => {
if (key.startsWith('Drupal.contextual.')) {
storage.removeItem(key);
}
});
}
storage.setItem('Drupal.contextual.permissionsHash', permissionsHash);
}
/**
* Determines if a contextual link is nested & overlapping, if so: adjusts it.
*
* This only deals with two levels of nesting; deeper levels are not touched.
*
* @param {jQuery} $contextual
* A contextual links placeholder DOM element, containing the actual
* contextual links as rendered by the server.
*/
function adjustIfNestedAndOverlapping($contextual) {
const $contextuals = $contextual
// @todo confirm that .closest() is not sufficient
.parents('.contextual-region')
.eq(-1)
.find('.contextual');
// Early-return when there's no nesting.
if ($contextuals.length <= 1) {
return;
}
// If the two contextual links overlap, then we move the second one.
const firstTop = $contextuals.eq(0).offset().top;
const secondTop = $contextuals.eq(1).offset().top;
if (firstTop === secondTop) {
const $nestedContextual = $contextuals.eq(1);
// Retrieve height of nested contextual link.
let height = 0;
const $trigger = $nestedContextual.find('.trigger');
// Elements with the .visually-hidden class have no dimensions, so this
// class must be temporarily removed to the calculate the height.
$trigger.removeClass('visually-hidden');
height = $nestedContextual.height();
$trigger.addClass('visually-hidden');
// Adjust nested contextual link's position.
$nestedContextual[0].style.top =
$nestedContextual.position().top + height;
}
}
/**
* Initializes a contextual link: updates its DOM, sets up model and views.
*
* @param {jQuery} $contextual
* A contextual links placeholder DOM element, containing the actual
* contextual links as rendered by the server.
* @param {string} html
* The server-side rendered HTML for this contextual link.
*/
function initContextual($contextual, html) {
const $region = $contextual.closest('.contextual-region');
const { contextual } = Drupal;
$contextual
// Update the placeholder to contain its rendered contextual links.
.html(html)
// 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'));
// Set the destination parameter on each of the contextual links.
const destination = `destination=${Drupal.encodePath(
Drupal.url(drupalSettings.path.currentPath + window.location.search),
)}`;
$contextual.find('.contextual-links a').each(function () {
const url = this.getAttribute('href');
const glue = url.includes('?') ? '&' : '?';
this.setAttribute('href', url + glue + destination);
});
let title = '';
const $regionHeading = $region.find('h2');
if ($regionHeading.length) {
title = $regionHeading[0].textContent.trim();
}
options.title = title;
const contextualModelView = new Drupal.contextual.ContextualModelView(
$contextual,
$region,
options,
);
contextual.instances.push(contextualModelView);
// Fix visual collisions between contextual link triggers.
adjustIfNestedAndOverlapping($contextual);
}
/**
* Attaches outline behavior for regions associated with contextual links.
*
* Events
* Contextual triggers an event that can be used by other scripts.
* - drupalContextualLinkAdded: Triggered when a contextual link is added.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the outline behavior to the right context.
*/
Drupal.behaviors.contextual = {
attach(context) {
const $context = $(context);
// Find all contextual links placeholders, if any.
let $placeholders = $(
once('contextual-render', '[data-contextual-id]', context),
);
if ($placeholders.length === 0) {
return;
}
// Collect the IDs for all contextual links placeholders.
const ids = [];
$placeholders.each(function () {
ids.push({
id: $(this).attr('data-contextual-id'),
token: $(this).attr('data-contextual-token'),
});
});
const uncachedIDs = [];
const uncachedTokens = [];
ids.forEach((contextualID) => {
const html = storage.getItem(`Drupal.contextual.${contextualID.id}`);
if (html?.length) {
// Initialize after the current execution cycle, to make the AJAX
// request for retrieving the uncached contextual links as soon as
// possible, but also to ensure that other Drupal behaviors have had
// the chance to set up an event listener on the collection
// Drupal.contextual.collection.
window.setTimeout(() => {
initContextual(
$context
.find(`[data-contextual-id="${contextualID.id}"]:empty`)
.eq(0),
html,
);
});
return;
}
uncachedIDs.push(contextualID.id);
uncachedTokens.push(contextualID.token);
});
// Perform an AJAX request to let the server render the contextual links
// for each of the placeholders.
if (uncachedIDs.length > 0) {
$.ajax({
url: Drupal.url('contextual/render'),
type: 'POST',
data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens },
dataType: 'json',
success(results) {
Object.entries(results).forEach(([contextualID, html]) => {
// Store the metadata.
storage.setItem(`Drupal.contextual.${contextualID}`, html);
// If the rendered contextual links are empty, then the current
// user does not have permission to access the associated links:
// don't render anything.
if (html.length > 0) {
// Update the placeholders to contain its rendered contextual
// links. Usually there will only be one placeholder, but it's
// possible for multiple identical placeholders exist on the
// page (probably because the same content appears more than
// once).
$placeholders = $context.find(
`[data-contextual-id="${contextualID}"]`,
);
// Initialize the contextual links.
for (let i = 0; i < $placeholders.length; i++) {
initContextual($placeholders.eq(i), html);
}
}
});
},
});
}
},
};
/**
* Namespace for contextual related functionality.
*
* @namespace
*
* @private
*/
Drupal.contextual = {
/**
* The {@link Drupal.contextual.View} instances associated with each list
* element of contextual links.
*
* @type {Array}
*
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
views: [],
/**
* The {@link Drupal.contextual.RegionView} instances associated with each
* contextual region element.
*
* @type {Array}
*
* @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
* replacement.
*/
regionViews: [],
instances: new Proxy([], {
set: function set(obj, prop, value) {
obj[prop] = value;
window.dispatchEvent(new Event('contextual-instances-added'));
return true;
},
deleteProperty(target, prop) {
if (prop in target) {
delete target[prop];
window.dispatchEvent(new Event('contextual-instances-removed'));
}
},
}),
/**
* Models the state of a contextual link's trigger, list & region.
*/
ContextualModelView: class {
constructor($contextual, $region, options) {
this.title = options.title || '';
this.regionIsHovered = false;
this._hasFocus = false;
this._isOpen = false;
this._isLocked = false;
this.strings = options.strings;
this.timer = NaN;
this.modelId = btoa(Math.random()).substring(0, 12);
this.$region = $region;
this.$contextual = $contextual;
if (!document.body.classList.contains('touchevents')) {
this.$region.on({
mouseenter: () => {
this.regionIsHovered = true;
},
mouseleave: () => {
this.close().blur();
this.regionIsHovered = false;
},
'mouseleave mouseenter': () => this.render(),
});
this.$contextual.on('mouseenter', () => {
this.focus();
this.render();
});
}
this.$contextual.on(
{
click: () => {
this.toggleOpen();
},
touchend: () => {
Drupal.contextual.ContextualModelView.touchEndToClick();
},
focus: () => {
this.focus();
},
blur: () => {
this.blur();
},
'click blur touchend focus': () => this.render(),
},
'.trigger',
);
this.$contextual.on(
{
click: () => {
this.close().blur();
},
touchend: (event) => {
Drupal.contextual.ContextualModelView.touchEndToClick(event);
},
focus: () => {
this.focus();
},
blur: () => {
this.waitCloseThenBlur();
},
'click blur touchend focus': () => this.render(),
},
'.contextual-links a',
);
this.render();
// Let other JavaScript react to the adding of a new contextual link.
$(document).trigger('drupalContextualLinkAdded', {
$el: $contextual,
$region,
model: this,
});
}
/**
* Updates the rendered representation of the current contextual links.
*/
render() {
const { isOpen } = this;
const isVisible = this.isLocked || this.regionIsHovered || isOpen;
this.$region.toggleClass('focus', this.hasFocus);
this.$contextual
.toggleClass('open', isOpen)
// Update the visibility of the trigger.
.find('.trigger')
.toggleClass('visually-hidden', !isVisible);
this.$contextual.find('.contextual-links').prop('hidden', !isOpen);
const trigger = this.$contextual.find('.trigger').get(0);
trigger.textContent = Drupal.t('@action @title configuration options', {
'@action': !isOpen ? this.strings.open : this.strings.close,
'@title': this.title,
});
trigger.setAttribute('aria-pressed', isOpen);
}
/**
* Prevents delay and simulated mouse events.
*
* @param {jQuery.Event} event the touch end event.
*/
static touchEndToClick(event) {
event.preventDefault();
event.target.click();
}
/**
* Set up a timeout to allow a user to tab between the trigger and the
* contextual links without the menu dismissing.
*/
waitCloseThenBlur() {
this.timer = window.setTimeout(() => {
this.isOpen = false;
this.hasFocus = false;
this.render();
}, 150);
}
/**
* Opens or closes the contextual link.
*
* If it is opened, then also give focus.
*
* @return {Drupal.contextual.ContextualModelView}
* The current contextual model view.
*/
toggleOpen() {
const newIsOpen = !this.isOpen;
this.isOpen = newIsOpen;
if (newIsOpen) {
this.focus();
}
return this;
}
/**
* Gives focus to this contextual link.
*
* Also closes + removes focus from every other contextual link.
*
* @return {Drupal.contextual.ContextualModelView}
* The current contextual model view.
*/
focus() {
const { modelId } = this;
Drupal.contextual.instances.forEach((model) => {
if (model.modelId !== modelId) {
model.close().blur();
}
});
window.clearTimeout(this.timer);
this.hasFocus = true;
return this;
}
/**
* Removes focus from this contextual link, unless it is open.
*
* @return {Drupal.contextual.ContextualModelView}
* The current contextual model view.
*/
blur() {
if (!this.isOpen) {
this.hasFocus = false;
}
return this;
}
/**
* Closes this contextual link.
*
* Does not call blur() because we want to allow a contextual link to have
* focus, yet be closed for example when hovering.
*
* @return {Drupal.contextual.ContextualModelView}
* The current contextual model view.
*/
close() {
this.isOpen = false;
return this;
}
/**
* Gets the current focus state.
*
* @return {boolean} the focus state.
*/
get hasFocus() {
return this._hasFocus;
}
/**
* Sets the current focus state.
*
* @param {boolean} value - new focus state
*/
set hasFocus(value) {
this._hasFocus = value;
this.$region.toggleClass('focus', this._hasFocus);
}
/**
* Gets the current open state.
*
* @return {boolean} the open state.
*/
get isOpen() {
return this._isOpen;
}
/**
* Sets the current open state.
*
* @param {boolean} value - new open state
*/
set isOpen(value) {
this._isOpen = value;
// Nested contextual region handling: hide any nested contextual triggers.
this.$region
.closest('.contextual-region')
.find('.contextual .trigger:not(:first)')
.toggle(!this.isOpen);
}
/**
* Gets the current locked state.
*
* @return {boolean} the locked state.
*/
get isLocked() {
return this._isLocked;
}
/**
* Sets the current locked state.
*
* @param {boolean} value - new locked state
*/
set isLocked(value) {
if (value !== this._isLocked) {
this._isLocked = value;
this.render();
}
}
},
};
/**
* A trigger is an interactive element often bound to a click handler.
*
* @return {string}
* A string representing a DOM fragment.
*/
Drupal.theme.contextualTrigger = function () {
return '<button class="trigger visually-hidden focusable" type="button"></button>';
};
/**
* Bind Ajax contextual links when added.
*
* @param {jQuery.Event} event
* The `drupalContextualLinkAdded` event.
* @param {object} data
* An object containing the data relevant to the event.
*
* @listens event:drupalContextualLinkAdded
*/
$(document).on('drupalContextualLinkAdded', (event, data) => {
Drupal.ajax.bindAjaxLinks(data.$el[0]);
});
})(jQuery, Drupal, drupalSettings, window.JSON, window.sessionStorage);

View File

@ -0,0 +1,70 @@
/**
* @file
* Attaches behaviors for the Contextual module's edit toolbar tab.
*/
(function ($, Drupal) {
const strings = {
tabbingReleased: Drupal.t(
'Tabbing is no longer constrained by the Contextual module.',
),
tabbingConstrained: Drupal.t(
'Tabbing is constrained to a set of @contextualsCount and the edit mode toggle.',
),
pressEsc: Drupal.t('Press the esc key to exit.'),
};
/**
* Initializes a contextual link: updates its DOM, sets up model and views.
*
* @param {HTMLElement} context
* A contextual links DOM element as rendered by the server.
*/
function initContextualToolbar(context) {
if (!Drupal.contextual || !Drupal.contextual.instances) {
return;
}
const { contextualToolbar } = Drupal;
const viewOptions = {
el: $('.toolbar .toolbar-bar .contextual-toolbar-tab'),
strings,
};
contextualToolbar.model = new Drupal.contextual.ContextualToolbarModelView(
viewOptions,
);
}
/**
* Attaches contextual's edit toolbar tab behavior.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches contextual toolbar behavior on a contextualToolbar-init event.
*/
Drupal.behaviors.contextualToolbar = {
attach(context) {
if (once('contextualToolbar-init', 'body').length) {
initContextualToolbar(context);
}
},
};
/**
* Namespace for the contextual toolbar.
*
* @namespace
*
* @private
*/
Drupal.contextualToolbar = {
/**
* The {@link Drupal.contextual.ContextualToolbarModelView} instance.
*
* @type {?Drupal.contextual.ContextualToolbarModelView}
*/
model: null,
};
})(jQuery, Drupal);

View File

@ -0,0 +1,175 @@
(($, Drupal) => {
Drupal.contextual.ContextualToolbarModelView = class {
constructor(options) {
this.strings = options.strings;
this.isVisible = false;
this._contextualCount = Drupal.contextual.instances.count;
this.tabbingContext = null;
this._isViewing =
localStorage.getItem('Drupal.contextualToolbar.isViewing') !== 'false';
this.$el = options.el;
window.addEventListener('contextual-instances-added', () =>
this.lockNewContextualLinks(),
);
window.addEventListener('contextual-instances-removed', () => {
this.contextualCount = Drupal.contextual.instances.count;
});
this.$el.on({
click: () => {
this.isViewing = !this.isViewing;
},
touchend: (event) => {
event.preventDefault();
event.target.click();
},
'click touchend': () => this.render(),
});
$(document).on('keyup', (event) => this.onKeypress(event));
this.manageTabbing(true);
this.render();
}
/**
* Responds to esc and tab key press events.
*
* @param {jQuery.Event} event
* The keypress event.
*/
onKeypress(event) {
// The first tab key press is tracked so that an announcement about
// tabbing constraints can be raised if edit mode is enabled when the page
// is loaded.
if (!this.announcedOnce && event.keyCode === 9 && !this.isViewing) {
this.announceTabbingConstraint();
// Set announce to true so that this conditional block won't run again.
this.announcedOnce = true;
}
// Respond to the ESC key. Exit out of edit mode.
if (event.keyCode === 27) {
this.isViewing = true;
}
}
/**
* Updates the rendered representation of the current toolbar model view.
*/
render() {
this.$el[0].classList.toggle('hidden', this.isVisible);
const button = this.$el[0].querySelector('button');
button.classList.toggle('is-active', !this.isViewing);
button.setAttribute('aria-pressed', !this.isViewing);
this.contextualCount = Drupal.contextual.instances.count;
}
/**
* Automatically updates visibility of the view/edit mode toggle.
*/
updateVisibility() {
this.isVisible = this.get('contextualCount') > 0;
}
/**
* Lock newly added contextual links if edit mode is enabled.
*/
lockNewContextualLinks() {
Drupal.contextual.instances.forEach((model) => {
model.isLocked = !this.isViewing;
});
this.contextualCount = Drupal.contextual.instances.count;
}
/**
* Limits tabbing to the contextual links and edit mode toolbar tab.
*
* @param {boolean} init - true to initialize tabbing.
*/
manageTabbing(init = false) {
let { tabbingContext } = this;
// Always release an existing tabbing context.
if (tabbingContext && !init) {
// Only announce release when the context was active.
if (tabbingContext.active) {
Drupal.announce(this.strings.tabbingReleased);
}
tabbingContext.release();
this.tabbingContext = null;
}
// Create a new tabbing context when edit mode is enabled.
if (!this.isViewing) {
tabbingContext = Drupal.tabbingManager.constrain(
$('.contextual-toolbar-tab, .contextual'),
);
this.tabbingContext = tabbingContext;
this.announceTabbingConstraint();
this.announcedOnce = true;
}
}
/**
* Announces the current tabbing constraint.
*/
announceTabbingConstraint() {
const { strings } = this;
Drupal.announce(
Drupal.formatString(strings.tabbingConstrained, {
'@contextualsCount': Drupal.formatPlural(
Drupal.contextual.instances.length,
'@count contextual link',
'@count contextual links',
),
}) + strings.pressEsc,
);
}
/**
* Gets the current viewing state.
*
* @return {boolean} the viewing state.
*/
get isViewing() {
return this._isViewing;
}
/**
* Sets the current viewing state.
*
* @param {boolean} value - new viewing state
*/
set isViewing(value) {
this._isViewing = value;
localStorage[!value ? 'setItem' : 'removeItem'](
'Drupal.contextualToolbar.isViewing',
'false',
);
Drupal.contextual.instances.forEach((model) => {
model.isLocked = !this.isViewing;
});
this.manageTabbing();
}
/**
* Gets the current contextual links count.
*
* @return {integer} the current contextual links count.
*/
get contextualCount() {
return this._contextualCount;
}
/**
* Sets the current contextual links count.
*
* @param {integer} value - new contextual links count.
*/
set contextualCount(value) {
if (value !== this._contextualCount) {
this._contextualCount = value;
this.updateVisibility();
}
}
};
})(jQuery, Drupal);

View File

@ -0,0 +1,90 @@
<?php
namespace Drupal\contextual;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Site\Settings;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Returns responses for Contextual module routes.
*/
class ContextualController implements ContainerInjectionInterface {
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructors a new ContextualController.
*
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
*/
public function __construct(RendererInterface $renderer) {
$this->renderer = $renderer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('renderer')
);
}
/**
* Returns the requested rendered contextual links.
*
* Given a list of contextual links IDs, render them. Hence this must be
* robust to handle arbitrary input.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The Symfony request object.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON response.
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* Thrown when the request contains no ids.
*
* @internal
*
* @see contextual_preprocess()
*/
public function render(Request $request) {
if (!$request->request->has('ids')) {
throw new BadRequestHttpException('No contextual ids specified.');
}
$ids = $request->request->all('ids');
if (!$request->request->has('tokens')) {
throw new BadRequestHttpException('No contextual ID tokens specified.');
}
$tokens = $request->request->all('tokens');
$rendered = [];
foreach ($ids as $key => $id) {
if (!isset($tokens[$key]) || !hash_equals($tokens[$key], Crypt::hmacBase64($id, Settings::getHashSalt() . \Drupal::service('private_key')->get()))) {
throw new BadRequestHttpException('Invalid contextual ID specified.');
}
$element = [
'#type' => 'contextual_links',
'#contextual_links' => _contextual_id_to_links($id),
];
$rendered[$id] = $this->renderer->renderRoot($element);
}
return new JsonResponse($rendered);
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace Drupal\contextual\Element;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\SortArray;
use Drupal\Core\Render\Attribute\RenderElement;
use Drupal\Core\Render\Element\RenderElementBase;
use Drupal\Core\Url;
/**
* Provides a contextual_links element.
*/
#[RenderElement('contextual_links')]
class ContextualLinks extends RenderElementBase {
/**
* {@inheritdoc}
*/
public function getInfo() {
return [
'#pre_render' => [
[static::class, 'preRenderLinks'],
],
'#theme' => 'links__contextual',
'#links' => [],
'#attributes' => ['class' => ['contextual-links']],
'#attached' => [
'library' => [
'contextual/drupal.contextual-links',
],
],
];
}
/**
* Pre-render callback: Builds a renderable array for contextual links.
*
* @param array $element
* A renderable array containing a #contextual_links property, which is a
* keyed array. Each key is the name of the group of contextual links to
* render (based on the 'group' key in the *.links.contextual.yml files for
* all enabled modules). The value contains an associative array containing
* the following keys:
* - route_parameters: The route parameters passed to the URL generator.
* - metadata: Any additional data needed in order to alter the link.
* @code
* ['#contextual_links' => [
* 'block' => [
* 'route_parameters' => ['block' => 'system.menu-tools'],
* ],
* 'menu' => [
* 'route_parameters' => ['menu' => 'tools'],
* ],
* ]]
* @endcode
*
* @return array
* A renderable array representing contextual links.
*/
public static function preRenderLinks(array $element) {
// Retrieve contextual menu links.
$items = [];
$contextual_links_manager = static::contextualLinkManager();
foreach ($element['#contextual_links'] as $group => $args) {
$args += [
'route_parameters' => [],
'metadata' => [],
];
$items += $contextual_links_manager->getContextualLinksArrayByGroup($group, $args['route_parameters'], $args['metadata']);
}
uasort($items, [SortArray::class, 'sortByWeightElement']);
// Transform contextual links into parameters suitable for links.html.twig.
$links = [];
foreach ($items as $class => $item) {
$class = Html::getClass($class);
$links[$class] = [
'title' => $item['title'],
'url' => Url::fromRoute($item['route_name'] ?? '', $item['route_parameters'] ?? [], $item['localized_options']),
];
}
$element['#links'] = $links;
// Allow modules to alter the renderable contextual links element.
static::moduleHandler()->alter('contextual_links_view', $element, $items);
// If there are no links, tell \Drupal::service('renderer')->render() to
// abort rendering.
if (empty($element['#links'])) {
$element['#printed'] = TRUE;
}
return $element;
}
/**
* Wraps the contextual link manager.
*
* @return \Drupal\Core\Menu\ContextualLinkManager
* The contextual link manager service.
*/
protected static function contextualLinkManager() {
return \Drupal::service('plugin.manager.menu.contextual_link');
}
/**
* Wraps the module handler.
*
* @return \Drupal\Core\Extension\ModuleHandlerInterface
* The module handler service.
*/
protected static function moduleHandler() {
return \Drupal::moduleHandler();
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Drupal\contextual\Element;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Site\Settings;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Render\Attribute\RenderElement;
use Drupal\Core\Render\Element\RenderElementBase;
use Drupal\Component\Render\FormattableMarkup;
/**
* Provides a contextual_links_placeholder element.
*/
#[RenderElement('contextual_links_placeholder')]
class ContextualLinksPlaceholder extends RenderElementBase {
/**
* {@inheritdoc}
*/
public function getInfo() {
return [
'#pre_render' => [
[static::class, 'preRenderPlaceholder'],
],
'#id' => NULL,
];
}
/**
* 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 array $element
* A structured array with #id containing a "contextual id".
*
* @return array
* The passed-in element with a contextual link placeholder in '#markup'.
*
* @see _contextual_links_to_id()
*/
public static function preRenderPlaceholder(array $element) {
$token = Crypt::hmacBase64($element['#id'], Settings::getHashSalt() . \Drupal::service('private_key')->get());
$attribute = new Attribute([
'data-contextual-id' => $element['#id'],
'data-contextual-token' => $token,
'data-drupal-ajax-container' => '',
]);
$element['#markup'] = new FormattableMarkup('<div@attributes></div>', ['@attributes' => $attribute]);
return $element;
}
}

View File

@ -0,0 +1,124 @@
<?php
namespace Drupal\contextual\Hook;
use Drupal\Component\Serialization\Json;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for contextual.
*/
class ContextualHooks {
use StringTranslationTrait;
/**
* Implements hook_toolbar().
*/
#[Hook('toolbar')]
public function toolbar(): array {
$items = [];
$items['contextual'] = ['#cache' => ['contexts' => ['user.permissions']]];
if (!\Drupal::currentUser()->hasPermission('access contextual links')) {
return $items;
}
$items['contextual'] += [
'#type' => 'toolbar_item',
'tab' => [
'#type' => 'html_tag',
'#tag' => 'button',
'#value' => $this->t('Edit'),
'#attributes' => [
'class' => [
'toolbar-icon',
'toolbar-icon-edit',
],
'aria-pressed' => 'false',
'type' => 'button',
],
],
'#wrapper_attributes' => [
'class' => [
'hidden',
'contextual-toolbar-tab',
],
],
'#attached' => [
'library' => [
'contextual/drupal.contextual-toolbar',
],
],
];
return $items;
}
/**
* Implements hook_page_attachments().
*
* Adds the drupal.contextual-links library to the page for any user who has
* the 'access contextual links' permission.
*
* @see contextual_preprocess()
*/
#[Hook('page_attachments')]
public function pageAttachments(array &$page): void {
if (!\Drupal::currentUser()->hasPermission('access contextual links')) {
return;
}
$page['#attached']['library'][] = 'contextual/drupal.contextual-links';
}
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.contextual':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The Contextual links module gives users with the <em>Use contextual links</em> permission quick access to tasks associated with certain areas of pages on your site. For example, a menu displayed as a block has links to edit the menu and configure the block. For more information, see the <a href=":contextual">online documentation for the Contextual Links module</a>.', [':contextual' => 'https://www.drupal.org/docs/8/core/modules/contextual']) . '</p>';
$output .= '<h2>' . $this->t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('Displaying contextual links') . '</dt>';
$output .= '<dd>';
$output .= $this->t('Contextual links for an area on a page are displayed using a contextual links button. There are two ways to make the contextual links button visible:');
$output .= '<ol>';
$sample_picture = [
'#theme' => 'image',
'#uri' => 'core/misc/icons/bebebe/pencil.svg',
'#alt' => $this->t('contextual links button'),
];
$sample_picture = \Drupal::service('renderer')->render($sample_picture);
$output .= '<li>' . $this->t('Hovering over the area of interest will temporarily make the contextual links button visible (which looks like a pencil in most themes, and is normally displayed in the upper right corner of the area). The icon typically looks like this: @picture', ['@picture' => $sample_picture]) . '</li>';
$output .= '<li>' . $this->t('If you have the <a href=":toolbar">Toolbar module</a> installed, clicking the contextual links button in the toolbar (which looks like a pencil) will make all contextual links buttons on the page visible. Clicking this button again will toggle them to invisible.', [
':toolbar' => \Drupal::moduleHandler()->moduleExists('toolbar') ? Url::fromRoute('help.page', [
'name' => 'toolbar',
])->toString() : '#',
]) . '</li>';
$output .= '</ol>';
$output .= $this->t('Once the contextual links button for the area of interest is visible, click the button to display the links.');
$output .= '</dd>';
$output .= '</dl>';
return $output;
}
return NULL;
}
/**
* Implements hook_contextual_links_view_alter().
*
* @see \Drupal\contextual\Plugin\views\field\ContextualLinks::render()
*/
#[Hook('contextual_links_view_alter')]
public function contextualLinksViewAlter(&$element, $items): void {
if (isset($element['#contextual_links']['contextual'])) {
$encoded_links = $element['#contextual_links']['contextual']['metadata']['contextual-views-field-links'];
$element['#links'] = Json::decode(rawurldecode($encoded_links));
}
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace Drupal\contextual\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
/**
* Hook implementations for contextual.
*/
class ContextualThemeHooks {
public function __construct(
protected readonly AccountInterface $currentUser,
) {}
/**
* Implements hook_preprocess().
*
* @see \Drupal\contextual\Element\ContextualLinksPlaceholder
* @see contextual_page_attachments()
* @see \Drupal\contextual\ContextualController::render()
*/
#[Hook('preprocess')]
public function preprocess(&$variables, $hook, $info): void {
// Determine the primary theme function argument.
if (!empty($info['variables'])) {
$keys = array_keys($info['variables']);
$key = $keys[0];
}
elseif (!empty($info['render element'])) {
$key = $info['render element'];
}
if (!empty($key) && isset($variables[$key])) {
$element = $variables[$key];
}
if (isset($element) && is_array($element) && !empty($element['#contextual_links'])) {
$variables['#cache']['contexts'][] = 'user.permissions';
if ($this->currentUser->hasPermission('access contextual links')) {
// 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_attachments() only adds the asset
// library for users with the 'access contextual links' permission, thus
// preventing unnecessary HTTP requests for users without that
// permission.
$variables['title_suffix']['contextual_links'] = [
'#type' => 'contextual_links_placeholder',
'#id' => _contextual_links_to_id($element['#contextual_links']),
];
}
}
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Drupal\contextual\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Hook implementations for contextual.
*/
class ContextualViewsHooks {
use StringTranslationTrait;
/**
* Implements hook_views_data_alter().
*/
#[Hook('views_data_alter')]
public function viewsDataAlter(&$data): void {
$data['views']['contextual_links'] = [
'title' => $this->t('Contextual Links'),
'help' => $this->t('Display fields in a contextual links menu.'),
'field' => [
'id' => 'contextual_links',
],
];
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace Drupal\contextual\Plugin\views\field;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RedirectDestinationTrait;
use Drupal\Core\Url;
use Drupal\views\Attribute\ViewsField;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\ResultRow;
/**
* Provides a handler that adds contextual links.
*
* @ingroup views_field_handlers
*/
#[ViewsField("contextual_links")]
class ContextualLinks extends FieldPluginBase {
use RedirectDestinationTrait;
/**
* {@inheritdoc}
*/
public function usesGroupBy() {
return FALSE;
}
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['fields'] = ['default' => []];
$options['destination'] = ['default' => 1];
return $options;
}
/**
* {@inheritdoc}
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
$all_fields = $this->view->display_handler->getFieldLabels();
// Offer to include only those fields that follow this one.
$field_options = array_slice($all_fields, 0, array_search($this->options['id'], array_keys($all_fields)));
$form['fields'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Fields'),
'#description' => $this->t('Fields to be included as contextual links.'),
'#options' => $field_options,
'#default_value' => $this->options['fields'],
];
$form['destination'] = [
'#type' => 'select',
'#title' => $this->t('Include destination'),
'#description' => $this->t('Include a "destination" parameter in the link to return the user to the original view upon completing the contextual action.'),
'#options' => [
'0' => $this->t('No'),
'1' => $this->t('Yes'),
],
'#default_value' => $this->options['destination'],
];
}
/**
* {@inheritdoc}
*/
public function preRender(&$values) {
// Add a row plugin css class for the contextual link.
$class = 'contextual-region';
if (!empty($this->view->style_plugin->options['row_class'])) {
$this->view->style_plugin->options['row_class'] .= " $class";
}
else {
$this->view->style_plugin->options['row_class'] = $class;
}
}
/**
* Overrides \Drupal\views\Plugin\views\field\FieldPluginBase::render().
*
* Renders the contextual fields.
*
* @param \Drupal\views\ResultRow $values
* The values retrieved from a single row of a view's query result.
*
* @see contextual_preprocess()
* @see contextual_contextual_links_view_alter()
*/
public function render(ResultRow $values) {
$links = [];
foreach ($this->options['fields'] as $field) {
$rendered_field = $this->view->style_plugin->getField($values->index, $field);
if (empty($rendered_field)) {
continue;
}
$title = $this->view->field[$field]->last_render_text;
$path = '';
if (!empty($this->view->field[$field]->options['alter']['path'])) {
$path = $this->view->field[$field]->options['alter']['path'];
}
elseif (!empty($this->view->field[$field]->options['alter']['url']) && $this->view->field[$field]->options['alter']['url'] instanceof Url) {
$path = $this->view->field[$field]->options['alter']['url']->toString();
}
if (!empty($title) && !empty($path)) {
// Make sure that tokens are replaced for this paths as well.
$tokens = $this->getRenderTokens([]);
$path = strip_tags(Html::decodeEntities(strtr($path, $tokens)));
$links[$field] = [
'href' => $path,
'title' => $title,
];
if (!empty($this->options['destination'])) {
$links[$field]['query'] = $this->getDestinationArray();
}
}
}
// Renders a contextual links placeholder.
if (!empty($links)) {
$contextual_links = [
'contextual' => [
'',
[],
[
'contextual-views-field-links' => UrlHelper::encodePath(Json::encode($links)),
],
],
];
$element = [
'#type' => 'contextual_links_placeholder',
'#id' => _contextual_links_to_id($contextual_links),
];
return \Drupal::service('renderer')->render($element);
}
else {
return '';
}
}
/**
* {@inheritdoc}
*/
public function query() {}
}

View File

@ -0,0 +1,327 @@
langcode: en
status: true
dependencies:
module:
- node
- user
id: contextual_recent
label: 'Recent content'
module: node
description: 'Recent content.'
tag: default
base_table: node_field_data
base_field: nid
display:
default:
id: default
display_title: Default
display_plugin: default
position: 0
display_options:
title: 'Recent content'
fields:
title:
id: title
table: node_field_data
field: title
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: title
plugin_id: field
label: ''
exclude: false
alter:
alter_text: false
make_link: false
absolute: false
word_boundary: false
ellipsis: false
strip_tags: false
trim: false
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
type: string
settings:
link_to_entity: true
changed:
id: changed
table: node_field_data
field: changed
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: changed
plugin_id: field
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: timestamp_ago
settings: { }
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
pager:
type: some
options:
offset: 0
items_per_page: 10
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
empty:
area_text_custom:
id: area_text_custom
table: views
field: area_text_custom
relationship: none
group_type: group
admin_label: ''
plugin_id: text_custom
empty: true
content: 'No content available.'
tokenize: false
sorts:
changed:
id: changed
table: node_field_data
field: changed
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: changed
plugin_id: date
order: DESC
expose:
label: ''
exposed: false
granularity: second
arguments: { }
filters:
status_extra:
id: status_extra
table: node_field_data
field: status_extra
relationship: none
group_type: group
admin_label: ''
entity_type: node
plugin_id: node_status
operator: '='
value: false
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
operator_limit_selection: false
operator_list: { }
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
langcode:
id: langcode
table: node_field_data
field: langcode
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: langcode
plugin_id: language
operator: in
value:
'***LANGUAGE_language_content***': '***LANGUAGE_language_content***'
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
operator_limit_selection: false
operator_list: { }
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
reduce: false
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
style:
type: html_list
options:
grouping: { }
row_class: ''
default_row_class: true
type: ul
wrapper_class: item-list
class: ''
row:
type: fields
query:
type: views_query
options:
query_comment: ''
disable_sql_rewrite: false
distinct: false
replica: false
query_tags: { }
relationships:
uid:
id: uid
table: node_field_data
field: uid
relationship: none
group_type: group
admin_label: author
entity_type: node
entity_field: uid
plugin_id: standard
required: true
use_more: false
use_more_always: false
use_more_text: More
link_display: '0'
link_url: ''
header: { }
footer: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- user
- 'user.node_grants:view'
- user.permissions
tags: { }
block_1:
id: block_1
display_title: Block
display_plugin: block
position: 2
display_options:
row:
type: 'entity:node'
options:
relationship: none
view_mode: teaser
defaults:
style: false
row: false
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- user
- 'user.node_grants:view'
- user.permissions
tags: { }

View File

@ -0,0 +1,7 @@
name: 'Contextual Test'
type: module
description: 'Provides test contextual links.'
package: Testing
version: VERSION
dependencies:
- drupal:contextual

View File

@ -0,0 +1,12 @@
contextual_test:
title: 'Test Link'
route_name: 'contextual_test'
group: 'contextual_test'
contextual_test_ajax:
title: 'Test Link with Ajax'
route_name: 'contextual_test'
group: 'contextual_test'
options:
attributes:
class: ['use-ajax']
data-dialog-type: 'modal'

View File

@ -0,0 +1,6 @@
contextual_test:
path: '/contextual-tests'
defaults:
_controller: '\Drupal\contextual_test\Controller\TestController::render'
requirements:
_access: 'TRUE'

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Drupal\contextual_test\Controller;
/**
* Test controller to provide a callback for the contextual link.
*/
class TestController {
/**
* Callback for the contextual link.
*
* @return array
* Render array.
*/
public function render() {
return [
'#type' => 'markup',
'#markup' => 'Everything is contextual!',
];
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Drupal\contextual_test\Hook;
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for contextual_test.
*/
class ContextualTestHooks {
/**
* Implements hook_block_view_alter().
*/
#[Hook('block_view_alter')]
public function blockViewAlter(array &$build, BlockPluginInterface $block): void {
$build['#contextual_links']['contextual_test'] = ['route_parameters' => []];
}
/**
* Implements hook_contextual_links_view_alter().
*
* @todo Apparently this too late to attach the library?
* It won't work without contextual_test_page_attachments_alter()
* Is that a problem? Should the contextual module itself do the attaching?
*/
#[Hook('contextual_links_view_alter')]
public function contextualLinksViewAlter(&$element, $items): void {
if (isset($element['#links']['contextual-test-ajax'])) {
$element['#attached']['library'][] = 'core/drupal.dialog.ajax';
}
}
/**
* Implements hook_page_attachments_alter().
*/
#[Hook('page_attachments_alter')]
public function pageAttachmentsAlter(array &$attachments): void {
$attachments['#attached']['library'][] = 'core/drupal.dialog.ajax';
}
}

View File

@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Site\Settings;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\BrowserTestBase;
use Psr\Http\Message\ResponseInterface;
/**
* Tests contextual link display on the front page based on permissions.
*
* @group contextual
*/
class ContextualDynamicContextTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user with permission to access contextual links and edit content.
*
* @var \Drupal\user\UserInterface
*/
protected $editorUser;
/**
* An authenticated user with permission to access contextual links.
*
* @var \Drupal\user\UserInterface
*/
protected $authenticatedUser;
/**
* A simulated anonymous user with access only to node content.
*
* @var \Drupal\user\UserInterface
*/
protected $anonymousUser;
/**
* {@inheritdoc}
*/
protected static $modules = [
'contextual',
'node',
'views',
'views_ui',
'language',
'menu_test',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
ConfigurableLanguage::createFromLangcode('it')->save();
$this->rebuildContainer();
$this->editorUser = $this->drupalCreateUser([
'access content',
'access contextual links',
'edit any article content',
]);
$this->authenticatedUser = $this->drupalCreateUser([
'access content',
'access contextual links',
]);
$this->anonymousUser = $this->drupalCreateUser(['access content']);
}
/**
* Tests contextual links with different permissions.
*
* Ensures that contextual link placeholders always exist, even if the user is
* not allowed to use contextual links.
*/
public function testDifferentPermissions(): void {
$this->drupalLogin($this->editorUser);
// Create three nodes in the following order:
// - An article, which should be user-editable.
// - A page, which should not be user-editable.
// - A second article, which should also be user-editable.
$node1 = $this->drupalCreateNode(['type' => 'article', 'promote' => 1]);
$node2 = $this->drupalCreateNode(['type' => 'page', 'promote' => 1]);
$node3 = $this->drupalCreateNode(['type' => 'article', 'promote' => 1]);
// Now, on the front page, all article nodes should have contextual links
// placeholders, as should the view that contains them.
$ids = [
'node:node=' . $node1->id() . ':changed=' . $node1->getChangedTime() . '&langcode=en',
'node:node=' . $node2->id() . ':changed=' . $node2->getChangedTime() . '&langcode=en',
'node:node=' . $node3->id() . ':changed=' . $node3->getChangedTime() . '&langcode=en',
'entity.view.edit_form:view=frontpage:location=page&name=frontpage&display_id=page_1&langcode=en',
];
// Editor user: can access contextual links and can edit articles.
$this->drupalGet('node');
for ($i = 0; $i < count($ids); $i++) {
$this->assertContextualLinkPlaceHolder($ids[$i]);
}
$response = $this->renderContextualLinks([], 'node');
$this->assertSame(400, $response->getStatusCode());
$this->assertStringContainsString('No contextual ids specified.', (string) $response->getBody());
$response = $this->renderContextualLinks($ids, 'node');
$this->assertSame(200, $response->getStatusCode());
$json = Json::decode((string) $response->getBody());
$this->assertSame('<ul class="contextual-links"><li><a href="' . base_path() . 'node/1/edit">Edit</a></li></ul>', $json[$ids[0]]);
$this->assertSame('', $json[$ids[1]]);
$this->assertSame('<ul class="contextual-links"><li><a href="' . base_path() . 'node/3/edit">Edit</a></li></ul>', $json[$ids[2]]);
$this->assertSame('', $json[$ids[3]]);
// Verify that link language is properly handled.
$node3->addTranslation('it')->set('title', $this->randomString())->save();
$id = 'node:node=' . $node3->id() . ':changed=' . $node3->getChangedTime() . '&langcode=it';
$this->drupalGet('node', ['language' => ConfigurableLanguage::createFromLangcode('it')]);
$this->assertContextualLinkPlaceHolder($id);
// Authenticated user: can access contextual links, cannot edit articles.
$this->drupalLogin($this->authenticatedUser);
$this->drupalGet('node');
for ($i = 0; $i < count($ids); $i++) {
$this->assertContextualLinkPlaceHolder($ids[$i]);
}
$response = $this->renderContextualLinks([], 'node');
$this->assertSame(400, $response->getStatusCode());
$this->assertStringContainsString('No contextual ids specified.', (string) $response->getBody());
$response = $this->renderContextualLinks($ids, 'node');
$this->assertSame(200, $response->getStatusCode());
$json = Json::decode((string) $response->getBody());
$this->assertSame('', $json[$ids[0]]);
$this->assertSame('', $json[$ids[1]]);
$this->assertSame('', $json[$ids[2]]);
$this->assertSame('', $json[$ids[3]]);
// Anonymous user: cannot access contextual links.
$this->drupalLogin($this->anonymousUser);
$this->drupalGet('node');
for ($i = 0; $i < count($ids); $i++) {
$this->assertNoContextualLinkPlaceHolder($ids[$i]);
}
$response = $this->renderContextualLinks([], 'node');
$this->assertSame(403, $response->getStatusCode());
$this->renderContextualLinks($ids, 'node');
$this->assertSame(403, $response->getStatusCode());
// Get a page where contextual links are directly rendered.
$this->drupalGet(Url::fromRoute('menu_test.contextual_test'));
$this->assertSession()->assertEscaped("<script>alert('Welcome to the jungle!')</script>");
$this->assertSession()->responseContains('<li><a href="' . base_path() . 'menu-test-contextual/1/edit" class="use-ajax" data-dialog-type="modal" data-is-something>Edit menu - contextual</a></li>');
// Test contextual links respects the weight set in *.links.contextual.yml.
$firstLink = $this->assertSession()->elementExists('css', 'ul.contextual-links li:nth-of-type(1) a');
$secondLink = $this->assertSession()->elementExists('css', 'ul.contextual-links li:nth-of-type(2) a');
$this->assertEquals(base_path() . 'menu-test-contextual/1/edit', $firstLink->getAttribute('href'));
$this->assertEquals(base_path() . 'menu-test-contextual/1', $secondLink->getAttribute('href'));
}
/**
* Tests the contextual placeholder content is protected by a token.
*/
public function testTokenProtection(): void {
$this->drupalLogin($this->editorUser);
// Create a node that will have a contextual link.
$node1 = $this->drupalCreateNode(['type' => 'article', 'promote' => 1]);
// Now, on the front page, all article nodes should have contextual links
// placeholders, as should the view that contains them.
$id = 'node:node=' . $node1->id() . ':changed=' . $node1->getChangedTime() . '&langcode=en';
// Editor user: can access contextual links and can edit articles.
$this->drupalGet('node');
$this->assertContextualLinkPlaceHolder($id);
$http_client = $this->getHttpClient();
$url = Url::fromRoute('contextual.render', [], [
'query' => [
'_format' => 'json',
'destination' => 'node',
],
])->setAbsolute()->toString();
$response = $http_client->request('POST', $url, [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => [$id], 'tokens' => []],
'http_errors' => FALSE,
]);
$this->assertEquals('400', $response->getStatusCode());
$this->assertStringContainsString('No contextual ID tokens specified.', (string) $response->getBody());
$response = $http_client->request('POST', $url, [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => [$id], 'tokens' => ['wrong_token']],
'http_errors' => FALSE,
]);
$this->assertEquals('400', $response->getStatusCode());
$this->assertStringContainsString('Invalid contextual ID specified.', (string) $response->getBody());
$response = $http_client->request('POST', $url, [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => [$id], 'tokens' => ['wrong_key' => $this->createContextualIdToken($id)]],
'http_errors' => FALSE,
]);
$this->assertEquals('400', $response->getStatusCode());
$this->assertStringContainsString('Invalid contextual ID specified.', (string) $response->getBody());
$response = $http_client->request('POST', $url, [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => [$id], 'tokens' => [$this->createContextualIdToken($id)]],
'http_errors' => FALSE,
]);
$this->assertEquals('200', $response->getStatusCode());
}
/**
* Asserts that a contextual link placeholder with the given id exists.
*
* @param string $id
* A contextual link id.
*
* @internal
*/
protected function assertContextualLinkPlaceHolder(string $id): void {
$this->assertSession()->elementAttributeContains(
'css',
'div[data-contextual-id="' . $id . '"]',
'data-contextual-token',
$this->createContextualIdToken($id)
);
}
/**
* Asserts that a contextual link placeholder with a given id does not exist.
*
* @param string $id
* A contextual link id.
*
* @internal
*/
protected function assertNoContextualLinkPlaceHolder(string $id): void {
$this->assertSession()->elementNotExists('css', 'div[data-contextual-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 \Psr\Http\Message\ResponseInterface
* The response object.
*/
protected function renderContextualLinks($ids, $current_path): ResponseInterface {
$tokens = array_map([$this, 'createContextualIdToken'], $ids);
$http_client = $this->getHttpClient();
$url = Url::fromRoute('contextual.render', [], [
'query' => [
'_format' => 'json',
'destination' => $current_path,
],
]);
return $http_client->request('POST', $this->buildUrl($url), [
'cookies' => $this->getSessionCookies(),
'form_params' => ['ids' => $ids, 'tokens' => $tokens],
'http_errors' => FALSE,
]);
}
/**
* Creates a contextual ID token.
*
* @param string $id
* The contextual ID to create a token for.
*
* @return string
* The contextual ID token.
*/
protected function createContextualIdToken($id) {
return Crypt::hmacBase64($id, Settings::getHashSalt() . $this->container->get('private_key')->get());
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for contextual.
*
* @group contextual
*/
class GenericTest extends GenericModuleTestBase {}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\FunctionalJavascript;
/**
* Functions for testing contextual links.
*/
trait ContextualLinkClickTrait {
/**
* Clicks a contextual link.
*
* @param string $selector
* The selector for the element that contains the contextual link.
* @param string $link_locator
* The link id, title, or text.
* @param bool $force_visible
* If true then the button will be forced to visible so it can be clicked.
*/
protected function clickContextualLink($selector, $link_locator, $force_visible = TRUE) {
$page = $this->getSession()->getPage();
$page->waitFor(10, function () use ($page, $selector) {
return $page->find('css', "$selector .contextual-links");
});
if ($force_visible) {
$this->toggleContextualTriggerVisibility($selector);
}
$element = $this->getSession()->getPage()->find('css', $selector);
$element->find('css', '.contextual button')->press();
$element->findLink($link_locator)->click();
if ($force_visible) {
$this->toggleContextualTriggerVisibility($selector);
}
}
/**
* Toggles the visibility of a contextual trigger.
*
* @param string $selector
* The selector for the element that contains the contextual link.
*/
protected function toggleContextualTriggerVisibility($selector) {
// Hovering over the element itself with should be enough, but does not
// work. Manually remove the visually-hidden class.
$this->getSession()->executeScript("jQuery('{$selector} .contextual .trigger').toggleClass('visually-hidden');");
}
}

View File

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\FunctionalJavascript;
use Drupal\Core\Url;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\user\Entity\Role;
/**
* Tests the UI for correct contextual links.
*
* @group contextual
*/
class ContextualLinksTest extends WebDriverTestBase {
use ContextualLinkClickTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['block', 'contextual'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->createUser(['access contextual links']));
$this->placeBlock('system_branding_block', [
'id' => 'branding',
]);
}
/**
* Tests the visibility of contextual links.
*/
public function testContextualLinksVisibility(): void {
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$this->assertEmpty($contextualLinks);
// Ensure visibility remains correct after cached paged load.
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$this->assertEmpty($contextualLinks);
// Grant permissions to use contextual links on blocks.
$this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), [
'access contextual links',
'administer blocks',
]);
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$this->assertNotEmpty($contextualLinks);
// Confirm touchevents detection is loaded with Contextual Links
$this->assertSession()->elementExists('css', 'html.no-touchevents');
// Ensure visibility remains correct after cached paged load.
$this->drupalGet('user');
$contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
$this->assertNotEmpty($contextualLinks);
}
/**
* Tests clicking contextual links.
*/
public function testContextualLinksClick(): void {
$this->container->get('module_installer')->install(['contextual_test']);
// Test clicking contextual link without toolbar.
$this->drupalGet('user');
$this->clickContextualLink('#block-branding', 'Test Link');
$this->assertSession()->pageTextContains('Everything is contextual!');
// Test click a contextual link that uses ajax.
$this->drupalGet('user');
$current_page_string = 'NOT_RELOADED_IF_ON_PAGE';
$this->getSession()->executeScript('document.body.appendChild(document.createTextNode("' . $current_page_string . '"));');
// Move the pointer over the branding block so the contextual link appears
// as it would with a real user interaction. Otherwise clickContextualLink()
// does not open the dialog in a manner that is opener-aware, and it isn't
// possible to reliably test focus management.
$this->getSession()->getDriver()->mouseOver('.//*[@id="block-branding"]');
$this->clickContextualLink('#block-branding', 'Test Link with Ajax', FALSE);
$this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '#drupal-modal'));
$this->assertSession()->elementContains('css', '#drupal-modal', 'Everything is contextual!');
$this->getSession()->executeScript('document.querySelector("#block-branding .trigger").addEventListener("focus", (e) => e.target.classList.add("i-am-focused"))');
$this->getSession()->getPage()->pressButton('Close');
$this->assertSession()->assertNoElementAfterWait('css', 'ui.dialog');
// When the dialog is closed, the opening contextual link is now inside a
// collapsed container, so focus should be routed to the contextual link
// toggle button.
$this->assertNotNull($this->assertSession()->waitForElement('css', '.trigger.i-am-focused'), $this->getSession()->getPage()->find('css', '#block-branding')->getOuterHtml());
$this->assertJsCondition('document.activeElement === document.querySelector("#block-branding button.trigger")', 10000, 'Focus should be on the contextual trigger, but instead is at ' . $this->getSession()->evaluateScript('document.activeElement.outerHTML'));
// Check to make sure that page was not reloaded.
$this->assertSession()->pageTextContains($current_page_string);
// Test clicking contextual link with toolbar.
$this->container->get('module_installer')->install(['toolbar']);
$this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), ['access toolbar']);
$this->drupalGet('user');
$this->assertSession()->assertExpectedAjaxRequest(1);
// Click "Edit" in toolbar to show contextual links.
$this->getSession()->getPage()->find('css', '.contextual-toolbar-tab button')->press();
$this->clickContextualLink('#block-branding', 'Test Link', FALSE);
$this->assertSession()->pageTextContains('Everything is contextual!');
}
/**
* Tests the contextual links destination.
*/
public function testContextualLinksDestination(): void {
$this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), [
'access contextual links',
'administer blocks',
]);
$this->drupalGet('user');
$this->assertSession()->waitForElement('css', '.contextual button');
$expected_destination_value = (string) $this->loggedInUser->toUrl()->toString();
$contextual_link_url_parsed = parse_url($this->getSession()->getPage()->findLink('Configure block')->getAttribute('href'));
$this->assertEquals("destination=$expected_destination_value", $contextual_link_url_parsed['query']);
}
/**
* Tests the contextual links destination with query.
*/
public function testContextualLinksDestinationWithQuery(): void {
$this->grantPermissions(Role::load(Role::AUTHENTICATED_ID), [
'access contextual links',
'administer blocks',
]);
$this->drupalGet('admin/structure/block', ['query' => ['foo' => 'bar']]);
$this->assertSession()->waitForElement('css', '.contextual button');
$expected_destination_value = Url::fromRoute('block.admin_display')->toString();
$contextual_link_url_parsed = parse_url($this->getSession()->getPage()->findLink('Configure block')->getAttribute('href'));
$this->assertEquals("destination=$expected_destination_value%3Ffoo%3Dbar", $contextual_link_url_parsed['query']);
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests the UI for correct contextual links.
*
* @group contextual
*/
class DuplicateContextualLinksTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'block',
'contextual',
'node',
'views',
'views_ui',
'contextual_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests the contextual links with same id.
*/
public function testSameContextualLinks(): void {
$this->drupalPlaceBlock('views_block:contextual_recent-block_1', ['id' => 'first']);
$this->drupalPlaceBlock('views_block:contextual_recent-block_1', ['id' => 'second']);
$this->drupalCreateContentType(['type' => 'page']);
$this->drupalCreateNode();
$this->drupalLogin($this->drupalCreateUser([
'access content',
'access contextual links',
'administer nodes',
'administer blocks',
'administer views',
'edit any page content',
]));
// Ensure same contextual links work correct with fresh and cached page.
foreach (['fresh', 'cached'] as $state) {
$this->drupalGet('user');
$contextual_id = '[data-contextual-id^="node:node=1"]';
$this->assertJsCondition("(typeof jQuery !== 'undefined' && jQuery('[data-contextual-id]:empty').length === 0)");
$this->getSession()->executeScript("jQuery('#block-first $contextual_id .trigger').trigger('click');");
$contextual_links = $this->assertSession()->waitForElementVisible('css', "#block-first $contextual_id .contextual-links");
$this->assertTrue($contextual_links->isVisible(), "Contextual links are visible with $state page.");
}
}
}

View File

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
* Tests edit mode.
*
* @group contextual
* @group #slow
*/
class EditModeTest extends WebDriverTestBase {
/**
* CSS selector for Drupal's announce element.
*/
const ANNOUNCE_SELECTOR = '#drupal-live-announce';
/**
* {@inheritdoc}
*/
protected static $modules = [
'node',
'block',
'user',
'system',
'breakpoint',
'toolbar',
'contextual',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The administration theme name.
*
* @var string
*/
protected $adminTheme = 'claro';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
\Drupal::service('theme_installer')->install([$this->adminTheme]);
\Drupal::configFactory()
->getEditable('system.theme')
->set('admin', $this->adminTheme)
->save();
$this->drupalLogin($this->createUser([
'administer blocks',
'access contextual links',
'access toolbar',
'view the administration theme',
]));
$this->placeBlock('system_powered_by_block', ['id' => 'powered']);
}
/**
* Tests enabling and disabling edit mode.
*/
public function testEditModeEnableDisable(): void {
$web_assert = $this->assertSession();
$page = $this->getSession()->getPage();
// Get the page twice to ensure edit mode remains enabled after a new page
// request.
$this->drupalGet('user');
$expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]'));
// After the page loaded we need to additionally wait until the settings
// tray Ajax activity is done.
$web_assert->assertWaitOnAjaxRequest();
$unrestricted_tab_count = $this->getTabbableElementsCount();
$this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count);
// Enable edit mode.
// After the first page load the page will be in edit mode when loaded.
$this->pressToolbarEditButton();
$this->assertAnnounceEditMode();
$this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount());
// Disable edit mode.
$this->pressToolbarEditButton();
$this->assertAnnounceLeaveEditMode();
$this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount());
// Enable edit mode again.
$this->pressToolbarEditButton();
// Finally assert that the 'edit mode enabled' announcement is still
// correct after toggling the edit mode at least once.
$this->assertAnnounceEditMode();
$this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount());
// Test while Edit Mode is enabled it doesn't interfere with pages with
// no contextual links.
$this->drupalGet('admin/structure/block');
$web_assert->elementContains('css', 'h1.page-title', 'Block layout');
$this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]')));
$this->assertGreaterThan(0, $this->getTabbableElementsCount());
}
/**
* Presses the toolbar edit mode.
*/
protected function pressToolbarEditButton(): void {
$edit_button = $this->getSession()->getPage()->find('css', '#toolbar-bar div.contextual-toolbar-tab button');
$edit_button->press();
}
/**
* Asserts that the correct message was announced when entering edit mode.
*
* @internal
*/
protected function assertAnnounceEditMode(): void {
$web_assert = $this->assertSession();
// Wait for contextual trigger button.
$web_assert->waitForElementVisible('css', '.contextual trigger');
$web_assert->elementContains('css', static::ANNOUNCE_SELECTOR, 'Tabbing is constrained to a set of');
$web_assert->elementNotContains('css', static::ANNOUNCE_SELECTOR, 'Tabbing is no longer constrained by the Contextual module.');
}
/**
* Assert that the correct message was announced when leaving edit mode.
*
* @internal
*/
protected function assertAnnounceLeaveEditMode(): void {
$web_assert = $this->assertSession();
$page = $this->getSession()->getPage();
// Wait till all the contextual links are hidden.
$page->waitFor(1, function () use ($page) {
return empty($page->find('css', '.contextual .trigger.visually-hidden'));
});
$web_assert->elementContains('css', static::ANNOUNCE_SELECTOR, 'Tabbing is no longer constrained by the Contextual module.');
$web_assert->elementNotContains('css', static::ANNOUNCE_SELECTOR, 'Tabbing is constrained to a set of');
}
/**
* Gets the number of elements that are tabbable.
*
* @return int
* The number of tabbable elements.
*/
protected function getTabbableElementsCount(): int {
// Mark all tabbable elements.
$this->getSession()->executeScript("jQuery(window.tabbable.tabbable(document.body)).attr('data-marked', '');");
// Count all marked elements.
$count = count($this->getSession()->getPage()->findAll('css', "[data-marked]"));
// Remove set attributes.
$this->getSession()->executeScript("jQuery('[data-marked]').removeAttr('data-marked');");
return $count;
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\contextual\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests edge cases for converting between contextual links and IDs.
*
* @group contextual
*/
class ContextualUnitTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['contextual'];
/**
* Provides test cases for both test functions.
*
* Used in testContextualLinksToId() and testContextualIdToLinks().
*
* @return array[]
* Test cases.
*/
public static function contextualLinksDataProvider(): array {
$tests['one group, one dynamic path argument, no metadata'] = [
[
'node' => [
'route_parameters' => [
'node' => '14031991',
],
'metadata' => ['langcode' => 'en'],
],
],
'node:node=14031991:langcode=en',
];
$tests['one group, multiple dynamic path arguments, no metadata'] = [
[
'foo' => [
'route_parameters' => [
0 => 'bar',
'key' => 'baz',
1 => 'qux',
],
'metadata' => ['langcode' => 'en'],
],
],
'foo:0=bar&key=baz&1=qux:langcode=en',
];
$tests['one group, one dynamic path argument, metadata'] = [
[
'views_ui_edit' => [
'route_parameters' => [
'view' => 'frontpage',
],
'metadata' => [
'location' => 'page',
'display' => 'page_1',
'langcode' => 'en',
],
],
],
'views_ui_edit:view=frontpage:location=page&display=page_1&langcode=en',
];
$tests['multiple groups, multiple dynamic path arguments'] = [
[
'node' => [
'route_parameters' => [
'node' => '14031991',
],
'metadata' => ['langcode' => 'en'],
],
'foo' => [
'route_parameters' => [
0 => 'bar',
'key' => 'baz',
1 => 'qux',
],
'metadata' => ['langcode' => 'en'],
],
'edge' => [
'route_parameters' => ['20011988'],
'metadata' => ['langcode' => 'en'],
],
],
'node:node=14031991:langcode=en|foo:0=bar&key=baz&1=qux:langcode=en|edge:0=20011988:langcode=en',
];
return $tests;
}
/**
* Tests the conversion from contextual links to IDs.
*
* @param array $links
* The #contextual_links property value array.
* @param string $id
* The serialized representation of the passed links.
*
* @covers ::_contextual_links_to_id
*
* @dataProvider contextualLinksDataProvider
*/
public function testContextualLinksToId(array $links, string $id): void {
$this->assertSame($id, _contextual_links_to_id($links));
}
/**
* Tests the conversion from contextual ID to links.
*
* @param array $links
* The #contextual_links property value array.
* @param string $id
* The serialized representation of the passed links.
*
* @covers ::_contextual_id_to_links
*
* @dataProvider contextualLinksDataProvider
*/
public function testContextualIdToLinks(array $links, string $id): void {
$this->assertSame($links, _contextual_id_to_links($id));
}
}