Initial Drupal 11 with DDEV setup
This commit is contained in:
@ -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'
|
||||
42
web/core/modules/contextual/contextual.api.php
Normal file
42
web/core/modules/contextual/contextual.api.php
Normal 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".
|
||||
*/
|
||||
5
web/core/modules/contextual/contextual.info.yml
Normal file
5
web/core/modules/contextual/contextual.info.yml
Normal 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
|
||||
34
web/core/modules/contextual/contextual.libraries.yml
Normal file
34
web/core/modules/contextual/contextual.libraries.yml
Normal 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
|
||||
77
web/core/modules/contextual/contextual.module
Normal file
77
web/core/modules/contextual/contextual.module
Normal 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;
|
||||
}
|
||||
2
web/core/modules/contextual/contextual.permissions.yml
Normal file
2
web/core/modules/contextual/contextual.permissions.yml
Normal file
@ -0,0 +1,2 @@
|
||||
access contextual links:
|
||||
title: 'Use contextual links'
|
||||
15
web/core/modules/contextual/contextual.post_update.php
Normal file
15
web/core/modules/contextual/contextual.post_update.php
Normal 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',
|
||||
];
|
||||
}
|
||||
6
web/core/modules/contextual/contextual.routing.yml
Normal file
6
web/core/modules/contextual/contextual.routing.yml
Normal file
@ -0,0 +1,6 @@
|
||||
contextual.render:
|
||||
path: '/contextual/render'
|
||||
defaults:
|
||||
_controller: '\Drupal\contextual\ContextualController::render'
|
||||
requirements:
|
||||
_permission: 'access contextual links'
|
||||
2
web/core/modules/contextual/contextual.services.yml
Normal file
2
web/core/modules/contextual/contextual.services.yml
Normal file
@ -0,0 +1,2 @@
|
||||
parameters:
|
||||
contextual.skip_procedural_hook_scan: true
|
||||
49
web/core/modules/contextual/css/contextual.icons.theme.css
Normal file
49
web/core/modules/contextual/css/contextual.icons.theme.css
Normal 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");
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
18
web/core/modules/contextual/css/contextual.module.css
Normal file
18
web/core/modules/contextual/css/contextual.module.css
Normal 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;
|
||||
}
|
||||
117
web/core/modules/contextual/css/contextual.theme.css
Normal file
117
web/core/modules/contextual/css/contextual.theme.css
Normal 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;
|
||||
}
|
||||
23
web/core/modules/contextual/css/contextual.toolbar.css
Normal file
23
web/core/modules/contextual/css/contextual.toolbar.css
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
537
web/core/modules/contextual/js/contextual.js
Normal file
537
web/core/modules/contextual/js/contextual.js
Normal 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);
|
||||
70
web/core/modules/contextual/js/contextual.toolbar.js
Normal file
70
web/core/modules/contextual/js/contextual.toolbar.js
Normal 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);
|
||||
@ -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);
|
||||
90
web/core/modules/contextual/src/ContextualController.php
Normal file
90
web/core/modules/contextual/src/ContextualController.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
120
web/core/modules/contextual/src/Element/ContextualLinks.php
Normal file
120
web/core/modules/contextual/src/Element/ContextualLinks.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
124
web/core/modules/contextual/src/Hook/ContextualHooks.php
Normal file
124
web/core/modules/contextual/src/Hook/ContextualHooks.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@ -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() {}
|
||||
|
||||
}
|
||||
@ -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: { }
|
||||
@ -0,0 +1,7 @@
|
||||
name: 'Contextual Test'
|
||||
type: module
|
||||
description: 'Provides test contextual links.'
|
||||
package: Testing
|
||||
version: VERSION
|
||||
dependencies:
|
||||
- drupal:contextual
|
||||
@ -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'
|
||||
@ -0,0 +1,6 @@
|
||||
contextual_test:
|
||||
path: '/contextual-tests'
|
||||
defaults:
|
||||
_controller: '\Drupal\contextual_test\Controller\TestController::render'
|
||||
requirements:
|
||||
_access: 'TRUE'
|
||||
@ -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!',
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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');");
|
||||
}
|
||||
|
||||
}
|
||||
@ -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']);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user