diff --git a/core/modules/user/src/Form/UserPermissionsForm.php b/core/modules/user/src/Form/UserPermissionsForm.php index 49b8511e8ff568f902b06ceb4725b6a8c70240b5..d1e9b9932657d17ddd33278ea7d4ecc29edfcb49 100644 --- a/core/modules/user/src/Form/UserPermissionsForm.php +++ b/core/modules/user/src/Form/UserPermissionsForm.php @@ -142,6 +142,27 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#type' => 'system_compact_link', ]; + $form['filters'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['table-filter', 'js-show'], + ], + ]; + + $form['filters']['text'] = [ + '#type' => 'search', + '#title' => $this->t('Filter permissions'), + '#title_display' => 'invisible', + '#size' => 30, + '#placeholder' => $this->t('Filter by permission name'), + '#description' => $this->t('Enter permission name'), + '#attributes' => [ + 'class' => ['table-filter-text'], + 'data-table' => '#permissions', + 'autocomplete' => 'off', + ], + ]; + $form['permissions'] = [ '#type' => 'table', '#header' => [$this->t('Permission')], @@ -177,7 +198,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { ]; $form['permissions'][$perm]['description'] = [ '#type' => 'inline_template', - '#template' => '<div class="permission"><span class="title">{{ title }}</span>{% if description or warning %}<div class="description">{% if warning %}<em class="permission-warning">{{ warning }}</em> {% endif %}{{ description }}</div>{% endif %}</div>', + '#template' => '<div class="permission"><span class="title table-filter-text-source">{{ title }}</span>{% if description or warning %}<div class="description">{% if warning %}<em class="permission-warning">{{ warning }}</em> {% endif %}{{ description }}</div>{% endif %}</div>', '#context' => [ 'title' => $perm_item['title'], ], diff --git a/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php b/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b98e2448eb9fd19d55745a877cd9e389d9b29b5e --- /dev/null +++ b/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php @@ -0,0 +1,96 @@ +<?php + +namespace Drupal\Tests\user\FunctionalJavascript; + +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; + +/** + * Tests the JavaScript functionality of the permission filter. + * + * @group user + */ +class PermissionFilterTest extends WebDriverTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['user', 'system']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $admin_user = $this->drupalCreateUser([ + 'administer permissions', + ]); + $this->drupalLogin($admin_user); + } + + /** + * Tests that filter results announcement has correct pluralization. + */ + public function testPermissionFilter() { + // Find the permission filter field. + $this->drupalGet('admin/people/permissions'); + $assertSession = $this->assertSession(); + $session = $this->getSession(); + $page = $session->getPage(); + + $filter = $page->findField('edit-text'); + + // Get all permission rows, for assertions later. + $permission_rows = $page->findAll('css', 'tbody tr td .permission'); + + // Administer filter reduces the number of visible rows. + $filter->setValue('Administer'); + $session->wait(1000, "jQuery('tr[data-drupal-selector=\"edit-permissions-access-content\"]').length == 0"); + $visible_rows = $this->filterVisibleElements($permission_rows); + // Test Drupal.announce() message when multiple matches are expected. + $expected_message = count($visible_rows) . ' permissions are available in the modified list.'; + $assertSession->elementTextContains('css', '#drupal-live-announce', $expected_message); + self::assertGreaterThan(count($visible_rows), count($permission_rows)); + self::assertGreaterThan(1, count($visible_rows)); + + // Test Drupal.announce() message when one match is expected. + // Using a very specific permission name, we expect only one row. + $filter->setValue('Administer site configuration'); + $session->wait(1000, "jQuery('tr[data-drupal-selector=\"edit-permissions-access-content\"]').length == 0"); + $visible_rows = $this->filterVisibleElements($permission_rows); + self::assertEquals(1, count($visible_rows)); + $expected_message = '1 permission is available in the modified list.'; + $assertSession->elementTextContains('css', '#drupal-live-announce', $expected_message); + + // Test Drupal.announce() message when no matches are expected. + $filter->setValue('Pan-Galactic Gargle Blaster'); + $session->wait(1000, "jQuery('tr[data-drupal-selector=\"edit-permissions-access-content\"]').length == 0"); + $visible_rows = $this->filterVisibleElements($permission_rows); + self::assertEquals(0, count($visible_rows)); + + $expected_message = '0 permissions are available in the modified list.'; + $assertSession->elementTextContains('css', '#drupal-live-announce', $expected_message); + } + + /** + * Removes any non-visible elements from the passed array. + * + * @param \Behat\Mink\Element\NodeElement[] $elements + * An array of node elements. + * + * @return \Behat\Mink\Element\NodeElement[] + * An array of node elements. + */ + protected function filterVisibleElements(array $elements): array { + $elements = array_filter($elements, function ($element) { + return $element->isVisible(); + }); + return $elements; + } + +} diff --git a/core/modules/user/user.libraries.yml b/core/modules/user/user.libraries.yml index 2832a9213463d2c5b8ce99466abfc1f2ccc75ec3..5b24ea8fceff00b03cbaa525cbbbab314495a2ca 100644 --- a/core/modules/user/user.libraries.yml +++ b/core/modules/user/user.libraries.yml @@ -28,6 +28,8 @@ drupal.user.permissions: - core/drupalSettings - user/drupal.user.admin - core/drupal.checkbox + - core/drupal.debounce + - core/drupal.announce drupal.user.icons: version: VERSION diff --git a/core/modules/user/user.permissions.js b/core/modules/user/user.permissions.js index ae22cbb5e3d927d0f75b751c1c5badc571805450..19a56ae1dce0b905a7290266abd88444ad63cbd8 100644 --- a/core/modules/user/user.permissions.js +++ b/core/modules/user/user.permissions.js @@ -3,7 +3,7 @@ * User permission page behaviors. */ -(function ($, Drupal) { +(function ($, Drupal, debounce) { /** * Shows checked and disabled checkboxes for inherited permissions. * @@ -89,4 +89,116 @@ }); }, }; -})(jQuery, Drupal); + + /** + * Filters the permission list table by a text input search string. + * + * Text search input: input.table-filter-text + * Target table: input.table-filter-text[data-table] + * Source text: .table-filter-text-source + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.tableFilterByText = { + attach(context, settings) { + const [input] = once('table-filter-text', 'input.table-filter-text'); + if (!input) { + return; + } + const tableSelector = input.getAttribute('data-table'); + const $table = $(tableSelector); + const $rows = $table.find('tbody tr'); + + function hideEmptyPermissionHeader(index, row) { + const tdsWithModuleClass = row.querySelectorAll('td.module'); + // Function to check if an element is visible (`display: block`). + function isVisible(element) { + return getComputedStyle(element).display !== 'none'; + } + if (tdsWithModuleClass.length > 0) { + // Find the next visible sibling `<tr>`. + let nextVisibleSibling = row.nextElementSibling; + while (nextVisibleSibling && !isVisible(nextVisibleSibling)) { + nextVisibleSibling = nextVisibleSibling.nextElementSibling; + } + + // Check if the next visible sibling has the "module" class in any of + // its `<td>` elements. + let nextVisibleSiblingHasModuleClass = false; + if (nextVisibleSibling) { + const nextSiblingTdsWithModuleClass = + nextVisibleSibling.querySelectorAll('td.module'); + nextVisibleSiblingHasModuleClass = + nextSiblingTdsWithModuleClass.length > 0; + } + + // Check if this is the last visible row with class "module". + const isLastVisibleModuleRow = + !nextVisibleSibling || !isVisible(nextVisibleSibling); + + // Hide the current row with class "module" if it meets the + // conditions. + $(row).toggle( + !nextVisibleSiblingHasModuleClass && !isLastVisibleModuleRow, + ); + } + } + + function filterPermissionList(e) { + const query = e.target.value; + if (query.length === 0) { + // Reset table when the textbox is cleared. + $rows.show(); + } + // Case insensitive expression to find query at the beginning of a word. + const re = new RegExp(`\\b${query}`, 'i'); + + function showPermissionRow(index, row) { + const sources = row.querySelectorAll('.table-filter-text-source'); + if (sources.length > 0) { + const textMatch = sources[0].textContent.search(re) !== -1; + $(row).closest('tr').toggle(textMatch); + } + } + // Search over all rows. + $rows.show(); + + // Filter if the length of the query is at least 2 characters. + if (query.length >= 2) { + $rows.each(showPermissionRow); + + // Hide the empty header if they don't have any visible rows. + const visibleRows = $table.find('tbody tr:visible'); + visibleRows.each(hideEmptyPermissionHeader); + const rowsWithoutEmptyModuleName = $table.find('tbody tr:visible'); + // Find elements with class "permission" within visible rows. + const tdsWithModuleOrPermissionClass = + rowsWithoutEmptyModuleName.find('.permission'); + + Drupal.announce( + Drupal.formatPlural( + tdsWithModuleOrPermissionClass.length, + '1 permission is available in the modified list.', + '@count permissions are available in the modified list.', + ), + ); + } + } + + function preventEnterKey(event) { + if (event.which === 13) { + event.preventDefault(); + event.stopPropagation(); + } + } + + if ($table.length) { + $(input).on({ + keyup: debounce(filterPermissionList, 200), + click: debounce(filterPermissionList, 200), + keydown: preventEnterKey, + }); + } + }, + }; +})(jQuery, Drupal, Drupal.debounce); diff --git a/core/themes/claro/claro.theme b/core/themes/claro/claro.theme index d174b632561c9c54daf725782d607a9d75aa7a78..0e53735e32ee3b1ef2f3c5ca2a7c5bb97e3fc814 100644 --- a/core/themes/claro/claro.theme +++ b/core/themes/claro/claro.theme @@ -1770,3 +1770,16 @@ function claro_system_module_invoked_theme_registry_alter(array &$theme_registry } } } + +/** + * Implements hook_form_FORM_ID_alter() for the user_admin_permissions form. + */ +function claro_form_user_admin_permissions_alter(&$form, FormStateInterface $form_state) { + if (isset($form['filters'])) { + $form['filters']['#attributes']['class'][] = 'permissions-table-filter'; + if (isset($form['filters']['text'])) { + unset($form['filters']['text']['#title_display']); + $form['filters']['text']['#title'] = t('Filter'); + } + } +} diff --git a/core/themes/claro/css/components/system-admin--modules.css b/core/themes/claro/css/components/system-admin--modules.css index 29385332e1195bd71b9b0bf8a7a42363850af814..55d91a507ede4a9c051bd98024ac4b1445a747ca 100644 --- a/core/themes/claro/css/components/system-admin--modules.css +++ b/core/themes/claro/css/components/system-admin--modules.css @@ -15,7 +15,8 @@ --module-table-cell-padding-horizontal: calc(var(--space-m) - (var(--input-border-size) * 2)); } -.modules-table-filter { +.modules-table-filter, +.permissions-table-filter { padding: 0.25rem var(--space-l); border: 1px solid var(--color-gray-200); border-radius: 2px 2px 0 0; @@ -24,11 +25,13 @@ /* Visually hide the module filter input description. */ -.modules-table-filter .form-item__description { +.modules-table-filter .form-item__description, +.permissions-table-filter .form-item__description { position: absolute !important; } -.modules-table-filter .form-item__description { +.modules-table-filter .form-item__description, +.permissions-table-filter .form-item__description { overflow: hidden; clip: rect(1px, 1px, 1px, 1px); width: 1px; diff --git a/core/themes/claro/css/components/system-admin--modules.pcss.css b/core/themes/claro/css/components/system-admin--modules.pcss.css index b25a543c3faf2fe46e93e43720644e13751635dc..da7b5aafe7b96005cdb4451b8f2dfae3ad69c94e 100644 --- a/core/themes/claro/css/components/system-admin--modules.pcss.css +++ b/core/themes/claro/css/components/system-admin--modules.pcss.css @@ -8,7 +8,7 @@ --module-table-cell-padding-horizontal: calc(var(--space-m) - (var(--input-border-size) * 2)); } -.modules-table-filter { +.modules-table-filter, .permissions-table-filter { padding: 0.25rem var(--space-l); border: 1px solid var(--color-gray-200); border-radius: 2px 2px 0 0; @@ -16,7 +16,7 @@ } /* Visually hide the module filter input description. */ -.modules-table-filter .form-item__description { +.modules-table-filter .form-item__description, .permissions-table-filter .form-item__description { position: absolute !important; overflow: hidden; clip: rect(1px, 1px, 1px, 1px);