Issue #3445993 by plopesc, m4olivei, quietone, gauravvvv, catch, finnsky, larowlan, nod_: Provide a NavigationLinkBlock Plugin and use Help as an usage example
parent
8da192a5d8
commit
e876830e54
|
@ -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: { }
|
||||
|
|
|
@ -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: ~
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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])) {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue