Issue #3445993 by plopesc, m4olivei, quietone, gauravvvv, catch, finnsky, larowlan, nod_: Provide a NavigationLinkBlock Plugin and use Help as an usage example

merge-requests/9146/merge
Lee Rowlands 2024-10-22 13:06:01 +10:00
parent 8da192a5d8
commit e876830e54
No known key found for this signature in database
GPG Key ID: 2B829A3DF9204DC4
10 changed files with 449 additions and 21 deletions

View File

@ -48,4 +48,18 @@ sections:
provider: navigation
weight: 0
additional: { }
6d7080a7-abab-4bad-b960-2459ca892a54:
uuid: 6d7080a7-abab-4bad-b960-2459ca892a54
region: footer
configuration:
id: navigation_link
label: Help
label_display: '0'
provider: navigation
context_mapping: { }
title: Help
uri: 'internal:/admin/help'
icon_class: help
weight: -2
additional: { }
third_party_settings: { }

View File

@ -71,3 +71,23 @@ block.settings.navigation_menu:*:
depth:
type: integer
label: 'Maximum number of levels'
block.settings.navigation_link:
type: block_settings
label: 'Link block'
mapping:
title:
type: label
label: 'Link title'
uri:
type: string
label: 'URL'
icon_class:
type: string
label: 'Icon CSS Class'
constraints:
Regex:
pattern: '/^[a-z0-9_-]+$/'
message: "The %value icon CSS class is not valid."
constraints:
FullyValidatable: ~

View File

@ -301,12 +301,15 @@ body {
.admin-toolbar__footer {
z-index: var(--admin-toolbar-z-index-footer);
display: grid;
gap: var(--admin-toolbar-space-16);
gap: var(--admin-toolbar-space-4);
margin-block-start: auto;
padding: var(--admin-toolbar-space-16);
border-block-start: 1px solid var(--admin-toolbar-color-gray-200);
border-inline-end: 1px solid var(--admin-toolbar-color-gray-100);
}
.admin-toolbar__footer > .toolbar-block:last-of-type {
margin-block-end: var(--admin-toolbar-space-12);
}
@media (min-width: 64rem) {
.admin-toolbar__footer {
--admin-toolbar-z-index-footer: -1;

View File

@ -313,12 +313,18 @@ body {
.admin-toolbar__footer {
z-index: var(--admin-toolbar-z-index-footer);
display: grid;
gap: var(--admin-toolbar-space-16);
gap: var(--admin-toolbar-space-4);
margin-block-start: auto;
padding: var(--admin-toolbar-space-16);
border-block-start: 1px solid var(--admin-toolbar-color-gray-200);
border-inline-end: 1px solid var(--admin-toolbar-color-gray-100);
& > .toolbar-block {
&:last-of-type {
margin-block-end: var(--admin-toolbar-space-12);
}
}
@media (--admin-toolbar-desktop) {
--admin-toolbar-z-index-footer: -1;

View File

@ -132,7 +132,6 @@ function navigation_theme($existing, $type, $theme, $path) {
$items['menu_region__footer'] = [
'variables' => [
'help' => NULL,
'items' => [],
'title' => NULL,
'menu_name' => NULL,
@ -200,6 +199,7 @@ function navigation_block_alter(&$definitions): void {
'navigation_user',
'navigation_shortcuts',
'navigation_menu',
'navigation_link',
];
foreach ($hidden as $block_id) {
if (isset($definitions[$block_id])) {

View File

@ -31,5 +31,5 @@ services:
navigation.user_lazy_builder:
class: Drupal\navigation\UserLazyBuilder
arguments: ['@module_handler', '@current_user']
arguments: ['@current_user']
Drupal\navigation\UserLazyBuilders: '@navigation.user_lazy_builder'

View File

@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace Drupal\navigation\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\Element\EntityAutocomplete;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
/**
* Defines a link navigation block.
*
* @internal
*/
#[Block(
id: 'navigation_link',
admin_label: new TranslatableMarkup('Link'),
)]
final class NavigationLinkBlock extends BlockBase {
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return [
'title' => '',
'uri' => '',
'icon_class' => '',
];
}
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state): array {
$config = $this->configuration;
$display_uri = NULL;
if (!empty($config['uri'])) {
try {
// The current field value could have been entered by a different user.
// However, if it is inaccessible to the current user, do not display it
// to them.
$url = Url::fromUri($config['uri']);
if (\Drupal::currentUser()->hasPermission('link to any page') || $url->access()) {
$display_uri = static::getUriAsDisplayableString($config['uri']);
}
}
catch (\InvalidArgumentException) {
// If $item->uri is invalid, show value as is, so the user can see what
// to edit.
$display_uri = $config['uri'];
}
}
// @todo Logic related to the uri component has been borrowed from
// Drupal\link\Plugin\Field\FieldWidget\LinkWidget.
// Will be fixed in https://www.drupal.org/project/drupal/issues/3450518.
$form['uri'] = [
'#type' => 'entity_autocomplete',
'#title' => $this->t('URL'),
'#default_value' => $display_uri,
'#element_validate' => [[static::class, 'validateUriElement']],
'#attributes' => [
'data-autocomplete-first-character-blacklist' => '/#?',
],
// @todo The user should be able to select an entity type. Will be fixed
// in https://www.drupal.org/node/2423093.
'#target_type' => 'node',
'#maxlength' => 2048,
'#required' => TRUE,
'#process_default_value' => FALSE,
];
$form['title'] = [
'#type' => 'textfield',
'#title' => $this->t('Link text'),
'#default_value' => $config['title'],
'#required' => TRUE,
'#maxlength' => 255,
];
$form['icon_class'] = [
'#type' => 'textfield',
'#title' => $this->t('Icon CSS class'),
'#default_value' => $config['icon_class'],
'#element_validate' => [[static::class, 'validateIconClassElement']],
'#required' => TRUE,
'#maxlength' => 64,
];
return $form;
}
/**
* Form element validation handler for the 'icon_class' element.
*
* Disallows saving invalid class values.
*/
public static function validateIconClassElement(array $element, FormStateInterface $form_state, array $form): void {
$icon = $element['#value'];
if (!preg_match('/^[a-z0-9_-]+$/', $icon)) {
$form_state->setError($element, t('The machine-readable name must contain only lowercase letters, numbers, underscores and hyphens.'));
}
}
/**
* Form element validation handler for the 'uri' element.
*
* Disallows saving inaccessible or untrusted URLs.
*/
public static function validateUriElement($element, FormStateInterface $form_state, $form): void {
$uri = static::getUserEnteredStringAsUri($element['#value']);
$form_state->setValueForElement($element, $uri);
// If getUserEnteredStringAsUri() mapped the entered value to an 'internal:'
// URI , ensure the raw value begins with '/', '?' or '#'.
// @todo '<front>' is valid input for BC reasons, may be removed by
// https://www.drupal.org/node/2421941
if (parse_url($uri, PHP_URL_SCHEME) === 'internal' && !in_array($element['#value'][0], ['/', '?', '#'], TRUE) && !str_starts_with($element['#value'], '<front>')) {
$form_state->setError($element, new TranslatableMarkup('Manually entered paths should start with one of the following characters: / ? #'));
return;
}
}
/**
* Gets the user-entered string as a URI.
*
* The following two forms of input are mapped to URIs:
* - entity autocomplete ("label (entity id)") strings: to 'entity:' URIs;
* - strings without a detectable scheme: to 'internal:' URIs.
*
* This method is the inverse of ::getUriAsDisplayableString().
*
* @param string $string
* The user-entered string.
*
* @return string
* The URI, if a non-empty $uri was passed.
*
* @see static::getUriAsDisplayableString()
*/
protected static function getUserEnteredStringAsUri($string):string {
// By default, assume the entered string is a URI.
$uri = trim($string);
// Detect entity autocomplete string, map to 'entity:' URI.
$entity_id = EntityAutocomplete::extractEntityIdFromAutocompleteInput($string);
if ($entity_id !== NULL) {
// @todo Support entity types other than 'node'. Will be fixed in
// https://www.drupal.org/node/2423093.
$uri = 'entity:node/' . $entity_id;
}
// Support linking to nothing.
elseif (in_array($string, ['<nolink>', '<none>', '<button>'], TRUE)) {
$uri = 'route:' . $string;
}
// Detect a schemeless string, map to 'internal:' URI.
elseif (!empty($string) && parse_url($string, PHP_URL_SCHEME) === NULL) {
// @todo '<front>' is valid input for BC reasons, may be removed by
// https://www.drupal.org/node/2421941
// - '<front>' -> '/'
// - '<front>#foo' -> '/#foo'
if (str_starts_with($string, '<front>')) {
$string = '/' . substr($string, strlen('<front>'));
}
$uri = 'internal:' . $string;
}
return $uri;
}
/**
* Gets the URI without the 'internal:' or 'entity:' scheme.
*
* The following two forms of URIs are transformed:
* - 'entity:' URIs: to entity autocomplete ("label (entity id)") strings;
* - 'internal:' URIs: the scheme is stripped.
*
* This method is the inverse of ::getUserEnteredStringAsUri().
*
* @param string $uri
* The URI to get the displayable string for.
*
* @return string
*
* @see static::getUserEnteredStringAsUri()
*/
protected static function getUriAsDisplayableString($uri): string {
$scheme = parse_url($uri, PHP_URL_SCHEME);
// By default, the displayable string is the URI.
$displayable_string = $uri;
// A different displayable string may be chosen in case of the 'internal:'
// or 'entity:' built-in schemes.
if ($scheme === 'internal') {
$uri_reference = explode(':', $uri, 2)[1];
// @todo '<front>' is valid input for BC reasons, may be removed by
// https://www.drupal.org/node/2421941
$path = parse_url($uri, PHP_URL_PATH);
if ($path === '/') {
$uri_reference = '<front>' . substr($uri_reference, 1);
}
$displayable_string = $uri_reference;
}
elseif ($scheme === 'entity') {
[$entity_type, $entity_id] = explode('/', substr($uri, 7), 2);
// Show the 'entity:' URI as the entity autocomplete would.
// @todo Support entity types other than 'node'. Will be fixed in
// https://www.drupal.org/node/2423093.
if ($entity_type == 'node' && $entity = \Drupal::entityTypeManager()->getStorage($entity_type)->load($entity_id)) {
$displayable_string = EntityAutocomplete::getEntityLabels([$entity]);
}
}
elseif ($scheme === 'route') {
$displayable_string = ltrim($displayable_string, 'route:');
}
return $displayable_string;
}
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state): void {
$this->configuration['uri'] = $form_state->getValue('uri');
$this->configuration['title'] = $form_state->getValue('title');
$this->configuration['icon_class'] = $form_state->getValue('icon_class');
}
/**
* {@inheritdoc}
*/
public function build(): array {
$config = $this->configuration;
$build = [];
// Ensure that user has access to link before rendering it.
try {
$url = Url::fromUri($config['uri']);
$access = $url->access(NULL, TRUE);
if (!$access->isAllowed()) {
// Cacheable dependency is explicitly added when access is not granted.
// It is bubbled when the link is rendered.
$cacheable_metadata = new CacheableMetadata();
$cacheable_metadata->addCacheableDependency($access);
$cacheable_metadata->applyTo($build);
return $build;
}
}
catch (\InvalidArgumentException) {
return $build;
}
return $build + [
'#title' => $config['label'],
'#theme' => 'navigation_menu',
'#menu_name' => 'link',
'#items' => [
[
'title' => $config['title'],
'class' => $config['icon_class'],
'url' => $url,
],
],
];
}
}

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Drupal\navigation;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
@ -22,13 +21,10 @@ final class UserLazyBuilder implements TrustedCallbackInterface {
/**
* Constructs an UserLazyBuilder object.
*
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler.
* @param \Drupal\Core\Session\AccountProxyInterface $account
* The current user.
*/
public function __construct(
protected readonly ModuleHandlerInterface $moduleHandler,
protected readonly AccountProxyInterface $account,
) {}
@ -40,7 +36,6 @@ final class UserLazyBuilder implements TrustedCallbackInterface {
*/
public function renderNavigationLinks() {
return [
'#help' => $this->moduleHandler->moduleExists('help'),
'#theme' => 'menu_region__footer',
'#items' => $this->userOperationLinks(),
'#menu_name' => 'user',

View File

@ -5,7 +5,6 @@
*
* Available variables:
* - menu_name: The machine name of the user menu.
* - help: TRUE if "Help" module is enabled.
* - title: A name of account.
* - items: A nested list of menu items. Each menu item contains:
* - attributes: HTML attributes for the menu item.
@ -26,17 +25,6 @@
{# @todo id & aria-labelledby will be brought back via JS in https://www.drupal.org/project/drupal/issues/3452724 #}
<h4 class="visually-hidden focusable">{{ title }}</h4>
<ul class="toolbar-block__list">
{% if help %}
<li class="toolbar-block__list-item">
{% include 'navigation:toolbar-button' with {
attributes: create_attribute({ 'href': path('help.main'), 'data-drupal-tooltip': 'Help'|t, 'data-drupal-tooltip-class': 'admin-toolbar__tooltip' }),
icon: 'help',
html_tag: 'a',
text: 'Help'|t,
modifiers: ['collapsible'],
} only %}
</li>
{% endif %}
<li id="admin-toolbar-user-menu" class="toolbar-block__list-item toolbar-block__list-item--user toolbar-popover" data-toolbar-popover>
{% include 'navigation:toolbar-button' with {
action: 'Extend'|t,

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\navigation\Functional;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Plugin\Context\Context;
use Drupal\Core\Plugin\Context\ContextDefinition;
use Drupal\Core\Url;
use Drupal\layout_builder\SectionComponent;
use Drupal\Tests\system\Functional\Cache\PageCacheTagsTestBase;
/**
* Tests for \Drupal\navigation\Plugin\Block\NavigationLinkBlockTest.
*
* @group navigation
*/
class NavigationLinkBlockTest extends PageCacheTagsTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['navigation', 'test_page_test', 'block'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* User with permission to administer navigation blocks and access navigation.
*
* @var \Drupal\user\UserInterface
*/
protected $adminUser;
/**
* An authenticated user to test navigation block caching.
*
* @var \Drupal\user\UserInterface
*/
protected $normalUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create an admin user, log in and enable test navigation blocks.
$this->adminUser = $this->drupalCreateUser([
'access administration pages',
'administer site configuration',
'access navigation',
]);
// Create additional users to test caching modes.
$this->normalUser = $this->drupalCreateUser([
'access navigation',
]);
// Add programmatically a link block to the navigation.
$section_storage_manager = \Drupal::service('plugin.manager.layout_builder.section_storage');
$cacheability = new CacheableMetadata();
$contexts = [
'navigation' => new Context(ContextDefinition::create('string'), 'navigation'),
];
/** @var \Drupal\layout_builder\SectionListInterface $section_list */
$section_list = $section_storage_manager->findByContext($contexts, $cacheability);
$section = $section_list->getSection(0);
$section->appendComponent(new SectionComponent(\Drupal::service('uuid')->generate(), 'content', [
'id' => 'navigation_link',
'label' => 'Admin Main Page',
'label_display' => '0',
'provider' => 'navigation',
'context_mapping' => [],
'title' => 'Navigation Settings',
'uri' => 'internal:/admin/config/user-interface/navigation/settings',
'icon_class' => 'admin-link',
]));
$section_list->save();
}
/**
* Test output of the link navigation with regards to caching and contents.
*/
public function testNavigationLinkBlock(): void {
// Verify some basic cacheability metadata. Ensures that we're not doing
// anything so egregious as to upset expected caching behavior. In this
// case, as an anonymous user, we should have zero effect on the page.
$test_page_url = Url::fromRoute('test_page_test.test_page');
$this->verifyPageCache($test_page_url, 'MISS');
$this->verifyPageCache($test_page_url, 'HIT');
// Login as a limited access user, and verify that the dynamic page cache
// is working as expected.
$this->drupalLogin($this->normalUser);
$this->verifyDynamicPageCache($test_page_url, 'MISS');
$this->verifyDynamicPageCache($test_page_url, 'HIT');
// We should not see the admin page link in the page.
$link_selector = '.admin-toolbar__item .toolbar-button--icon--admin-link';
$this->assertSession()->elementNotExists('css', $link_selector);
// Login as a different user, UI should update.
$this->drupalLogin($this->adminUser);
$this->verifyDynamicPageCache($test_page_url, 'MISS');
$this->verifyDynamicPageCache($test_page_url, 'HIT');
$this->drupalGet(Url::fromRoute('navigation.settings'));
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->elementExists('css', $link_selector);
$this->assertSession()
->elementTextContains('css', $link_selector, 'Navigation Settings');
// The link should link to the admin page.
$link = $this->getSession()->getPage()->find('named', [
'link',
'Navigation Settings',
]);
$this->assertStringContainsString('/admin/config/user-interface/navigation/settings', $link->getAttribute('href'));
}
}