From e876830e5436d9f401bb87bd5ddb4e45a31cee52 Mon Sep 17 00:00:00 2001 From: Lee Rowlands Date: Tue, 22 Oct 2024 13:06:01 +1000 Subject: [PATCH] Issue #3445993 by plopesc, m4olivei, quietone, gauravvvv, catch, finnsky, larowlan, nod_: Provide a NavigationLinkBlock Plugin and use Help as an usage example --- .../install/navigation.block_layout.yml | 14 + .../config/schema/navigation.schema.yml | 20 ++ .../css/components/admin-toolbar.css | 5 +- .../css/components/admin-toolbar.pcss.css | 8 +- core/modules/navigation/navigation.module | 2 +- .../navigation/navigation.services.yml | 2 +- .../src/Plugin/Block/NavigationLinkBlock.php | 277 ++++++++++++++++++ .../navigation/src/UserLazyBuilder.php | 5 - .../templates/menu-region--footer.html.twig | 12 - .../Functional/NavigationLinkBlockTest.php | 125 ++++++++ 10 files changed, 449 insertions(+), 21 deletions(-) create mode 100644 core/modules/navigation/src/Plugin/Block/NavigationLinkBlock.php create mode 100644 core/modules/navigation/tests/src/Functional/NavigationLinkBlockTest.php diff --git a/core/modules/navigation/config/install/navigation.block_layout.yml b/core/modules/navigation/config/install/navigation.block_layout.yml index 571062db767..818d34df7e7 100644 --- a/core/modules/navigation/config/install/navigation.block_layout.yml +++ b/core/modules/navigation/config/install/navigation.block_layout.yml @@ -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: { } diff --git a/core/modules/navigation/config/schema/navigation.schema.yml b/core/modules/navigation/config/schema/navigation.schema.yml index fabf40b964f..ee31849202a 100644 --- a/core/modules/navigation/config/schema/navigation.schema.yml +++ b/core/modules/navigation/config/schema/navigation.schema.yml @@ -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: ~ diff --git a/core/modules/navigation/css/components/admin-toolbar.css b/core/modules/navigation/css/components/admin-toolbar.css index a91ae746c63..14388c04e42 100644 --- a/core/modules/navigation/css/components/admin-toolbar.css +++ b/core/modules/navigation/css/components/admin-toolbar.css @@ -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; diff --git a/core/modules/navigation/css/components/admin-toolbar.pcss.css b/core/modules/navigation/css/components/admin-toolbar.pcss.css index 6257fa74b32..9d9a447b290 100644 --- a/core/modules/navigation/css/components/admin-toolbar.pcss.css +++ b/core/modules/navigation/css/components/admin-toolbar.pcss.css @@ -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; diff --git a/core/modules/navigation/navigation.module b/core/modules/navigation/navigation.module index 1c2f8ed4c46..578b73205e9 100644 --- a/core/modules/navigation/navigation.module +++ b/core/modules/navigation/navigation.module @@ -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])) { diff --git a/core/modules/navigation/navigation.services.yml b/core/modules/navigation/navigation.services.yml index 6c5ca8462d9..5413372b557 100644 --- a/core/modules/navigation/navigation.services.yml +++ b/core/modules/navigation/navigation.services.yml @@ -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' diff --git a/core/modules/navigation/src/Plugin/Block/NavigationLinkBlock.php b/core/modules/navigation/src/Plugin/Block/NavigationLinkBlock.php new file mode 100644 index 00000000000..0043e54d5dd --- /dev/null +++ b/core/modules/navigation/src/Plugin/Block/NavigationLinkBlock.php @@ -0,0 +1,277 @@ + '', + '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 '' 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'], '')) { + $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, ['', '', '