Commit 40fc5b84 authored by David Suissa's avatar David Suissa
Browse files

Issue #3564229 by dydave: Admin Toolbar Search: Refactored JS code with...

Issue #3564229 by dydave: Admin Toolbar Search: Refactored JS code with Vanilla JS and fixed eslint issues.
parent 2cab53c8
Loading
Loading
Loading
Loading
Loading
+6 −4
Original line number Diff line number Diff line
@@ -47,6 +47,12 @@
  max-height: 18.75rem;
  font-size: 0.8125rem;

  &.ui-autocomplete {
    /* override core 'toolbar.module.css' class '.toolbar .toolbar-tray' default
      value of 'z-index:501', to prevent overlaps with other toolbar trays. */
    z-index: 600;
  }

  /* Styles for each suggestion item in the autocomplete list. */
  .ui-menu-item {
    /* Add a bit of vertical spacing between the list items. */
@@ -68,10 +74,6 @@
        color: white;
      }
    }
    /* Hide link URLs in the span tags within autocomplete suggestion items. */
    .admin-toolbar-search-url {
      display: none;
    }
  }
}

+247 −112
Original line number Diff line number Diff line
/**
 * @file
 * Behaviors for the search widget in the admin toolbar.
 * Behaviors for the autocomplete search widget in the drupal core toolbar.
 *
 * @param {Function} once
 *   The once library used to ensure behaviors are attached only once.
 * @param {Drupal} Drupal
 *   The Drupal global used to define behaviors.
 * @param {jQuery} $
 *   The jQuery library used to instantiate the autocomplete widget.
 */

(function ($, Drupal) {

  'use strict';

((once, Drupal, $) => {
  /**
   * Integrates jQuery UI Autocomplete with Admin Toolbar for searching links.
   *
   * @namespace Drupal.behaviors.adminToolbarSearch
   *
   * @type {Object}
   *   More specifically, a Drupal behavior.
   *
   * @prop {Function} attach
   *   Attaches the behavior to instantiate the jQuery autocomplete search field
   *   in the admin toolbar.
   *
   * @see https://api.jqueryui.com/autocomplete/
   */
  Drupal.behaviors.adminToolbarSearch = {

    // If extra links have been fetched.
    extraFetched: false,

    attach: function (context) {
      if (context != document) {
    attach(context) {
      // Attach only when the whole document is loaded.
      if (context !== document) {
        return;
      }
      // Search input field ID in toolbar tray and mobile support version.
      const searchInputID = '#admin-toolbar-search-input';
      // Search input field ID in toolbar bar.
      const searchFieldInputID = '#admin-toolbar-search-field-input';

      var $self = this;
      // Ensure the autocomplete is only called once when the toolbar is loaded.
      once('admin-toolbar-search', '#toolbar-bar', context).forEach(
        (toolbarBar) => {
          // Get the two search input fields on which to call the autocomplete.
          const searchInputFields = toolbarBar.querySelectorAll(
            [searchInputID, searchFieldInputID].join(', '),
          );

      const elements = once('admin-toolbar-search', '#toolbar-bar', context);
      $(elements).each(function () {
        $self.links = [];
          // Skip any further processing if no search input field is found.
          if (searchInputFields.length === 0) {
            return;
          }

        var $searchInputs = $(this).find('#admin-toolbar-search-input, #admin-toolbar-search-field-input');
          /**
           * The list of menu links collected from the admin toolbar trays for
           * autocomplete searching.
           *
           * @param {Array}
           */
          const menuLinks = [];

        if ($searchInputs.length === 0) {
          return;
          /**
           * Helper function to get the display label for a menu link item.
           *
           * This function constructs a breadcrumb-like label for the given menu
           * link item by traversing its parent menu items, for example:
           * Configuration > Development > Performance > Performance
           *
           * @param {HTMLLinkElement} item
           *  The link element item to be processed.
           *
           * @return {String}
           *   The HTML for the list item to be displayed in the autocomplete
           *   suggestions.
           */
          const getItemDisplayLabel = (item) => {
            // Start with an empty array of breadcrumbs.
            const breadcrumbs = [];
            let parent = item.closest('.menu-item');
            // Loop through all the parents with the class 'menu-item'.
            while (parent) {
              const link = parent.querySelector(
                // For each parent menu item, find the first child link without
                // the CSS class 'admin-toolbar-search-ignore', to give the
                // ability to ignore certain menu items from the breadcrumb.
                'a:first-child:not(.admin-toolbar-search-ignore)',
              );
              if (link) {
                // Extract the text of the links of the menu items.
                breadcrumbs.unshift(link.innerText);
              }
              // Move to the next parent menu item.
              parent = parent.parentNode.closest('.menu-item');
            }
            // Return the breadcrumbs joined with ' > ' separator.
            return breadcrumbs.join(' > ');
          };

        $searchInputs.each(function (index, $searchInput) {
          $($searchInput).autocomplete({
            minLength: 2,
            position: { collision : 'fit' },
            source: function (request, response) {
              var data = $self.handleAutocomplete(request.term);
              if (!$self.extraFetched && drupalSettings.adminToolbarSearch.loadExtraLinks) {
                $.getJSON( Drupal.url('admin/admin-toolbar-search'), function ( data ) {
                  $(data).each(function () {
                    var item = this;
                    item.label = this.labelRaw + ' ' + this.value;
                    $self.links.push(item);
                  });
          /**
           * Function callback for handling the autocomplete search.
           *
           * This function filters through the list of menu links collected from
           * the admin toolbar trays and returns the ones matching the search
           * query:
           * The search matches first the exact matches of the whole search
           * query, then adds suggestions matching all searched keywords (AND).
           *
           * Query strings are stripped from link URLs for searching to prevent
           * generated tokens or destinations from appearing in the results.
           *
           * @param {String} term
           * The search term entered by the user.
           *
           * @return {Array}
           *   The list of matched menu links suggestions.
           */
          const handleAutocomplete = (term) => {
            // Split the search query keywords separated by spaces (' ') into a
            // list.
            const keywords = term.split(' ');
            const suggestions = [];

                  $self.extraFetched = true;
            menuLinks.forEach((element) => {
              // Strip query strings from link URLs for searching to prevent generated
              // tokens or destinations from appearing in the search results.
              const linkUrl = element.linkUrl.split('?')[0].toLowerCase();
              // Concatenate the label and link URL for searching.
              const label = `${element.label.toLowerCase()} ${linkUrl}`;

                  var results = $self.handleAutocomplete(request.term);
                  response(results);
              // Try selecting first the exact matches of the whole search query.
              if (label.indexOf(term.toLowerCase()) >= 0) {
                suggestions.push(element);
              } else {
                // Add suggestions matching *all* searched keywords.
                let matchCount = 0;
                keywords.forEach((keyword) => {
                  if (label.indexOf(keyword.toLowerCase()) >= 0) {
                    matchCount += 1;
                  }
                });
                // Keep the suggestion only if *all* keywords matched (AND).
                if (matchCount === keywords.length) {
                  suggestions.push(element);
                }
              else {
                response(data);
              }
            },
            open: function () {
              var zIndex = $('#toolbar-item-administration-tray')
                .css('z-index') + 1;
              $(this).autocomplete('widget').css('z-index', zIndex);
            });
            // Return the list of matched menu links suggestions.
            return suggestions;
          };

              return false;
          /**
           * Initialize the autocomplete widget for each search input field.
           *
           * The autocomplete widgets use the same menu links array for search
           * suggestions.
           *
           * @see https://api.jqueryui.com/autocomplete/
           */
          searchInputFields.forEach((searchInputField) => {
            // Initialize the jQuery UI Autocomplete widget.
            $(searchInputField)
              .autocomplete({
                // Minimum characters to trigger the autocomplete.
                minLength: 2,
                // Position the autocomplete list below the input field.
                position: { collision: 'fit' },
                // Source callback to provide the autocomplete suggestions.
                source(request, response) {
                  // Call the handleAutocomplete function to get the results.
                  response(handleAutocomplete(request.term));
                },
            select: function (event, ui) {
                // Handle the selection of an autocomplete item.
                select(event, ui) {
                  if (ui.item.value) {
                location.href = ui.item.value;
                return false;
              }
                    // Navigate to the selected link URL.
                    window.location.href = ui.item.linkUrl;
                  }
          }).data('ui-autocomplete')._renderItem = (function (ul, item) {
                  return false;
                },
              })
              // Override the default rendering of the autocomplete list items.
              .data('ui-autocomplete')._renderItem = (ul, item) => {
              // Add a custom CSS class to the autocomplete list.
              ul.addClass('admin-toolbar-search-autocomplete-list');
              // Return the formatted list item HTML with link label and URL.
              return $('<li>')
              .append('<div ><a href="' + item.value + '">' + item.labelRaw + ' <span class="admin-toolbar-search-url">' + item.value + '</span></a></div>')
                .append(
                  `<div><a href="${item.linkUrl}">${item.label}</a></div>`,
                )
                .appendTo(ul);
            };

            /**
             * Initialize the menu links for autocomplete searches.
             *
             * Populates the links in admin toolbar search only when the input
             * fields are focused:
             * - Collect all the links available in the admin toolbar trays
             *   ('.toolbar-tray') with the drupal custom data attribute:
             *   - 'data-drupal-link-system-path'
             * - Optionally, fetch extra links through an AJAX call to the
             *   server with a specific controller.
             */
            searchInputField.addEventListener('focus', () => {
              // Populate only when links array is empty (only the first time).
              if (menuLinks.length === 0) {
                // Exclude certain paths from the search results, such as the
                // front page.
                const getUrl = window.location;
                const baseUrl = `${getUrl.protocol}//${getUrl.host}/`;
                // Define paths to be excluded from the search results.
                const excludedPaths = [Drupal.url(''), baseUrl];

                // Collect all the links available in the admin toolbar trays
                // ('.toolbar-tray') with the drupal custom data attribute:
                // - 'data-drupal-link-system-path'.
                document
                  .querySelectorAll(
                    `.toolbar-tray a[data-drupal-link-system-path]`,
                  )
                  .forEach((element) => {
                    // Exclude links with URLs matching the excluded paths.
                    if (excludedPaths.includes(element.href)) {
                      return;
                    }
                    // Save each link in the menuLinks array for filtering with
                    // autocomplete.
                    menuLinks.push({
                      // The link text is used as value to be displayed in the
                      // input field when selected with keyboard.
                      value: element.innerText,
                      // The label is used to display the full path of the link
                      // in the autocomplete suggestions.
                      label: Drupal.checkPlain(getItemDisplayLabel(element)),
                      // The link URL is used for navigation when the item is
                      // selected.
                      linkUrl: element.href,
                    });
                  });
        // Populate the links for search results when the input is pressed.
        $searchInputs.focus(function () {
          Drupal.behaviors.adminToolbarSearch.populateLinks($self);
        });

        // Focus the search input field when the search tab is pressed.
        context.querySelector('#admin-toolbar-search-tab .toolbar-item')
          .addEventListener('click', (e) => {
            e.preventDefault();
            const searchTabTray = e.target.nextElementSibling;
            searchTabTray.classList.toggle('is-active');
            searchTabTray.querySelector('#admin-toolbar-search-input').focus();
                // When the admin toolbar tools module is enabled, support
                // loading extra links.
                if (drupalSettings.adminToolbarSearch.loadExtraLinks) {
                  // Optionally, fetch extra links through an AJAX call to the
                  // server with a specific controller returning a JSON array.
                  fetch(Drupal.url('admin/admin-toolbar-search'))
                    .then((response) => response.json())
                    .then((dataParam) => {
                      // Merge the results of the JSON call into the array of
                      // menu links so it can be searched as well.
                      dataParam.forEach((dataParamItem) => {
                        menuLinks.push({
                          value: dataParamItem.labelRaw,
                          // The full path is already provided by the controller
                          // so a call to 'getItemDisplayLabel' is not needed.
                          label: Drupal.checkPlain(dataParamItem.labelRaw),
                          linkUrl: dataParamItem.value,
                        });
                      });
    },
    getItemLabel: function (item) {
      var breadcrumbs = [];
      $(item).parents().each(function () {
        if ($(this).hasClass('menu-item')) {
          var $link = $(this).find('a:first');
          if ($link.length && !$link.hasClass('admin-toolbar-search-ignore')) {
            breadcrumbs.unshift($link.text());
          }
        }
                    });
      return breadcrumbs.join(' > ');
    },
    handleAutocomplete: function (term) {
      var $self = this;
      var keywords = term.split(" "); // Split search terms into list.

      var suggestions = [];
      $self.links.forEach(function (element) {
        var label = element.label.toLowerCase();

        // Add exact matches.
        if (label.indexOf(term.toLowerCase()) >= 0) {
          suggestions.push(element);
                }
        else {
          // Add suggestions where it matches all search terms.
          var matchCount = 0;
          keywords.forEach(function (keyword) {
            if (label.indexOf(keyword.toLowerCase()) >= 0) {
              matchCount++;
              }
            });
          if (matchCount == keywords.length) {
            suggestions.push(element);
          }
        }
          });
      return suggestions;
    },

          /**
     * Populates the links in admin toolbar search.
           * Focus the search input field when the search tab is clicked.
           *
           * Clicking on the search icon in the toolbar bar toggles the
           * visibility of the search tray and focuses the search input field
           * for immediate typing.
           */
    populateLinks: function ($self) {
      // Populate only when links array is empty (only the first time).
      if ($self.links.length === 0) {
        var getUrl = window.location;
        var baseUrl = getUrl.protocol + "//" + getUrl.host + "/";
        $('.toolbar-tray a[data-drupal-link-system-path]').each(function () {
          if (this.href !== baseUrl) {
            var label = $self.getItemLabel(this);
            $self.links.push({
              'value': this.href,
              'label': label + ' ' + this.href,
              'labelRaw': Drupal.checkPlain(label)
            });
          }
          toolbarBar
            // Attach the click event to the search tab 'span' element, since
            // the tab has no link.
            .querySelector('#admin-toolbar-search-tab .toolbar-item')
            .addEventListener('click', (e) => {
              e.preventDefault();
              // Toggle the search tray visibility.
              const searchTabTray = e.target.nextElementSibling;
              searchTabTray.classList.toggle('is-active');
              // Focus the search input field.
              searchTabTray.querySelector(searchInputID).focus();
            });
      }
        },
      );
    },
  };

})(jQuery, Drupal);
})(once, Drupal, jQuery);
+61 −8
Original line number Diff line number Diff line
@@ -8,17 +8,19 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\admin_toolbar_search\Constants\AdminToolbarSearchConstants;

/**
 * Test the keyboard shortcut functionality of Admin Toolbar Search.
 * Test the Javascript events interactions with the Admin Toolbar Search.
 *
 * Ensure the search input field is focused when the keyboard shortcut 'Alt + a'
 * is used.
 * Ensure the javascript events attached to the module are working as expected:
 * - The search input field should be focused when the keyboard shortcut
 *   'Alt + a' is used.
 * - The search input field should be focused when the search tab is clicked.
 *
 * @see admin_toolbar_search/js/admin_toolbar_search.keyboard_shortcut.js
 *
 * @group admin_toolbar
 * @group admin_toolbar_search
 */
class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase {
class AdminToolbarSearchEventsTest extends WebDriverTestBase {

  /**
   * A user with access to the Admin Toolbar Search.
@@ -77,8 +79,46 @@ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase {
   *
   * Ensure the search input field is focused when the keyboard shortcut
   * 'Alt + a' is used:
   *  - Call the standard test routine with no event, defaulting to 'keyboard':
   *    Trigger the keyboard shortcut 'Alt + a' to focus on the search input
   *    field.
   *
   * @return void
   *   Nothing to return.
   *
   * @see doTestAdminToolbarSearchEvents()
   */
  public function testAdminToolbarSearchKeyboardShortcut() {
    // Call the standard test routine with the default 'keyboard' event.
    $this->doTestAdminToolbarSearchEvents();
  }

  /**
   * Test the click event on the search tab functionality.
   *
   * Ensure the search input field is focused when the search tab is clicked:
   *  - Call the standard test routine with the 'click' event:
   *    Click on the search tab in the toolbar and focus the input field.
   *
   * @return void
   *   Nothing to return.
   *
   * @see doTestAdminToolbarSearchEvents()
   */
  public function testAdminToolbarSearchClickFocus() {
    // Call the standard test routine with the 'click' event.
    $this->doTestAdminToolbarSearchEvents('click');
  }

  /**
   * Sub routine to test the javascript events attached to the search field.
   *
   * Ensure the search input field is focused when the keyboard shortcut
   * 'Alt + a' is used or the search tab is clicked:
   * - Check that the search tray and input field are initially *not* visible.
   * - Trigger the keyboard shortcut 'Alt + a'.
   * - Trigger the specified event:
   *   - Focus, by default, with the keyboard shortcut 'Alt + a'.
   *   - Click, if specified, on the search tab in the toolbar.
   * - Check that the search tray and input field are now visible.
   * - Check that the search input field has focus.
   *
@@ -86,10 +126,15 @@ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase {
   * search input field is displayed in a tray in the toolbar, thus initially
   * not visible when the page loads.
   *
   * @param string $event
   *   The event to trigger to focus the search input field:
   *   - 'keyboard' (default): Trigger the keyboard shortcut 'Alt + a'.
   *   - 'click': Click on the search tab in the toolbar.
   *
   * @return void
   *   Nothing to return.
   */
  public function testAdminToolbarSearchKeyboardShortcut() {
  public function doTestAdminToolbarSearchEvents(string $event = 'keyboard'): void {
    // Get the current test session.
    $test_session = $this->getSession();
    // Get the current page.
@@ -102,14 +147,22 @@ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase {
    $this->assertFalse($search_tray_element->isVisible());
    $this->assertFalse($search_input_element->isVisible());

    // Trigger the keyboard shortcut 'Alt + a' to focus the search input field.
    if ($event === 'click') {
      // Click on the search tab to open the toolbar tray and focus the input
      // field.
      $page->find('css', '#' . AdminToolbarSearchConstants::ADMIN_TOOLBAR_SEARCH_HTML_IDS['search_tab'] . ' .toolbar-item')->click();
    }
    else {
      // Trigger the keyboard shortcut 'Alt + a' to focus the search input
      // field.
      $test_session->executeScript("document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 65, altKey: true }));");
    }

    // Check the search tray and input field are now visible.
    $this->assertTrue($search_tray_element->isVisible());
    $this->assertTrue($search_input_element->isVisible());

    // Check that the search input field has focus.
    // Check that the search input field *has* focus.
    $search_input_has_focus = $test_session->evaluateScript('document.activeElement.getAttribute("id") === "' . AdminToolbarSearchConstants::ADMIN_TOOLBAR_SEARCH_HTML_IDS['search_input'] . '";');
    $this->assertTrue($search_input_has_focus);
  }
+1 −1
Original line number Diff line number Diff line
@@ -185,7 +185,7 @@ class AdminToolbarToolsSearchTest extends AdminToolbarSearchTestBase {

    // Assert there is only one suggestion with a link to
    // /admin/structure/types/manage/article/fields.
    $count = count($suggestions->findAll('xpath', '//span[contains(text(), "/admin/structure/types/manage/article/fields")]'));
    $count = count($suggestions->findAll('xpath', '//a[contains(@href, "/admin/structure/types/manage/article/fields")]'));
    $this->assertEquals(1, $count);

    // Test that bundle within admin toolbar appears in search.