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 diff --git a/composer.json b/composer.json index 8eb55fc173..c40447f32e 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 e49be31450..c6de440a9f 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": "647812e9d2be3f7a8cc0e8b848f2dd8d", + "content-hash": "24a015b7f95fd29055ec182dcc6d789d", "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", @@ -12022,12 +12073,12 @@ "version": "v7.0.2", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", + "url": "https://github.com/googleapis/php-jwt.git", "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", "shasum": "" }, 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..524d82f8f2 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 @@ -238,6 +239,7 @@ permissions: - 'rabbit hole administer node' - 'rabbit hole bypass node' - 'rebuild node access permissions' + - 'reference blocked users' - 'reorder layout paragraphs components' - 'reschedule scheduled transitions media document' - 'reschedule scheduled transitions node advisory' 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..c3f2272ae3 --- /dev/null +++ b/docroot/modules/custom/mass_views/tests/src/ExistingSiteJavascript/BlockedAuthorsMediaEditTest.php @@ -0,0 +1,155 @@ +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() . ') - User' + ); + + $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() . ') - User' + ); + } + + /** + * 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); + 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( + 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)[1]" + ); + $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(); + } + +}