From b1eddcbb575a5b18d5cc13d7838251519c3083de Mon Sep 17 00:00:00 2001 From: Lauri Eskola <lauri.eskola@acquia.com> Date: Sun, 20 Aug 2023 23:24:29 +0300 Subject: [PATCH] =?UTF-8?q?Issue=20#229193=20by=20dmitrig01,=20narendraR,?= =?UTF-8?q?=20katbailey,=20k4v,=20Gurpartap=20Singh,=20anavarre,=20kkaefer?= =?UTF-8?q?,=20chx,=20G=C3=A1bor=20Hojtsy,=20jrockowitz,=20Bojhan,=20catch?= =?UTF-8?q?,=20Kiphaas7,=20corey.aufang,=20webchick,=20Dries,=20Senpai,=20?= =?UTF-8?q?smustgrave,=20anders.fajerson,=20larowlan:=20Incremental=20filt?= =?UTF-8?q?er=20for=20permissions=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/src/Form/UserPermissionsForm.php | 23 +++- .../PermissionFilterTest.php | 96 +++++++++++++++ core/modules/user/user.libraries.yml | 2 + core/modules/user/user.permissions.js | 116 +++++++++++++++++- core/themes/claro/claro.theme | 13 ++ .../css/components/system-admin--modules.css | 9 +- .../components/system-admin--modules.pcss.css | 4 +- 7 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php diff --git a/core/modules/user/src/Form/UserPermissionsForm.php b/core/modules/user/src/Form/UserPermissionsForm.php index 49b8511e8ff5..d1e9b9932657 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 000000000000..b98e2448eb9f --- /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 2832a9213463..5b24ea8fceff 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 ae22cbb5e3d9..19a56ae1dce0 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 d174b632561c..0e53735e32ee 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 29385332e119..55d91a507ede 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 b25a543c3faf..da7b5aafe7b9 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); -- GitLab