From 523df78fe8da4daca511c4ff006e6fe6500ec584 Mon Sep 17 00:00:00 2001 From: Arthur Baghdasaryan Date: Wed, 8 Apr 2026 15:55:40 +0400 Subject: [PATCH 1/4] =?UTF-8?q?DP-45941:=20Allow=20Editors=20to=20Referenc?= =?UTF-8?q?e=20Blocked=20Users=20in=20=E2=80=9CAuthored=20By=E2=80=9D=20Fi?= =?UTF-8?q?eld=20for=20all=20report=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 1 + composer.lock | 53 +++++++- conf/drupal/config/core.extension.yml | 1 + conf/drupal/config/user.role.editor.yml | 2 + .../src/EntityAutocompleteMatcher.php | 19 ++- .../BlockedUserAutocompleteLabelTest.php | 56 ++++++++ .../ExistingSite/BlockedAuthorsFilterTest.php | 117 +++++++++++++++++ .../BlockedAuthorsMediaEditTest.php | 121 ++++++++++++++++++ 8 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 docroot/modules/custom/mass_fields/tests/src/ExistingSite/BlockedUserAutocompleteLabelTest.php create mode 100644 docroot/modules/custom/mass_views/tests/src/ExistingSite/BlockedAuthorsFilterTest.php create mode 100644 docroot/modules/custom/mass_views/tests/src/ExistingSiteJavascript/BlockedAuthorsMediaEditTest.php diff --git a/composer.json b/composer.json index aa7c83759b..f1c782792c 100644 --- a/composer.json +++ b/composer.json @@ -260,6 +260,7 @@ "drupal/r4032login": "^2.2", "drupal/rabbit_hole": "^1.1", "drupal/redirect": "^1", + "drupal/reference_blocked_users": "^1.0", "drupal/require_on_publish": "^2.0", "drupal/scheduled_transitions": "^2.7", "drupal/schema_metatag": "^3.0", diff --git a/composer.lock b/composer.lock index 1355e9ddcb..ef5498c033 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "19af9b646f082fae687388e209a0721f", + "content-hash": "0ab6306eca11e62629a8c70c6effaab0", "packages": [ { "name": "akamai-open/edgegrid-auth", @@ -9796,6 +9796,57 @@ "source": "https://git.drupalcode.org/project/redirect" } }, + { + "name": "drupal/reference_blocked_users", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/reference_blocked_users.git", + "reference": "1.0.5" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/reference_blocked_users-1.0.5.zip", + "reference": "1.0.5", + "shasum": "f2c899496150da781a04934e082b7fd6ffe21442" + }, + "require": { + "drupal/core": "^10.1 || ^11" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "1.0.5", + "datestamp": "1737408877", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "autoload": { + "psr-4": { + "Drupal\\reference_blocked_users\\": "src" + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Alaa Haddad (alaahaddad.com)", + "homepage": "https://www.drupal.org/u/flashwebcenter", + "role": "Maintainer" + } + ], + "description": "Permission to select all active and blocked users in any entity reference user field.", + "homepage": "https://drupal.org/project/reference_blocked_users", + "support": { + "source": "https://git.drupalcode.org/project/reference_blocked_users", + "issues": "https://drupal.org/project/issues/reference_blocked_users" + } + }, { "name": "drupal/require_on_publish", "version": "2.0.0", diff --git a/conf/drupal/config/core.extension.yml b/conf/drupal/config/core.extension.yml index ea3738276b..8d98dc56e3 100644 --- a/conf/drupal/config/core.extension.yml +++ b/conf/drupal/config/core.extension.yml @@ -211,6 +211,7 @@ module: rabbit_hole: 0 redirect: 0 redirect_404: 0 + reference_blocked_users: 0 require_on_publish: 0 responsive_image: 0 rest: 0 diff --git a/conf/drupal/config/user.role.editor.yml b/conf/drupal/config/user.role.editor.yml index 9e651432e1..a6eebb5c4c 100644 --- a/conf/drupal/config/user.role.editor.yml +++ b/conf/drupal/config/user.role.editor.yml @@ -66,6 +66,7 @@ dependencies: - node - quick_node_clone - rabbit_hole + - reference_blocked_users - scheduled_transitions - system - taxonomy @@ -239,6 +240,7 @@ permissions: - 'rabbit hole bypass node' - 'rebuild node access permissions' - 'reorder layout paragraphs components' + - 'reference blocked users' - 'reschedule scheduled transitions media document' - 'reschedule scheduled transitions node advisory' - 'reschedule scheduled transitions node alert' diff --git a/docroot/modules/custom/mass_fields/src/EntityAutocompleteMatcher.php b/docroot/modules/custom/mass_fields/src/EntityAutocompleteMatcher.php index a10c8950fc..6b65350bdd 100644 --- a/docroot/modules/custom/mass_fields/src/EntityAutocompleteMatcher.php +++ b/docroot/modules/custom/mass_fields/src/EntityAutocompleteMatcher.php @@ -4,9 +4,11 @@ use Drupal\Component\Utility\Html; use Drupal\Component\Utility\Tags; +use Drupal\Core\Entity\EntityPublishedInterface; use Drupal\Core\Entity\EntityAutocompleteMatcher as DefaultAutocompleteMatcher; use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\user\UserInterface; /** * Matcher class to get autocompletion results for entity reference. @@ -87,15 +89,24 @@ public function getMatches($target_type, $selection_handler, $selection_settings else { $key = "$label ($entity_id)"; } - // Strip troublesome characters like starting/trailing white spaces, line breaks and tags. - $key = preg_replace('/\s\s+/', ' ', str_replace("\n", '', trim(Html::decodeEntities(strip_tags($key))))); + // Strip extra spaces, line breaks, and tags. + $key = preg_replace( + '/\s\s+/', + ' ', + str_replace("\n", '', trim(Html::decodeEntities(strip_tags($key)))) + ); // Names containing commas or quotes must be wrapped in quotes. $key = Tags::encode($key); - $entity = \Drupal::entityTypeManager()->getStorage($target_type)->load($entity_id); + $entity = $this->entityTypeManager + ->getStorage($target_type) + ->load($entity_id); if ($entity) { - if ($entity->getEntityType()->id() == 'node' && !$entity->isPublished()) { + if ($entity instanceof EntityPublishedInterface && !$entity->isPublished()) { $label .= " (unpublished)"; } + if ($entity instanceof UserInterface && !$entity->isActive()) { + $label .= " (blocked)"; + } } $matches[] = [ 'value' => $key, diff --git a/docroot/modules/custom/mass_fields/tests/src/ExistingSite/BlockedUserAutocompleteLabelTest.php b/docroot/modules/custom/mass_fields/tests/src/ExistingSite/BlockedUserAutocompleteLabelTest.php new file mode 100644 index 0000000000..e893305e44 --- /dev/null +++ b/docroot/modules/custom/mass_fields/tests/src/ExistingSite/BlockedUserAutocompleteLabelTest.php @@ -0,0 +1,56 @@ +randomMachineName(6); + + // Step 1: Create an author and content authored by that user. + $author_user = $this->createUser([], $name_prefix . '_author'); + $author_user->activate(); + $author_user->save(); + + $node = $this->createNode([ + 'type' => 'info_details', + 'title' => 'Blocked author coverage ' . $name_prefix, + 'uid' => $author_user->id(), + 'field_info_detail_overview' => '

Autocomplete coverage body

', + ]); + $this->assertEquals($author_user->id(), (int) $node->getOwnerId()); + + // Step 2: Block that author user. + $author_user->block(); + $author_user->save(); + + // Step 3: Create another user who searches in autocomplete. + $editor_user = $this->createUser(['reference blocked users']); + $this->drupalLogin($editor_user); + + // Additional active account with similar prefix for mixed results. + $active_user = $this->createUser([], $name_prefix . '_active'); + $active_user->activate(); + $active_user->save(); + + $matcher = \Drupal::service('mass_fields.autocomplete_matcher'); + $matches = $matcher->getMatches('user', 'default', [], $name_prefix); + + $labels = array_column($matches, 'label'); + $labels_as_string = implode("\n", $labels); + + $this->assertStringContainsString($active_user->getDisplayName(), $labels_as_string); + $this->assertStringContainsString($author_user->getDisplayName() . ' (blocked)', $labels_as_string); + } + +} diff --git a/docroot/modules/custom/mass_views/tests/src/ExistingSite/BlockedAuthorsFilterTest.php b/docroot/modules/custom/mass_views/tests/src/ExistingSite/BlockedAuthorsFilterTest.php new file mode 100644 index 0000000000..cd5ad3abc8 --- /dev/null +++ b/docroot/modules/custom/mass_views/tests/src/ExistingSite/BlockedAuthorsFilterTest.php @@ -0,0 +1,117 @@ +assertNotNull($editor_role); + $this->assertTrue( + $editor_role->hasPermission('reference blocked users'), + 'Editor role must include permission to reference blocked users.' + ); + + $author = $this->createUser([], 'blocked_author_' . $this->randomMachineName(6)); + $author->activate(); + $author->save(); + + $active_author = $this->createUser([], 'active_author_' . $this->randomMachineName(6)); + $active_author->activate(); + $active_author->save(); + + $this->createDocumentMediaForAuthor( + 'Blocked author document ' . $this->randomMachineName(6), + (int) $author->id() + ); + $active_author_title = 'Active author document ' . $this->randomMachineName(6); + $editable_media_id = $this->createDocumentMediaForAuthor( + $active_author_title, + (int) $active_author->id() + ); + + // Simulate former employee account lifecycle. + $author->block(); + $author->save(); + + $editor = $this->createUser([], 'editor_user_' . $this->randomMachineName(6)); + $editor->addRole('editor'); + $editor->save(); + $this->drupalLogin($editor); + + $this->drupalGet('admin/ma-dash/documents'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->fieldExists('edit-uid'); + + // Filter by blocked author in exposed "Authored by" filter. + $this->submitForm( + [ + 'uid' => $author->getAccountName() . ' (' . $author->id() . ')', + ], + 'Filter', + 'views-exposed-form-all-documents-page-1' + ); + + $this->assertSession()->pageTextContains('Blocked author document'); + $this->assertSession()->pageTextNotContains($active_author_title); + + // Also verify active users are still referenceable in the same field. + $this->submitForm( + [ + 'uid' => $active_author->getAccountName() . ' (' . $active_author->id() . ')', + ], + 'Filter', + 'views-exposed-form-all-documents-page-1' + ); + + $this->assertSession()->pageTextContains($active_author_title); + $this->assertSession()->pageTextNotContains('Blocked author document'); + + // Keep one editable media id for JS workflow coverage in companion test. + $this->assertGreaterThan(0, $editable_media_id); + } + + /** + * Creates a published document media item for a specific author. + */ + private function createDocumentMediaForAuthor(string $title, int $author_id): int { + $destination = 'public://' . $this->randomMachineName(12) . '.txt'; + $file = File::create([ + 'uri' => $destination, + ]); + $file->setPermanent(); + $file->save(); + + $src = 'core/tests/Drupal/Tests/Component/FileCache/Fixtures/llama-23.txt'; + \Drupal::service('file_system')->copy($src, $destination, TRUE); + + $media = $this->createMedia([ + 'bundle' => 'document', + 'title' => $title, + 'field_title' => $title, + 'uid' => $author_id, + 'field_upload_file' => [ + 'target_id' => $file->id(), + ], + 'status' => 1, + ]); + + return (int) $media->id(); + } + +} diff --git a/docroot/modules/custom/mass_views/tests/src/ExistingSiteJavascript/BlockedAuthorsMediaEditTest.php b/docroot/modules/custom/mass_views/tests/src/ExistingSiteJavascript/BlockedAuthorsMediaEditTest.php new file mode 100644 index 0000000000..32ab7ee144 --- /dev/null +++ b/docroot/modules/custom/mass_views/tests/src/ExistingSiteJavascript/BlockedAuthorsMediaEditTest.php @@ -0,0 +1,121 @@ +createUser([], 'blocked_author_' . $this->randomMachineName(6)); + $blocked_author->activate(); + $blocked_author->save(); + $blocked_author->block(); + $blocked_author->save(); + + $active_author = $this->createUser([], 'active_author_' . $this->randomMachineName(6)); + $active_author->activate(); + $active_author->save(); + + $editor = $this->createUser([], 'editor_user_' . $this->randomMachineName(6)); + $editor->addRole('editor'); + $editor->save(); + + $editable_media_id = $this->createDocumentMediaForAuthor( + 'Manual authored-by media ' . $this->randomMachineName(6), + (int) $active_author->id() + ); + + $this->drupalLogin($editor); + + $this->drupalGet("media/$editable_media_id/edit"); + $this->selectAutocompleteAuthor( + 'edit-uid-0-target-id', + $blocked_author->getAccountName(), + $blocked_author->getAccountName() + ); + $this->getSession()->getPage()->pressButton('Save'); + $this->assertSession()->fieldValueEquals( + 'edit-uid-0-target-id', + $blocked_author->getAccountName() . ' (' . $blocked_author->id() . ')' + ); + + $this->drupalGet("media/$editable_media_id/edit"); + $this->selectAutocompleteAuthor( + 'edit-uid-0-target-id', + $active_author->getAccountName(), + $active_author->getAccountName() + ); + $this->getSession()->getPage()->pressButton('Save'); + $this->assertSession()->fieldValueEquals( + 'edit-uid-0-target-id', + $active_author->getAccountName() . ' (' . $active_author->id() . ')' + ); + } + + /** + * Selects an autocomplete option by typing and clicking dropdown item. + */ + private function selectAutocompleteAuthor(string $field_id, string $search, string $option_text): void { + $field = $this->assertSession()->fieldExists($field_id); + $field->setValue($search); + + $escaped = json_encode($option_text); + $this->getSession()->wait( + 8000, + "document.querySelectorAll('ul.ui-autocomplete li').length > 0" + ); + $this->getSession()->wait( + 8000, + "Array.from(document.querySelectorAll('ul.ui-autocomplete li')).some(li => li.textContent.includes($escaped))" + ); + + $option = $this->getSession()->getPage()->find( + 'xpath', + "//ul[contains(@class, 'ui-autocomplete')]//li[contains(., \"$option_text\")]" + ); + $this->assertNotNull($option); + $option->click(); + } + + /** + * Creates a published document media item for a specific author. + */ + private function createDocumentMediaForAuthor(string $title, int $author_id): int { + $destination = 'public://' . $this->randomMachineName(12) . '.txt'; + $file = File::create([ + 'uri' => $destination, + ]); + $file->setPermanent(); + $file->save(); + + $src = 'core/tests/Drupal/Tests/Component/FileCache/Fixtures/llama-23.txt'; + \Drupal::service('file_system')->copy($src, $destination, TRUE); + + $media = $this->createMedia([ + 'bundle' => 'document', + 'title' => $title, + 'field_title' => $title, + 'uid' => $author_id, + 'field_upload_file' => [ + 'target_id' => $file->id(), + ], + 'status' => 1, + ]); + + return (int) $media->id(); + } + +} From cc79ebe75d6138c259d0ae2e4c9e6e01c4c629dc Mon Sep 17 00:00:00 2001 From: Arthur Baghdasaryan Date: Wed, 8 Apr 2026 15:56:59 +0400 Subject: [PATCH 2/4] =?UTF-8?q?DP-45941:=20Allow=20Editors=20to=20Referenc?= =?UTF-8?q?e=20Blocked=20Users=20in=20=E2=80=9CAuthored=20By=E2=80=9D=20Fi?= =?UTF-8?q?eld=20for=20all=20report=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelogs/DP-45941.yml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 changelogs/DP-45941.yml diff --git a/changelogs/DP-45941.yml b/changelogs/DP-45941.yml new file mode 100644 index 0000000000..698d538878 --- /dev/null +++ b/changelogs/DP-45941.yml @@ -0,0 +1,41 @@ +# +# Write your changelog entry here. Every pull request must have a changelog yml file. +# +# Change types: +# ############################################################################# +# You can use one of the following types: +# - Added: For new features. +# - Changed: For changes to existing functionality. +# - Deprecated: For soon-to-be removed features. +# - Removed: For removed features. +# - Fixed: For any bug fixes. +# - Security: In case of vulnerabilities. +# +# Format +# ############################################################################# +# The format is crucial. Please follow the examples below. For reference, the requirements are: +# - All 3 parts are required and you must include "Type", "description" and "issue". +# - "Type" must be left aligned and followed by a colon. +# - "description" must be indented with 2 spaces followed by a colon +# - "issue" must be indented with 4 spaces followed by a colon. +# - "issue" is for the Jira ticket number only e.g. DP-1234 +# - No extra spaces, indents, or blank lines are allowed. +# +# Example: +# ############################################################################# +# Fixed: +# - description: Fixes scrolling on edit pages in Safari. +# issue: DP-13314 +# +# You may add more than 1 description & issue for each type using the following format: +# Changed: +# - description: Automating the release branch. +# issue: DP-10166 +# - description: Second change item that needs a description. +# issue: DP-19875 +# - description: Third change item that needs a description along with an issue. +# issue: DP-19843 +# +Changed: + - description: Allow Editors to Reference Blocked Users in “Authored By” Field for all report filters. + issue: DP-45941 From 388cb2262b8534cb53fa6d1d2cb8a98c84cb897b Mon Sep 17 00:00:00 2001 From: Arthur Baghdasaryan Date: Thu, 9 Apr 2026 10:01:12 +0400 Subject: [PATCH 3/4] Export config --- conf/drupal/config/user.role.editor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/drupal/config/user.role.editor.yml b/conf/drupal/config/user.role.editor.yml index a6eebb5c4c..524d82f8f2 100644 --- a/conf/drupal/config/user.role.editor.yml +++ b/conf/drupal/config/user.role.editor.yml @@ -239,8 +239,8 @@ permissions: - 'rabbit hole administer node' - 'rabbit hole bypass node' - 'rebuild node access permissions' - - 'reorder layout paragraphs components' - 'reference blocked users' + - 'reorder layout paragraphs components' - 'reschedule scheduled transitions media document' - 'reschedule scheduled transitions node advisory' - 'reschedule scheduled transitions node alert' From 9fd6468d3d449d1d4edd097b119a000328a1212a Mon Sep 17 00:00:00 2001 From: Arthur Baghdasaryan Date: Mon, 13 Apr 2026 10:38:49 +0400 Subject: [PATCH 4/4] Test fix --- .../BlockedAuthorsMediaEditTest.php | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/docroot/modules/custom/mass_views/tests/src/ExistingSiteJavascript/BlockedAuthorsMediaEditTest.php b/docroot/modules/custom/mass_views/tests/src/ExistingSiteJavascript/BlockedAuthorsMediaEditTest.php index 32ab7ee144..c3f2272ae3 100644 --- a/docroot/modules/custom/mass_views/tests/src/ExistingSiteJavascript/BlockedAuthorsMediaEditTest.php +++ b/docroot/modules/custom/mass_views/tests/src/ExistingSiteJavascript/BlockedAuthorsMediaEditTest.php @@ -49,7 +49,7 @@ public function testEditorCanChangeMediaAuthorToBlockedAndActive(): void { $this->getSession()->getPage()->pressButton('Save'); $this->assertSession()->fieldValueEquals( 'edit-uid-0-target-id', - $blocked_author->getAccountName() . ' (' . $blocked_author->id() . ')' + $blocked_author->getAccountName() . ' (' . $blocked_author->id() . ') - User' ); $this->drupalGet("media/$editable_media_id/edit"); @@ -61,7 +61,7 @@ public function testEditorCanChangeMediaAuthorToBlockedAndActive(): void { $this->getSession()->getPage()->pressButton('Save'); $this->assertSession()->fieldValueEquals( 'edit-uid-0-target-id', - $active_author->getAccountName() . ' (' . $active_author->id() . ')' + $active_author->getAccountName() . ' (' . $active_author->id() . ') - User' ); } @@ -70,7 +70,41 @@ public function testEditorCanChangeMediaAuthorToBlockedAndActive(): void { */ private function selectAutocompleteAuthor(string $field_id, string $search, string $option_text): void { $field = $this->assertSession()->fieldExists($field_id); - $field->setValue($search); + if (!$field->isVisible()) { + $this->getSession()->executeScript(sprintf( + "(function (fieldId) { + const input = document.getElementById(fieldId); + if (!input) { + return; + } + const details = input.closest('details'); + if (details) { + details.open = true; + } + })(%s);", + json_encode($field_id) + )); + $this->getSession()->wait( + 8000, + "(() => { const el = document.getElementById(" . json_encode($field_id) . "); return !!el && el.offsetParent !== null; })()" + ); + $field = $this->assertSession()->fieldExists($field_id); + } + $this->getSession()->executeScript(sprintf( + "(function (fieldId, searchTerm) { + const input = document.getElementById(fieldId); + if (!input) { + return; + } + input.focus(); + input.value = searchTerm; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); + input.dispatchEvent(new KeyboardEvent('keyup', { key: 'a', bubbles: true })); + })(%s, %s);", + json_encode($field_id), + json_encode($search) + )); $escaped = json_encode($option_text); $this->getSession()->wait( @@ -84,7 +118,7 @@ private function selectAutocompleteAuthor(string $field_id, string $search, stri $option = $this->getSession()->getPage()->find( 'xpath', - "//ul[contains(@class, 'ui-autocomplete')]//li[contains(., \"$option_text\")]" + "(//ul[contains(@class, 'ui-autocomplete')]//li)[1]" ); $this->assertNotNull($option); $option->click();