diff options
Diffstat (limited to 'core/modules')
208 files changed, 3244 insertions, 1523 deletions
diff --git a/core/modules/block/block.module b/core/modules/block/block.module index 94a2cb9fc7a..24e28589491 100644 --- a/core/modules/block/block.module +++ b/core/modules/block/block.module @@ -16,6 +16,10 @@ use Drupal\Core\Installer\InstallerKernel; * @see block_modules_installed() */ function block_themes_installed($theme_list): void { + // Do not create blocks during config sync. + if (\Drupal::service('config.installer')->isSyncing()) { + return; + } // Disable this functionality prior to install profile installation because // block configuration is often optional or provided by the install profile // itself. block_theme_initialize() will be called when the install profile is diff --git a/core/modules/block/migrations/d6_block.yml b/core/modules/block/migrations/d6_block.yml index 74922444e8d..853ce28a47b 100644 --- a/core/modules/block/migrations/d6_block.yml +++ b/core/modules/block/migrations/d6_block.yml @@ -56,8 +56,6 @@ process: 1: forum_new_block locale: 0: language_block - node: - 0: node_syndicate_block search: 0: search_form_block statistics: diff --git a/core/modules/block/migrations/d7_block.yml b/core/modules/block/migrations/d7_block.yml index 9b031b7daa7..35c6f23d86f 100644 --- a/core/modules/block/migrations/d7_block.yml +++ b/core/modules/block/migrations/d7_block.yml @@ -59,8 +59,6 @@ process: new: forum_new_block # locale: # 0: language_block - node: - syndicate: node_syndicate_block search: form: search_form_block statistics: diff --git a/core/modules/block/src/Hook/BlockHooks.php b/core/modules/block/src/Hook/BlockHooks.php index 657109309a3..802a60bccb1 100644 --- a/core/modules/block/src/Hook/BlockHooks.php +++ b/core/modules/block/src/Hook/BlockHooks.php @@ -151,7 +151,12 @@ class BlockHooks { * @see block_themes_installed() */ #[Hook('modules_installed')] - public function modulesInstalled($modules): void { + public function modulesInstalled($modules, bool $is_syncing): void { + // Do not create blocks during config sync. + if ($is_syncing) { + return; + } + // block_themes_installed() does not call block_theme_initialize() during // site installation because block configuration can be optional or provided // by the profile. Now, when the profile is installed, this configuration diff --git a/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php b/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php index 8b2ead48eda..6305ab7f841 100644 --- a/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php +++ b/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php @@ -65,6 +65,10 @@ class BlockConfigSchemaTest extends KernelTestBase { */ public function testBlockConfigSchema(): void { foreach ($this->blockManager->getDefinitions() as $block_id => $definition) { + // Skip the syndicate block as it is deprecated. + if ($block_id === 'node_syndicate_block') { + continue; + } $id = $this->randomMachineName(); $block = Block::create([ 'id' => $id, diff --git a/core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php b/core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php new file mode 100644 index 00000000000..80e3f798342 --- /dev/null +++ b/core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\block\Kernel; + +use Drupal\Core\Config\ConfigInstallerInterface; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Extension\ThemeInstallerInterface; +use Drupal\KernelTests\KernelTestBase; +use Drupal\block\Entity\Block; + +/** + * Tests that blocks are not created during config sync. + * + * @group block + */ +class BlockConfigSyncTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'system']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + \Drupal::service(ThemeInstallerInterface::class) + ->install(['stark', 'claro']); + + // Delete all existing blocks. + foreach (Block::loadMultiple() as $block) { + $block->delete(); + } + + // Set the default theme. + $this->config('system.theme') + ->set('default', 'stark') + ->save(); + + // Create a block for the default theme to be copied later. + Block::create([ + 'id' => 'test_block', + 'plugin' => 'system_powered_by_block', + 'region' => 'content', + 'theme' => 'stark', + ])->save(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + $container->setParameter('install_profile', 'testing'); + } + + /** + * Tests blocks are not created during config sync. + * + * @param bool $syncing + * Whether or not config is syncing when the hook is invoked. + * @param string|null $expected_block_id + * The expected ID of the block that should be created, or NULL if no block + * should be created. + * + * @testWith [true, null] + * [false, "claro_test_block"] + */ + public function testNoBlocksCreatedDuringConfigSync(bool $syncing, ?string $expected_block_id): void { + \Drupal::service(ConfigInstallerInterface::class) + ->setSyncing($syncing); + + // Invoke the hook that should skip block creation due to config sync. + \Drupal::moduleHandler()->invoke('block', 'themes_installed', [['claro']]); + // This should hold true if the "current" install profile triggers an + // invocation of hook_modules_installed(). + \Drupal::moduleHandler()->invoke('block', 'modules_installed', [['testing'], $syncing]); + + $this->assertSame($expected_block_id, Block::load('claro_test_block')?->id()); + } + +} diff --git a/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php b/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php index 3f20b2148b8..dc96d95e699 100644 --- a/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php +++ b/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php @@ -100,7 +100,7 @@ class MigrateBlockTest extends MigrateDrupal6TestBase { */ public function testBlockMigration(): void { $blocks = Block::loadMultiple(); - $this->assertCount(25, $blocks); + $this->assertCount(24, $blocks); // Check user blocks. $visibility = [ diff --git a/core/modules/block_content/src/Controller/BlockContentController.php b/core/modules/block_content/src/Controller/BlockContentController.php index b2776f51d7d..77f8eee7939 100644 --- a/core/modules/block_content/src/Controller/BlockContentController.php +++ b/core/modules/block_content/src/Controller/BlockContentController.php @@ -2,9 +2,9 @@ namespace Drupal\block_content\Controller; +use Drupal\block_content\BlockContentTypeInterface; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Entity\EntityStorageInterface; -use Drupal\block_content\BlockContentTypeInterface; use Drupal\Core\Extension\ThemeHandlerInterface; use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -88,7 +88,8 @@ class BlockContentController extends ControllerBase { uasort($types, [$this->blockContentTypeStorage->getEntityType()->getClass(), 'sort']); if ($types && count($types) == 1) { $type = reset($types); - return $this->addForm($type, $request); + $query = $request->query->all(); + return $this->redirect('block_content.add_form', ['block_content_type' => $type->id()], ['query' => $query]); } if (count($types) === 0) { return [ diff --git a/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php b/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php index 844e06895cc..4e6c3b141e7 100644 --- a/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php +++ b/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php @@ -5,7 +5,6 @@ namespace Drupal\block_content\Plugin\Menu\LocalAction; use Drupal\Core\Menu\LocalActionDefault; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Routing\RouteProviderInterface; -use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; @@ -54,11 +53,6 @@ class BlockContentAddLocalAction extends LocalActionDefault { if ($region = $this->requestStack->getCurrentRequest()->query->getString('region')) { $options['query']['region'] = $region; } - - // Adds a destination on content block listing. - if ($route_match->getRouteName() == 'entity.block_content.collection') { - $options['query']['destination'] = Url::fromRoute('<current>')->toString(); - } return $options; } diff --git a/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php b/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php index bca42cd3e32..364b5f4524d 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php @@ -155,11 +155,7 @@ class BlockContentCreationTest extends BlockContentTestBase { // Create a block and place in block layout. $this->drupalGet('/admin/content/block'); $this->clickLink('Add content block'); - // Verify destination URL, when clicking "Save and configure" this - // destination will be ignored. - $base = base_path(); - $url = 'block/add?destination=' . $base . 'admin/content/block'; - $this->assertSession()->addressEquals($url); + $this->assertSession()->addressEquals('/block/add/basic'); $edit = []; $edit['info[0][value]'] = 'Test Block'; $edit['body[0][value]'] = $this->randomMachineName(16); diff --git a/core/modules/block_content/tests/src/Functional/LocalActionTest.php b/core/modules/block_content/tests/src/Functional/LocalActionTest.php new file mode 100644 index 00000000000..bb1a20df880 --- /dev/null +++ b/core/modules/block_content/tests/src/Functional/LocalActionTest.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\block_content\Functional; + +/** + * Tests block_content local action links. + * + * @group block_content + */ +class LocalActionTest extends BlockContentTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->drupalLogin($this->adminUser); + } + + /** + * Tests the block_content_add_action link. + */ + public function testAddContentBlockLink(): void { + // Verify that the link takes you straight to the block form if there's only + // one type. + $this->drupalGet('/admin/content/block'); + $this->clickLink('Add content block'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals('/block/add/basic'); + + $type = $this->randomMachineName(); + $this->createBlockContentType([ + 'id' => $type, + 'label' => $type, + ]); + + // Verify that the link takes you to the block add page if there's more than + // one type. + $this->drupalGet('/admin/content/block'); + $this->clickLink('Add content block'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals('/block/add'); + } + +} diff --git a/core/modules/comment/src/Hook/CommentThemeHooks.php b/core/modules/comment/src/Hook/CommentThemeHooks.php index e789af6dab1..c137d586d41 100644 --- a/core/modules/comment/src/Hook/CommentThemeHooks.php +++ b/core/modules/comment/src/Hook/CommentThemeHooks.php @@ -2,7 +2,7 @@ namespace Drupal\comment\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for comment. @@ -12,7 +12,7 @@ class CommentThemeHooks { /** * Implements hook_preprocess_HOOK() for block templates. */ - #[Preprocess('block')] + #[Hook('preprocess_block')] public function preprocessBlock(&$variables): void { if ($variables['configuration']['provider'] == 'comment') { $variables['attributes']['role'] = 'navigation'; diff --git a/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php b/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php index db1ffae5a6d..01a40394b40 100644 --- a/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php +++ b/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\comment_empty_title_test\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for comment_empty_title_test. @@ -14,7 +14,7 @@ class CommentEmptyTitleTestThemeHooks { /** * Implements hook_preprocess_comment(). */ - #[Preprocess('comment')] + #[Hook('preprocess_comment')] public function preprocessComment(&$variables): void { $variables['title'] = ''; } diff --git a/core/modules/comment/tests/src/Functional/CommentAdminTest.php b/core/modules/comment/tests/src/Functional/CommentAdminTest.php index f8dfc8a9b38..69c634ba0f9 100644 --- a/core/modules/comment/tests/src/Functional/CommentAdminTest.php +++ b/core/modules/comment/tests/src/Functional/CommentAdminTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\comment\Functional; use Drupal\comment\CommentInterface; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Html; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\user\RoleInterface; @@ -281,8 +280,8 @@ class CommentAdminTest extends CommentTestBase { ]; $this->drupalGet('admin/content/comment'); $this->submitForm($edit, 'Update'); - $this->assertSession()->responseContains(new FormattableMarkup('@label (Original translation) - <em>The following comment translations will be deleted:</em>', ['@label' => $comment1->label()])); - $this->assertSession()->responseContains(new FormattableMarkup('@label (Original translation) - <em>The following comment translations will be deleted:</em>', ['@label' => $comment2->label()])); + $this->assertSession()->responseContains($comment1->label() . " (Original translation) - <em>The following comment translations will be deleted:</em>"); + $this->assertSession()->responseContains($comment2->label() . " (Original translation) - <em>The following comment translations will be deleted:</em>"); $this->assertSession()->pageTextContains('English'); $this->assertSession()->pageTextContains('Urdu'); $this->submitForm([], 'Delete'); diff --git a/core/modules/comment/tests/src/Functional/CommentPagerTest.php b/core/modules/comment/tests/src/Functional/CommentPagerTest.php index 819403386b1..4927803208b 100644 --- a/core/modules/comment/tests/src/Functional/CommentPagerTest.php +++ b/core/modules/comment/tests/src/Functional/CommentPagerTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\comment\Functional; use Drupal\comment\CommentManagerInterface; -use Drupal\Component\Render\FormattableMarkup; use Drupal\node\Entity\Node; /** @@ -446,7 +445,7 @@ class CommentPagerTest extends CommentTestBase { $url_target = $this->getAbsoluteUrl($urls[$index]->getAttribute('href')); return $this->drupalGet($url_target); } - $this->fail(new FormattableMarkup('Link %label does not exist on @url_before', ['%label' => $xpath, '@url_before' => $url_before])); + $this->fail("Link $xpath does not exist on $url_before"); return FALSE; } diff --git a/core/modules/config/tests/src/Functional/ConfigEntityTest.php b/core/modules/config/tests/src/Functional/ConfigEntityTest.php index 1fe966fb127..d9f9b15724d 100644 --- a/core/modules/config/tests/src/Functional/ConfigEntityTest.php +++ b/core/modules/config/tests/src/Functional/ConfigEntityTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\config\Functional; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Uuid\Uuid; use Drupal\Core\Entity\EntityMalformedException; use Drupal\Core\Entity\EntityStorageException; @@ -172,10 +171,9 @@ class ConfigEntityTest extends BrowserTestBase { ]); try { $status = $id_length_config_test->save(); - $this->fail(new FormattableMarkup("config_test entity with ID length @length exceeding the maximum allowed length of @max saved successfully", [ - '@length' => strlen($id_length_config_test->id()), - '@max' => static::MAX_ID_LENGTH, - ])); + $length = strlen($id_length_config_test->id()); + $max = static::MAX_ID_LENGTH; + $this->fail("config_test entity with ID length $length exceeding the maximum allowed length of $max saved successfully"); } catch (ConfigEntityIdLengthException) { // Expected exception; just continue testing. diff --git a/core/modules/config_translation/migrations/d6_block_translation.yml b/core/modules/config_translation/migrations/d6_block_translation.yml index 6d57fdae1be..7925c49626f 100644 --- a/core/modules/config_translation/migrations/d6_block_translation.yml +++ b/core/modules/config_translation/migrations/d6_block_translation.yml @@ -39,8 +39,6 @@ process: 1: forum_new_block locale: 0: language_block - node: - 0: node_syndicate_block search: 0: search_form_block statistics: diff --git a/core/modules/config_translation/migrations/d7_block_translation.yml b/core/modules/config_translation/migrations/d7_block_translation.yml index 9c82ee6b678..d2530e3b50a 100644 --- a/core/modules/config_translation/migrations/d7_block_translation.yml +++ b/core/modules/config_translation/migrations/d7_block_translation.yml @@ -44,8 +44,6 @@ process: new: forum_new_block # locale: # 0: language_block - node: - syndicate: node_syndicate_block search: form: search_form_block statistics: diff --git a/core/modules/contact/src/Hook/ContactFormHooks.php b/core/modules/contact/src/Hook/ContactFormHooks.php index ad8223c3ec6..b31b929bddf 100644 --- a/core/modules/contact/src/Hook/ContactFormHooks.php +++ b/core/modules/contact/src/Hook/ContactFormHooks.php @@ -4,7 +4,7 @@ namespace Drupal\contact\Hook; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Hook\Attribute\FormAlter; +use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\user\UserDataInterface; @@ -29,7 +29,7 @@ class ContactFormHooks { * * @see \Drupal\user\ProfileForm::form() */ - #[FormAlter('user_form')] + #[Hook('form_user_form_alter')] public function formUserFormAlter(&$form, FormStateInterface $form_state) : void { $form['contact'] = [ '#type' => 'details', @@ -55,7 +55,7 @@ class ContactFormHooks { * * Adds the default personal contact setting on the user settings page. */ - #[FormAlter('user_admin_settings')] + #[Hook('form_user_admin_settings_alter')] public function formUserAdminSettingsAlter(&$form, FormStateInterface $form_state) : void { $form['contact'] = [ '#type' => 'details', diff --git a/core/modules/contact/tests/src/Functional/ContactPersonalTest.php b/core/modules/contact/tests/src/Functional/ContactPersonalTest.php index bac903bdd29..df4f0834788 100644 --- a/core/modules/contact/tests/src/Functional/ContactPersonalTest.php +++ b/core/modules/contact/tests/src/Functional/ContactPersonalTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\contact\Functional; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Html; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Test\AssertMailTrait; @@ -106,12 +105,7 @@ class ContactPersonalTest extends BrowserTestBase { $this->drupalLogin($this->adminUser); // Verify that the correct watchdog message has been logged. $this->drupalGet('/admin/reports/dblog'); - $placeholders = [ - '@sender_name' => $this->webUser->getAccountName(), - '@sender_email' => $this->webUser->getEmail(), - '@recipient_name' => $this->contactUser->getAccountName(), - ]; - $this->assertSession()->responseContains(new FormattableMarkup('@sender_name (@sender_email) sent @recipient_name an email.', $placeholders)); + $this->assertSession()->responseContains($this->webUser->getAccountName() . " (" . HTML::escape($this->webUser->getEmail()) . ") sent " . $this->contactUser->getAccountName() . " an email."); // Ensure an unescaped version of the email does not exist anywhere. $this->assertSession()->responseNotContains($this->webUser->getEmail()); diff --git a/core/modules/contextual/contextual.libraries.yml b/core/modules/contextual/contextual.libraries.yml index bfc1c996c98..6798d02d907 100644 --- a/core/modules/contextual/contextual.libraries.yml +++ b/core/modules/contextual/contextual.libraries.yml @@ -1,16 +1,9 @@ drupal.contextual-links: version: VERSION js: + js/contextualModelView.js: {} # Ensure to run before contextual/drupal.context-toolbar. - # Core. js/contextual.js: { weight: -2 } - # Models. - js/models/StateModel.js: { weight: -2 } - # Views. - js/views/AuralView.js: { weight: -2 } - js/views/KeyboardView.js: { weight: -2 } - js/views/RegionView.js: { weight: -2 } - js/views/VisualView.js: { weight: -2 } css: component: css/contextual.module.css: {} @@ -22,28 +15,21 @@ drupal.contextual-links: - core/drupal - core/drupal.ajax - core/drupalSettings - # @todo Remove this in https://www.drupal.org/project/drupal/issues/3203920 - - core/internal.backbone - core/once - core/drupal.touchevents-test drupal.contextual-toolbar: version: VERSION js: + js/toolbar/contextualToolbarModelView.js: {} js/contextual.toolbar.js: {} - # Models. - js/toolbar/models/StateModel.js: {} - # Views. - js/toolbar/views/AuralView.js: {} - js/toolbar/views/VisualView.js: {} css: component: css/contextual.toolbar.css: {} dependencies: - core/jquery + - contextual/drupal.contextual-links - core/drupal - # @todo Remove this in https://www.drupal.org/project/drupal/issues/3203920 - - core/internal.backbone - core/once - core/drupal.tabbingmanager - core/drupal.announce diff --git a/core/modules/contextual/css/contextual.theme.css b/core/modules/contextual/css/contextual.theme.css index 06a6728be39..55a83d5ca12 100644 --- a/core/modules/contextual/css/contextual.theme.css +++ b/core/modules/contextual/css/contextual.theme.css @@ -17,6 +17,10 @@ left: 0; } +.contextual.open { + z-index: 501; +} + /** * Contextual region. */ diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js index 87ccaa52dff..f1008eabe07 100644 --- a/core/modules/contextual/js/contextual.js +++ b/core/modules/contextual/js/contextual.js @@ -3,7 +3,7 @@ * Attaches behaviors for the Contextual module. */ -(function ($, Drupal, drupalSettings, _, Backbone, JSON, storage) { +(function ($, Drupal, drupalSettings, JSON, storage) { const options = $.extend( drupalSettings.contextual, // Merge strings on top of drupalSettings so that they are not mutable. @@ -14,22 +14,19 @@ }, }, ); - // Clear the cached contextual links whenever the current user's set of // permissions changes. const cachedPermissionsHash = storage.getItem( 'Drupal.contextual.permissionsHash', ); - const permissionsHash = drupalSettings.user.permissionsHash; + const { permissionsHash } = drupalSettings.user; if (cachedPermissionsHash !== permissionsHash) { if (typeof permissionsHash === 'string') { - _.chain(storage) - .keys() - .each((key) => { - if (key.startsWith('Drupal.contextual.')) { - storage.removeItem(key); - } - }); + Object.keys(storage).forEach((key) => { + if (key.startsWith('Drupal.contextual.')) { + storage.removeItem(key); + } + }); } storage.setItem('Drupal.contextual.permissionsHash', permissionsHash); } @@ -87,7 +84,7 @@ */ function initContextual($contextual, html) { const $region = $contextual.closest('.contextual-region'); - const contextual = Drupal.contextual; + const { contextual } = Drupal; $contextual // Update the placeholder to contain its rendered contextual links. @@ -107,46 +104,18 @@ const glue = url.includes('?') ? '&' : '?'; this.setAttribute('href', url + glue + destination); }); - let title = ''; const $regionHeading = $region.find('h2'); if ($regionHeading.length) { title = $regionHeading[0].textContent.trim(); } - // Create a model and the appropriate views. - const model = new contextual.StateModel({ - title, - }); - const viewOptions = $.extend({ el: $contextual, model }, options); - contextual.views.push({ - visual: new contextual.VisualView(viewOptions), - aural: new contextual.AuralView(viewOptions), - keyboard: new contextual.KeyboardView(viewOptions), - }); - contextual.regionViews.push( - new contextual.RegionView($.extend({ el: $region, model }, options)), - ); - - // Add the model to the collection. This must happen after the views have - // been associated with it, otherwise collection change event handlers can't - // trigger the model change event handler in its views. - contextual.collection.add(model); - - // Let other JavaScript react to the adding of a new contextual link. - $(document).trigger( - 'drupalContextualLinkAdded', - Drupal.deprecatedProperty({ - target: { - $el: $contextual, - $region, - model, - }, - deprecatedProperty: 'model', - message: - 'The model property is deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no replacement.', - }), + options.title = title; + const contextualModelView = new Drupal.contextual.ContextualModelView( + $contextual, + $region, + options, ); - + contextual.instances.push(contextualModelView); // Fix visual collisions between contextual link triggers. adjustIfNestedAndOverlapping($contextual); } @@ -192,7 +161,7 @@ // Initialize after the current execution cycle, to make the AJAX // request for retrieving the uncached contextual links as soon as // possible, but also to ensure that other Drupal behaviors have had - // the chance to set up an event listener on the Backbone collection + // the chance to set up an event listener on the collection // Drupal.contextual.collection. window.setTimeout(() => { initContextual( @@ -217,7 +186,7 @@ data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens }, dataType: 'json', success(results) { - _.each(results, (html, contextualID) => { + Object.entries(results).forEach(([contextualID, html]) => { // Store the metadata. storage.setItem(`Drupal.contextual.${contextualID}`, html); // If the rendered contextual links are empty, then the current @@ -274,21 +243,23 @@ * replacement. */ regionViews: [], + instances: new Proxy([], { + set: function set(obj, prop, value) { + obj[prop] = value; + window.dispatchEvent(new Event('contextual-instances-added')); + return true; + }, + deleteProperty(target, prop) { + if (prop in target) { + delete target[prop]; + window.dispatchEvent(new Event('contextual-instances-removed')); + } + }, + }), + ContextualModelView: {}, }; /** - * A Backbone.Collection of {@link Drupal.contextual.StateModel} instances. - * - * @type {Backbone.Collection} - * - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.collection = new Backbone.Collection([], { - model: Drupal.contextual.StateModel, - }); - - /** * A trigger is an interactive element often bound to a click handler. * * @return {string} @@ -311,12 +282,4 @@ $(document).on('drupalContextualLinkAdded', (event, data) => { Drupal.ajax.bindAjaxLinks(data.$el[0]); }); -})( - jQuery, - Drupal, - drupalSettings, - _, - Backbone, - window.JSON, - window.sessionStorage, -); +})(jQuery, Drupal, drupalSettings, window.JSON, window.sessionStorage); diff --git a/core/modules/contextual/js/contextual.toolbar.js b/core/modules/contextual/js/contextual.toolbar.js index 8fc206cc2c3..c94d0df414c 100644 --- a/core/modules/contextual/js/contextual.toolbar.js +++ b/core/modules/contextual/js/contextual.toolbar.js @@ -3,7 +3,7 @@ * Attaches behaviors for the Contextual module's edit toolbar tab. */ -(function ($, Drupal, Backbone) { +(function ($, Drupal) { const strings = { tabbingReleased: Drupal.t( 'Tabbing is no longer constrained by the Contextual module.', @@ -21,33 +21,19 @@ * A contextual links DOM element as rendered by the server. */ function initContextualToolbar(context) { - if (!Drupal.contextual || !Drupal.contextual.collection) { + if (!Drupal.contextual || !Drupal.contextual.instances) { return; } - const contextualToolbar = Drupal.contextualToolbar; - contextualToolbar.model = new contextualToolbar.StateModel( - { - // Checks whether localStorage indicates we should start in edit mode - // rather than view mode. - // @see Drupal.contextualToolbar.VisualView.persist - isViewing: - document.querySelector('body .contextual-region') === null || - localStorage.getItem('Drupal.contextualToolbar.isViewing') !== - 'false', - }, - { - contextualCollection: Drupal.contextual.collection, - }, - ); + const { contextualToolbar } = Drupal; const viewOptions = { el: $('.toolbar .toolbar-bar .contextual-toolbar-tab'), - model: contextualToolbar.model, strings, }; - new contextualToolbar.VisualView(viewOptions); - new contextualToolbar.AuralView(viewOptions); + contextualToolbar.model = new Drupal.contextual.ContextualToolbarModelView( + viewOptions, + ); } /** @@ -75,13 +61,10 @@ */ Drupal.contextualToolbar = { /** - * The {@link Drupal.contextualToolbar.StateModel} instance. - * - * @type {?Drupal.contextualToolbar.StateModel} + * The {@link Drupal.contextual.ContextualToolbarModelView} instance. * - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is - * no replacement. + * @type {?Drupal.contextual.ContextualToolbarModelView} */ model: null, }; -})(jQuery, Drupal, Backbone); +})(jQuery, Drupal); diff --git a/core/modules/contextual/js/contextualModelView.js b/core/modules/contextual/js/contextualModelView.js new file mode 100644 index 00000000000..4488045e223 --- /dev/null +++ b/core/modules/contextual/js/contextualModelView.js @@ -0,0 +1,254 @@ +(($, Drupal) => { + /** + * Models the state of a contextual link's trigger, list & region. + */ + Drupal.contextual.ContextualModelView = class { + constructor($contextual, $region, options) { + this.title = options.title || ''; + this.regionIsHovered = false; + this._hasFocus = false; + this._isOpen = false; + this._isLocked = false; + this.strings = options.strings; + this.timer = NaN; + this.modelId = btoa(Math.random()).substring(0, 12); + this.$region = $region; + this.$contextual = $contextual; + + if (!document.body.classList.contains('touchevents')) { + this.$region.on({ + mouseenter: () => { + this.regionIsHovered = true; + }, + mouseleave: () => { + this.close().blur(); + this.regionIsHovered = false; + }, + 'mouseleave mouseenter': () => this.render(), + }); + this.$contextual.on('mouseenter', () => { + this.focus(); + this.render(); + }); + } + + this.$contextual.on( + { + click: () => { + this.toggleOpen(); + }, + touchend: () => { + Drupal.contextual.ContextualModelView.touchEndToClick(); + }, + focus: () => { + this.focus(); + }, + blur: () => { + this.blur(); + }, + 'click blur touchend focus': () => this.render(), + }, + '.trigger', + ); + + this.$contextual.on( + { + click: () => { + this.close().blur(); + }, + touchend: (event) => { + Drupal.contextual.ContextualModelView.touchEndToClick(event); + }, + focus: () => { + this.focus(); + }, + blur: () => { + this.waitCloseThenBlur(); + }, + 'click blur touchend focus': () => this.render(), + }, + '.contextual-links a', + ); + + this.render(); + + // Let other JavaScript react to the adding of a new contextual link. + $(document).trigger('drupalContextualLinkAdded', { + $el: $contextual, + $region, + model: this, + }); + } + + /** + * Updates the rendered representation of the current contextual links. + */ + render() { + const { isOpen } = this; + const isVisible = this.isLocked || this.regionIsHovered || isOpen; + this.$region.toggleClass('focus', this.hasFocus); + this.$contextual + .toggleClass('open', isOpen) + // Update the visibility of the trigger. + .find('.trigger') + .toggleClass('visually-hidden', !isVisible); + + this.$contextual.find('.contextual-links').prop('hidden', !isOpen); + const trigger = this.$contextual.find('.trigger').get(0); + trigger.textContent = Drupal.t('@action @title configuration options', { + '@action': !isOpen ? this.strings.open : this.strings.close, + '@title': this.title, + }); + trigger.setAttribute('aria-pressed', isOpen); + } + + /** + * Prevents delay and simulated mouse events. + * + * @param {jQuery.Event} event the touch end event. + */ + static touchEndToClick(event) { + event.preventDefault(); + event.target.click(); + } + + /** + * Set up a timeout to allow a user to tab between the trigger and the + * contextual links without the menu dismissing. + */ + waitCloseThenBlur() { + this.timer = window.setTimeout(() => { + this.isOpen = false; + this.hasFocus = false; + this.render(); + }, 150); + } + + /** + * Opens or closes the contextual link. + * + * If it is opened, then also give focus. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + toggleOpen() { + const newIsOpen = !this.isOpen; + this.isOpen = newIsOpen; + if (newIsOpen) { + this.focus(); + } + return this; + } + + /** + * Gives focus to this contextual link. + * + * Also closes + removes focus from every other contextual link. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + focus() { + const { modelId } = this; + Drupal.contextual.instances.forEach((model) => { + if (model.modelId !== modelId) { + model.close().blur(); + } + }); + window.clearTimeout(this.timer); + this.hasFocus = true; + return this; + } + + /** + * Removes focus from this contextual link, unless it is open. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + blur() { + if (!this.isOpen) { + this.hasFocus = false; + } + return this; + } + + /** + * Closes this contextual link. + * + * Does not call blur() because we want to allow a contextual link to have + * focus, yet be closed for example when hovering. + * + * @return {Drupal.contextual.ContextualModelView} + * The current contextual model view. + */ + close() { + this.isOpen = false; + return this; + } + + /** + * Gets the current focus state. + * + * @return {boolean} the focus state. + */ + get hasFocus() { + return this._hasFocus; + } + + /** + * Sets the current focus state. + * + * @param {boolean} value - new focus state + */ + set hasFocus(value) { + this._hasFocus = value; + this.$region.toggleClass('focus', this._hasFocus); + } + + /** + * Gets the current open state. + * + * @return {boolean} the open state. + */ + get isOpen() { + return this._isOpen; + } + + /** + * Sets the current open state. + * + * @param {boolean} value - new open state + */ + set isOpen(value) { + this._isOpen = value; + // Nested contextual region handling: hide any nested contextual triggers. + this.$region + .closest('.contextual-region') + .find('.contextual .trigger:not(:first)') + .toggle(!this.isOpen); + } + + /** + * Gets the current locked state. + * + * @return {boolean} the locked state. + */ + get isLocked() { + return this._isLocked; + } + + /** + * Sets the current locked state. + * + * @param {boolean} value - new locked state + */ + set isLocked(value) { + if (value !== this._isLocked) { + this._isLocked = value; + this.render(); + } + } + }; +})(jQuery, Drupal); diff --git a/core/modules/contextual/js/models/StateModel.js b/core/modules/contextual/js/models/StateModel.js deleted file mode 100644 index 622f897917f..00000000000 --- a/core/modules/contextual/js/models/StateModel.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @file - * A Backbone Model for the state of a contextual link's trigger, list & region. - */ - -(function (Drupal, Backbone) { - /** - * Models the state of a contextual link's trigger, list & region. - * - * @constructor - * - * @augments Backbone.Model - * - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.StateModel = Backbone.Model.extend( - /** @lends Drupal.contextual.StateModel# */ { - /** - * @type {object} - * - * @prop {string} title - * @prop {boolean} regionIsHovered - * @prop {boolean} hasFocus - * @prop {boolean} isOpen - * @prop {boolean} isLocked - */ - defaults: /** @lends Drupal.contextual.StateModel# */ { - /** - * The title of the entity to which these contextual links apply. - * - * @type {string} - */ - title: '', - - /** - * Represents if the contextual region is being hovered. - * - * @type {boolean} - */ - regionIsHovered: false, - - /** - * Represents if the contextual trigger or options have focus. - * - * @type {boolean} - */ - hasFocus: false, - - /** - * Represents if the contextual options for an entity are available to - * be selected (i.e. whether the list of options is visible). - * - * @type {boolean} - */ - isOpen: false, - - /** - * When the model is locked, the trigger remains active. - * - * @type {boolean} - */ - isLocked: false, - }, - - /** - * Opens or closes the contextual link. - * - * If it is opened, then also give focus. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - toggleOpen() { - const newIsOpen = !this.get('isOpen'); - this.set('isOpen', newIsOpen); - if (newIsOpen) { - this.focus(); - } - return this; - }, - - /** - * Closes this contextual link. - * - * Does not call blur() because we want to allow a contextual link to have - * focus, yet be closed for example when hovering. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - close() { - this.set('isOpen', false); - return this; - }, - - /** - * Gives focus to this contextual link. - * - * Also closes + removes focus from every other contextual link. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - focus() { - this.set('hasFocus', true); - const cid = this.cid; - this.collection.each((model) => { - if (model.cid !== cid) { - model.close().blur(); - } - }); - return this; - }, - - /** - * Removes focus from this contextual link, unless it is open. - * - * @return {Drupal.contextual.StateModel} - * The current contextual state model. - */ - blur() { - if (!this.get('isOpen')) { - this.set('hasFocus', false); - } - return this; - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/toolbar/contextualToolbarModelView.js b/core/modules/contextual/js/toolbar/contextualToolbarModelView.js new file mode 100644 index 00000000000..6c6db5fe70c --- /dev/null +++ b/core/modules/contextual/js/toolbar/contextualToolbarModelView.js @@ -0,0 +1,175 @@ +(($, Drupal) => { + Drupal.contextual.ContextualToolbarModelView = class { + constructor(options) { + this.strings = options.strings; + this.isVisible = false; + this._contextualCount = Drupal.contextual.instances.count; + this.tabbingContext = null; + this._isViewing = + localStorage.getItem('Drupal.contextualToolbar.isViewing') !== 'false'; + this.$el = options.el; + + window.addEventListener('contextual-instances-added', () => + this.lockNewContextualLinks(), + ); + window.addEventListener('contextual-instances-removed', () => { + this.contextualCount = Drupal.contextual.instances.count; + }); + + this.$el.on({ + click: () => { + this.isViewing = !this.isViewing; + }, + touchend: (event) => { + event.preventDefault(); + event.target.click(); + }, + 'click touchend': () => this.render(), + }); + + $(document).on('keyup', (event) => this.onKeypress(event)); + this.manageTabbing(true); + this.render(); + } + + /** + * Responds to esc and tab key press events. + * + * @param {jQuery.Event} event + * The keypress event. + */ + onKeypress(event) { + // The first tab key press is tracked so that an announcement about + // tabbing constraints can be raised if edit mode is enabled when the page + // is loaded. + if (!this.announcedOnce && event.keyCode === 9 && !this.isViewing) { + this.announceTabbingConstraint(); + // Set announce to true so that this conditional block won't run again. + this.announcedOnce = true; + } + // Respond to the ESC key. Exit out of edit mode. + if (event.keyCode === 27) { + this.isViewing = true; + } + } + + /** + * Updates the rendered representation of the current toolbar model view. + */ + render() { + this.$el[0].classList.toggle('hidden', this.isVisible); + const button = this.$el[0].querySelector('button'); + button.classList.toggle('is-active', !this.isViewing); + button.setAttribute('aria-pressed', !this.isViewing); + this.contextualCount = Drupal.contextual.instances.count; + } + + /** + * Automatically updates visibility of the view/edit mode toggle. + */ + updateVisibility() { + this.isVisible = this.get('contextualCount') > 0; + } + + /** + * Lock newly added contextual links if edit mode is enabled. + */ + lockNewContextualLinks() { + Drupal.contextual.instances.forEach((model) => { + model.isLocked = !this.isViewing; + }); + this.contextualCount = Drupal.contextual.instances.count; + } + + /** + * Limits tabbing to the contextual links and edit mode toolbar tab. + * + * @param {boolean} init - true to initialize tabbing. + */ + manageTabbing(init = false) { + let { tabbingContext } = this; + // Always release an existing tabbing context. + if (tabbingContext && !init) { + // Only announce release when the context was active. + if (tabbingContext.active) { + Drupal.announce(this.strings.tabbingReleased); + } + tabbingContext.release(); + this.tabbingContext = null; + } + // Create a new tabbing context when edit mode is enabled. + if (!this.isViewing) { + tabbingContext = Drupal.tabbingManager.constrain( + $('.contextual-toolbar-tab, .contextual'), + ); + this.tabbingContext = tabbingContext; + this.announceTabbingConstraint(); + this.announcedOnce = true; + } + } + + /** + * Announces the current tabbing constraint. + */ + announceTabbingConstraint() { + const { strings } = this; + Drupal.announce( + Drupal.formatString(strings.tabbingConstrained, { + '@contextualsCount': Drupal.formatPlural( + Drupal.contextual.instances.length, + '@count contextual link', + '@count contextual links', + ), + }) + strings.pressEsc, + ); + } + + /** + * Gets the current viewing state. + * + * @return {boolean} the viewing state. + */ + get isViewing() { + return this._isViewing; + } + + /** + * Sets the current viewing state. + * + * @param {boolean} value - new viewing state + */ + set isViewing(value) { + this._isViewing = value; + localStorage[!value ? 'setItem' : 'removeItem']( + 'Drupal.contextualToolbar.isViewing', + 'false', + ); + + Drupal.contextual.instances.forEach((model) => { + model.isLocked = !this.isViewing; + }); + this.manageTabbing(); + } + + /** + * Gets the current contextual links count. + * + * @return {integer} the current contextual links count. + */ + get contextualCount() { + return this._contextualCount; + } + + /** + * Sets the current contextual links count. + * + * @param {integer} value - new contextual links count. + */ + set contextualCount(value) { + if (value !== this._contextualCount) { + this._contextualCount = value; + this.updateVisibility(); + } + } + }; +})(jQuery, Drupal); diff --git a/core/modules/contextual/js/toolbar/models/StateModel.js b/core/modules/contextual/js/toolbar/models/StateModel.js deleted file mode 100644 index 88f66193f9f..00000000000 --- a/core/modules/contextual/js/toolbar/models/StateModel.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @file - * A Backbone Model for the state of Contextual module's edit toolbar tab. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextualToolbar.StateModel = Backbone.Model.extend( - /** @lends Drupal.contextualToolbar.StateModel# */ { - /** - * @type {object} - * - * @prop {boolean} isViewing - * @prop {boolean} isVisible - * @prop {number} contextualCount - * @prop {Drupal~TabbingContext} tabbingContext - */ - defaults: /** @lends Drupal.contextualToolbar.StateModel# */ { - /** - * Indicates whether the toggle is currently in "view" or "edit" mode. - * - * @type {boolean} - */ - isViewing: true, - - /** - * Indicates whether the toggle should be visible or hidden. Automatically - * calculated, depends on contextualCount. - * - * @type {boolean} - */ - isVisible: false, - - /** - * Tracks how many contextual links exist on the page. - * - * @type {number} - */ - contextualCount: 0, - - /** - * A TabbingContext object as returned by {@link Drupal~TabbingManager}: - * the set of tabbable elements when edit mode is enabled. - * - * @type {?Drupal~TabbingContext} - */ - tabbingContext: null, - }, - - /** - * Models the state of the edit mode toggle. - * - * @constructs - * - * @augments Backbone.Model - * - * @param {object} attrs - * Attributes for the backbone model. - * @param {object} options - * An object with the following option: - * @param {Backbone.collection} options.contextualCollection - * The collection of {@link Drupal.contextual.StateModel} models that - * represent the contextual links on the page. - */ - initialize(attrs, options) { - // Respond to new/removed contextual links. - this.listenTo( - options.contextualCollection, - 'reset remove add', - this.countContextualLinks, - ); - this.listenTo( - options.contextualCollection, - 'add', - this.lockNewContextualLinks, - ); - - // Automatically determine visibility. - this.listenTo(this, 'change:contextualCount', this.updateVisibility); - - // Whenever edit mode is toggled, lock all contextual links. - this.listenTo(this, 'change:isViewing', (model, isViewing) => { - options.contextualCollection.each((contextualModel) => { - contextualModel.set('isLocked', !isViewing); - }); - }); - }, - - /** - * Tracks the number of contextual link models in the collection. - * - * @param {Drupal.contextual.StateModel} contextualModel - * The contextual links model that was added or removed. - * @param {Backbone.Collection} contextualCollection - * The collection of contextual link models. - */ - countContextualLinks(contextualModel, contextualCollection) { - this.set('contextualCount', contextualCollection.length); - }, - - /** - * Lock newly added contextual links if edit mode is enabled. - * - * @param {Drupal.contextual.StateModel} contextualModel - * The contextual links model that was added. - * @param {Backbone.Collection} [contextualCollection] - * The collection of contextual link models. - */ - lockNewContextualLinks(contextualModel, contextualCollection) { - if (!this.get('isViewing')) { - contextualModel.set('isLocked', true); - } - }, - - /** - * Automatically updates visibility of the view/edit mode toggle. - */ - updateVisibility() { - this.set('isVisible', this.get('contextualCount') > 0); - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/toolbar/views/AuralView.js b/core/modules/contextual/js/toolbar/views/AuralView.js deleted file mode 100644 index 2bcf9cdcca0..00000000000 --- a/core/modules/contextual/js/toolbar/views/AuralView.js +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @file - * A Backbone View that provides the aural view of the edit mode toggle. - */ - -(function ($, Drupal, Backbone, _) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextualToolbar.AuralView = Backbone.View.extend( - /** @lends Drupal.contextualToolbar.AuralView# */ { - /** - * Tracks whether the tabbing constraint announcement has been read once. - * - * @type {boolean} - */ - announcedOnce: false, - - /** - * Renders the aural view of the edit mode toggle (screen reader support). - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options for the view. - */ - initialize(options) { - this.options = options; - - this.listenTo(this.model, 'change', this.render); - this.listenTo(this.model, 'change:isViewing', this.manageTabbing); - - $(document).on('keyup', _.bind(this.onKeypress, this)); - this.manageTabbing(); - }, - - /** - * {@inheritdoc} - * - * @return {Drupal.contextualToolbar.AuralView} - * The current contextual toolbar aural view. - */ - render() { - // Render the state. - this.$el - .find('button') - .attr('aria-pressed', !this.model.get('isViewing')); - - return this; - }, - - /** - * Limits tabbing to the contextual links and edit mode toolbar tab. - */ - manageTabbing() { - let tabbingContext = this.model.get('tabbingContext'); - // Always release an existing tabbing context. - if (tabbingContext) { - // Only announce release when the context was active. - if (tabbingContext.active) { - Drupal.announce(this.options.strings.tabbingReleased); - } - tabbingContext.release(); - } - // Create a new tabbing context when edit mode is enabled. - if (!this.model.get('isViewing')) { - tabbingContext = Drupal.tabbingManager.constrain( - $('.contextual-toolbar-tab, .contextual'), - ); - this.model.set('tabbingContext', tabbingContext); - this.announceTabbingConstraint(); - this.announcedOnce = true; - } - }, - - /** - * Announces the current tabbing constraint. - */ - announceTabbingConstraint() { - const strings = this.options.strings; - Drupal.announce( - Drupal.formatString(strings.tabbingConstrained, { - '@contextualsCount': Drupal.formatPlural( - Drupal.contextual.collection.length, - '@count contextual link', - '@count contextual links', - ), - }), - ); - Drupal.announce(strings.pressEsc); - }, - - /** - * Responds to esc and tab key press events. - * - * @param {jQuery.Event} event - * The keypress event. - */ - onKeypress(event) { - // The first tab key press is tracked so that an announcement about - // tabbing constraints can be raised if edit mode is enabled when the page - // is loaded. - if ( - !this.announcedOnce && - event.keyCode === 9 && - !this.model.get('isViewing') - ) { - this.announceTabbingConstraint(); - // Set announce to true so that this conditional block won't run again. - this.announcedOnce = true; - } - // Respond to the ESC key. Exit out of edit mode. - if (event.keyCode === 27) { - this.model.set('isViewing', true); - } - }, - }, - ); -})(jQuery, Drupal, Backbone, _); diff --git a/core/modules/contextual/js/toolbar/views/VisualView.js b/core/modules/contextual/js/toolbar/views/VisualView.js deleted file mode 100644 index 10d8dff2dea..00000000000 --- a/core/modules/contextual/js/toolbar/views/VisualView.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @file - * A Backbone View that provides the visual view of the edit mode toggle. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextualToolbar.VisualView = Backbone.View.extend( - /** @lends Drupal.contextualToolbar.VisualView# */ { - /** - * Events for the Backbone view. - * - * @return {object} - * A mapping of events to be used in the view. - */ - events() { - // Prevents delay and simulated mouse events. - const touchEndToClick = function (event) { - event.preventDefault(); - event.target.click(); - }; - - return { - click() { - this.model.set('isViewing', !this.model.get('isViewing')); - }, - touchend: touchEndToClick, - }; - }, - - /** - * Renders the visual view of the edit mode toggle. - * - * Listens to mouse & touch and handles edit mode toggle interactions. - * - * @constructs - * - * @augments Backbone.View - */ - initialize() { - this.listenTo(this.model, 'change', this.render); - this.listenTo(this.model, 'change:isViewing', this.persist); - }, - - /** - * {@inheritdoc} - * - * @return {Drupal.contextualToolbar.VisualView} - * The current contextual toolbar visual view. - */ - render() { - // Render the visibility. - this.$el.toggleClass('hidden', !this.model.get('isVisible')); - // Render the state. - this.$el - .find('button') - .toggleClass('is-active', !this.model.get('isViewing')); - - return this; - }, - - /** - * Model change handler; persists the isViewing value to localStorage. - * - * `isViewing === true` is the default, so only stores in localStorage when - * it's not the default value (i.e. false). - * - * @param {Drupal.contextualToolbar.StateModel} model - * A {@link Drupal.contextualToolbar.StateModel} model. - * @param {boolean} isViewing - * The value of the isViewing attribute in the model. - */ - persist(model, isViewing) { - if (!isViewing) { - localStorage.setItem('Drupal.contextualToolbar.isViewing', 'false'); - } else { - localStorage.removeItem('Drupal.contextualToolbar.isViewing'); - } - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/AuralView.js b/core/modules/contextual/js/views/AuralView.js deleted file mode 100644 index 62287c1bf11..00000000000 --- a/core/modules/contextual/js/views/AuralView.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @file - * A Backbone View that provides the aural view of a contextual link. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.AuralView = Backbone.View.extend( - /** @lends Drupal.contextual.AuralView# */ { - /** - * Renders the aural view of a contextual link (i.e. screen reader support). - * - * @constructs - * - * @augments Backbone.View - * - * @param {object} options - * Options for the view. - */ - initialize(options) { - this.options = options; - - this.listenTo(this.model, 'change', this.render); - - // Initial render. - this.render(); - }, - - /** - * {@inheritdoc} - */ - render() { - const isOpen = this.model.get('isOpen'); - - // Set the hidden property of the links. - this.$el.find('.contextual-links').prop('hidden', !isOpen); - - // Update the view of the trigger. - const $trigger = this.$el.find('.trigger'); - $trigger - .each((index, element) => { - element.textContent = Drupal.t( - '@action @title configuration options', - { - '@action': !isOpen - ? this.options.strings.open - : this.options.strings.close, - '@title': this.model.get('title'), - }, - ); - }) - .attr('aria-pressed', isOpen); - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/KeyboardView.js b/core/modules/contextual/js/views/KeyboardView.js deleted file mode 100644 index 2a3d144bea0..00000000000 --- a/core/modules/contextual/js/views/KeyboardView.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @file - * A Backbone View that provides keyboard interaction for a contextual link. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.KeyboardView = Backbone.View.extend( - /** @lends Drupal.contextual.KeyboardView# */ { - /** - * @type {object} - */ - events: { - 'focus .trigger': 'focus', - 'focus .contextual-links a': 'focus', - 'blur .trigger': function () { - this.model.blur(); - }, - 'blur .contextual-links a': function () { - // Set up a timeout to allow a user to tab between the trigger and the - // contextual links without the menu dismissing. - const that = this; - this.timer = window.setTimeout(() => { - that.model.close().blur(); - }, 150); - }, - }, - - /** - * Provides keyboard interaction for a contextual link. - * - * @constructs - * - * @augments Backbone.View - */ - initialize() { - /** - * The timer is used to create a delay before dismissing the contextual - * links on blur. This is only necessary when keyboard users tab into - * contextual links without edit mode (i.e. without TabbingManager). - * That means that if we decide to disable tabbing of contextual links - * without edit mode, all this timer logic can go away. - * - * @type {NaN|number} - */ - this.timer = NaN; - }, - - /** - * Sets focus on the model; Clears the timer that dismisses the links. - */ - focus() { - // Clear the timeout that might have been set by blurring a link. - window.clearTimeout(this.timer); - this.model.focus(); - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/RegionView.js b/core/modules/contextual/js/views/RegionView.js deleted file mode 100644 index 349428301d8..00000000000 --- a/core/modules/contextual/js/views/RegionView.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file - * A Backbone View that renders the visual view of a contextual region element. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.RegionView = Backbone.View.extend( - /** @lends Drupal.contextual.RegionView# */ { - /** - * Events for the Backbone view. - * - * @return {object} - * A mapping of events to be used in the view. - */ - events() { - // Used for tracking the presence of touch events. When true, the - // mousemove and mouseenter event handlers are effectively disabled. - // This is used instead of preventDefault() on touchstart as some - // touchstart events are not cancelable. - let touchStart = false; - return { - touchstart() { - // Set to true so the mouseenter and mouseleave events that follow - // know to not execute any hover related logic. - touchStart = true; - }, - mouseenter() { - if (!touchStart) { - this.model.set('regionIsHovered', true); - } - }, - mouseleave() { - if (!touchStart) { - this.model.close().blur().set('regionIsHovered', false); - } - }, - mousemove() { - // Because there are scenarios where there are both touchscreens - // and pointer devices, the touchStart flag should be set back to - // false after mouseenter and mouseleave complete. It will be set to - // true if another touchstart event occurs. - touchStart = false; - }, - }; - }, - - /** - * Renders the visual view of a contextual region element. - * - * @constructs - * - * @augments Backbone.View - */ - initialize() { - this.listenTo(this.model, 'change:hasFocus', this.render); - }, - - /** - * {@inheritdoc} - * - * @return {Drupal.contextual.RegionView} - * The current contextual region view. - */ - render() { - this.$el.toggleClass('focus', this.model.get('hasFocus')); - - return this; - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/js/views/VisualView.js b/core/modules/contextual/js/views/VisualView.js deleted file mode 100644 index fcd932b1faf..00000000000 --- a/core/modules/contextual/js/views/VisualView.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @file - * A Backbone View that provides the visual view of a contextual link. - */ - -(function (Drupal, Backbone) { - /** - * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no - * replacement. - */ - Drupal.contextual.VisualView = Backbone.View.extend( - /** @lends Drupal.contextual.VisualView# */ { - /** - * Events for the Backbone view. - * - * @return {object} - * A mapping of events to be used in the view. - */ - events() { - // Prevents delay and simulated mouse events. - const touchEndToClick = function (event) { - event.preventDefault(); - event.target.click(); - }; - - // Used for tracking the presence of touch events. When true, the - // mousemove and mouseenter event handlers are effectively disabled. - // This is used instead of preventDefault() on touchstart as some - // touchstart events are not cancelable. - let touchStart = false; - - return { - touchstart() { - // Set to true so the mouseenter events that follows knows to not - // execute any hover related logic. - touchStart = true; - }, - mouseenter() { - // We only want mouse hover events on non-touch. - if (!touchStart) { - this.model.focus(); - } - }, - mousemove() { - // Because there are scenarios where there are both touchscreens - // and pointer devices, the touchStart flag should be set back to - // false after mouseenter and mouseleave complete. It will be set to - // true if another touchstart event occurs. - touchStart = false; - }, - 'click .trigger': function () { - this.model.toggleOpen(); - }, - 'touchend .trigger': touchEndToClick, - 'click .contextual-links a': function () { - this.model.close().blur(); - }, - 'touchend .contextual-links a': touchEndToClick, - }; - }, - - /** - * Renders the visual view of a contextual link. Listens to mouse & touch. - * - * @constructs - * - * @augments Backbone.View - */ - initialize() { - this.listenTo(this.model, 'change', this.render); - }, - - /** - * {@inheritdoc} - * - * @return {Drupal.contextual.VisualView} - * The current contextual visual view. - */ - render() { - const isOpen = this.model.get('isOpen'); - // The trigger should be visible when: - // - the mouse hovered over the region, - // - the trigger is locked, - // - and for as long as the contextual menu is open. - const isVisible = - this.model.get('isLocked') || - this.model.get('regionIsHovered') || - isOpen; - - this.$el - // The open state determines if the links are visible. - .toggleClass('open', isOpen) - // Update the visibility of the trigger. - .find('.trigger') - .toggleClass('visually-hidden', !isVisible); - - // Nested contextual region handling: hide any nested contextual triggers. - if ('isOpen' in this.model.changed) { - this.$el - .closest('.contextual-region') - .find('.contextual .trigger:not(:first)') - .toggle(!isOpen); - } - - return this; - }, - }, - ); -})(Drupal, Backbone); diff --git a/core/modules/contextual/src/Hook/ContextualThemeHooks.php b/core/modules/contextual/src/Hook/ContextualThemeHooks.php index 760a42c9785..7d873196b43 100644 --- a/core/modules/contextual/src/Hook/ContextualThemeHooks.php +++ b/core/modules/contextual/src/Hook/ContextualThemeHooks.php @@ -2,7 +2,7 @@ namespace Drupal\contextual\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Session\AccountInterface; /** @@ -21,7 +21,7 @@ class ContextualThemeHooks { * @see contextual_page_attachments() * @see \Drupal\contextual\ContextualController::render() */ - #[Preprocess] + #[Hook('preprocess')] public function preprocess(&$variables, $hook, $info): void { // Determine the primary theme function argument. if (!empty($info['variables'])) { diff --git a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php index 75e56b5f76b..1d4fa243c49 100644 --- a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php +++ b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php @@ -73,47 +73,40 @@ class EditModeTest extends WebDriverTestBase { $page = $this->getSession()->getPage(); // Get the page twice to ensure edit mode remains enabled after a new page // request. - for ($page_get_count = 0; $page_get_count < 2; $page_get_count++) { - $this->drupalGet('user'); - $expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]')); - - // After the page loaded we need to additionally wait until the settings - // tray Ajax activity is done. - if ($page_get_count === 0) { - $web_assert->assertWaitOnAjaxRequest(); - } - - if ($page_get_count == 0) { - $unrestricted_tab_count = $this->getTabbableElementsCount(); - $this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count); - - // Enable edit mode. - // After the first page load the page will be in edit mode when loaded. - $this->pressToolbarEditButton(); - } - - $this->assertAnnounceEditMode(); - $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); - - // Disable edit mode. - $this->pressToolbarEditButton(); - $this->assertAnnounceLeaveEditMode(); - $this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount()); - // Enable edit mode again. - $this->pressToolbarEditButton(); - // Finally assert that the 'edit mode enabled' announcement is still - // correct after toggling the edit mode at least once. - $this->assertAnnounceEditMode(); - $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); - - // Test while Edit Mode is enabled it doesn't interfere with pages with - // no contextual links. - $this->drupalGet('admin/structure/block'); - $web_assert->elementContains('css', 'h1.page-title', 'Block layout'); - $this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]'))); - $this->assertGreaterThan(0, $this->getTabbableElementsCount()); - } - + $this->drupalGet('user'); + $expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]')); + + // After the page loaded we need to additionally wait until the settings + // tray Ajax activity is done. + $web_assert->assertWaitOnAjaxRequest(); + + $unrestricted_tab_count = $this->getTabbableElementsCount(); + $this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count); + + // Enable edit mode. + // After the first page load the page will be in edit mode when loaded. + $this->pressToolbarEditButton(); + + $this->assertAnnounceEditMode(); + $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); + + // Disable edit mode. + $this->pressToolbarEditButton(); + $this->assertAnnounceLeaveEditMode(); + $this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount()); + // Enable edit mode again. + $this->pressToolbarEditButton(); + // Finally assert that the 'edit mode enabled' announcement is still + // correct after toggling the edit mode at least once. + $this->assertAnnounceEditMode(); + $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); + + // Test while Edit Mode is enabled it doesn't interfere with pages with + // no contextual links. + $this->drupalGet('admin/structure/block'); + $web_assert->elementContains('css', 'h1.page-title', 'Block layout'); + $this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]'))); + $this->assertGreaterThan(0, $this->getTabbableElementsCount()); } /** diff --git a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php index f2c2578f320..d6dee40b55e 100644 --- a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php +++ b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\datetime\Functional; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Datetime\Entity\DateFormat; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; @@ -190,11 +189,15 @@ class DateTimeFieldTest extends DateTestBase { $display_repository->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); - $expected = new FormattableMarkup($this->displayOptions['settings']['past_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]), - ]); + $expected = str_replace( + '@interval', + $this->dateFormatter->formatTimeDiffSince( + $timestamp, + ['granularity' => $this->displayOptions['settings']['granularity']]), + $this->displayOptions['settings']['past_format'] + ); $output = $this->renderTestEntity($id); - $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone."); + $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone."); // Verify that the 'datetime_time_ago' formatter works for intervals in // the future. First update the test entity so that the date difference @@ -211,11 +214,15 @@ class DateTimeFieldTest extends DateTestBase { $display_repository->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); - $expected = new FormattableMarkup($this->displayOptions['settings']['future_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]), - ]); + $expected = str_replace( + '@interval', + $this->dateFormatter->formatTimeDiffUntil( + $timestamp, + ['granularity' => $this->displayOptions['settings']['granularity']]), + $this->displayOptions['settings']['future_format'] + ); $output = $this->renderTestEntity($id); - $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone."); + $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone."); } } @@ -341,11 +348,15 @@ class DateTimeFieldTest extends DateTestBase { $display_repository->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); - $expected = new FormattableMarkup($this->displayOptions['settings']['past_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]), - ]); + $expected = str_replace( + '@interval', + $this->dateFormatter->formatTimeDiffSince( + $timestamp, + ['granularity' => $this->displayOptions['settings']['granularity']]), + $this->displayOptions['settings']['past_format'] + ); $output = $this->renderTestEntity($id); - $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected."); + $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected."); // Verify that the 'datetime_time_ago' formatter works for intervals in the // future. First update the test entity so that the date difference always @@ -363,11 +374,15 @@ class DateTimeFieldTest extends DateTestBase { ->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full') ->setComponent($field_name, $this->displayOptions) ->save(); - $expected = new FormattableMarkup($this->displayOptions['settings']['future_format'], [ - '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]), - ]); + $expected = str_replace( + '@interval', + $this->dateFormatter->formatTimeDiffUntil( + $timestamp, + ['granularity' => $this->displayOptions['settings']['granularity']]), + $this->displayOptions['settings']['future_format'] + ); $output = $this->renderTestEntity($id); - $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected."); + $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected."); // Test the required field validation error message. $entity = EntityTest::create(['name' => 'test datetime required message']); @@ -375,9 +390,9 @@ class DateTimeFieldTest extends DateTestBase { $form_state = new FormState(); \Drupal::formBuilder()->submitForm($form, $form_state); $errors = $form_state->getErrors(); - $expected_error_message = new FormattableMarkup('The %field date is required.', ['%field' => $field_label]); + $expected_error_message = "The <em class=\"placeholder\">$field_label</em> date is required."; $actual_error_message = $errors["{$field_name}][0][value"]->__toString(); - $this->assertEquals($expected_error_message->__toString(), $actual_error_message); + $this->assertEquals($expected_error_message, $actual_error_message); } /** diff --git a/core/modules/dblog/tests/src/Functional/DbLogTest.php b/core/modules/dblog/tests/src/Functional/DbLogTest.php index 95c46392443..d1a09aed265 100644 --- a/core/modules/dblog/tests/src/Functional/DbLogTest.php +++ b/core/modules/dblog/tests/src/Functional/DbLogTest.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace Drupal\Tests\dblog\Functional; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Unicode; use Drupal\Core\Database\Database; use Drupal\Core\Logger\RfcLogLevel; use Drupal\Core\Link; use Drupal\Core\Url; use Drupal\dblog\Controller\DbLogController; -use Drupal\error_test\Controller\ErrorTestController; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\system\Functional\Menu\AssertBreadcrumbTrait; @@ -914,16 +912,9 @@ class DbLogTest extends BrowserTestBase { $wid = $query->execute()->fetchField(); $this->drupalGet('admin/reports/dblog/event/' . $wid); - $error_user_notice = [ - '%type' => 'User warning', - '@message' => 'Drupal & awesome', - '%function' => ErrorTestController::class . '->generateWarnings()', - '%file' => $this->getModulePath('error_test') . '/error_test.module', - ]; - // Check if the full message displays on the details page and backtrace is a // pre-formatted text. - $message = new FormattableMarkup('%type: @message in %function (line', $error_user_notice); + $message = '<em class="placeholder">User warning</em>: Drupal & awesome in <em class="placeholder">Drupal\error_test\Controller\ErrorTestController->generateWarnings()</em> (line'; $this->assertSession()->responseContains($message); $this->assertSession()->responseContains('<pre class="backtrace">'); } diff --git a/core/modules/editor/tests/src/Functional/EditorAdminTest.php b/core/modules/editor/tests/src/Functional/EditorAdminTest.php index 639aa030618..12ec751f41c 100644 --- a/core/modules/editor/tests/src/Functional/EditorAdminTest.php +++ b/core/modules/editor/tests/src/Functional/EditorAdminTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\editor\Functional; -use Drupal\Component\Render\FormattableMarkup; +use Drupal\Component\Utility\Html; use Drupal\filter\Entity\FilterFormat; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; @@ -142,7 +142,7 @@ class EditorAdminTest extends BrowserTestBase { $this->drupalLogin($account); // The node edit page header. - $text = (string) new FormattableMarkup('<em>Edit @type</em> @title', ['@type' => $node_type->label(), '@title' => $node->label()]); + $text = sprintf('<em>Edit %s</em> %s', Html::escape($node_type->label()), Html::escape($node->label())); // Go to node edit form. $this->drupalGet('node/' . $node->id() . '/edit'); diff --git a/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php b/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php index de14164bd80..48a5c652c8c 100644 --- a/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php +++ b/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\field\Functional\FunctionalString; -use Drupal\Component\Render\FormattableMarkup; use Drupal\entity_test\Entity\EntityTest; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; @@ -95,7 +94,7 @@ class StringFieldTest extends BrowserTestBase { $this->drupalGet('entity_test/add'); $this->assertSession()->fieldValueEquals("{$field_name}[0][value]", ''); $this->assertSession()->fieldNotExists("{$field_name}[0][format]"); - $this->assertSession()->responseContains(new FormattableMarkup('placeholder="A placeholder on @widget_type"', ['@widget_type' => $widget_type])); + $this->assertSession()->responseContains('placeholder="A placeholder on ' . $widget_type . '"'); // Submit with some value. $value = $this->randomMachineName(); diff --git a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php index 129f28576d6..76907277eae 100644 --- a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php +++ b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\field\FunctionalJavascript\EntityReference; use Behat\Mink\Element\NodeElement; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; @@ -317,10 +316,9 @@ class EntityReferenceAdminTest extends WebDriverTestBase { // Try to select the views handler. $this->drupalGet($bundle_path . '/fields/' . $field_name); $page->findField('settings[handler]')->setValue('views'); - $views_text = (string) new FormattableMarkup('No eligible views were found. <a href=":create">Create a view</a> with an <em>Entity Reference</em> display, or add such a display to an <a href=":existing">existing view</a>.', [ - ':create' => Url::fromRoute('views_ui.add')->toString(), - ':existing' => Url::fromRoute('entity.view.collection')->toString(), - ]); + $create = Url::fromRoute('views_ui.add')->toString(); + $existing = Url::fromRoute('entity.view.collection')->toString(); + $views_text = 'No eligible views were found. <a href="' . $create . '">Create a view</a> with an <em>Entity Reference</em> display, or add such a display to an <a href="' . $existing . '">existing view</a>.'; $assert_session->waitForElement('xpath', '//a[contains(text(), "Create a view")]'); $assert_session->responseContains($views_text); diff --git a/core/modules/file/tests/src/Functional/SaveUploadTest.php b/core/modules/file/tests/src/Functional/SaveUploadTest.php index 66cfe08cad3..14509ea426c 100644 --- a/core/modules/file/tests/src/Functional/SaveUploadTest.php +++ b/core/modules/file/tests/src/Functional/SaveUploadTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\file\Functional; -use Drupal\Component\Render\FormattableMarkup; +use Drupal\Component\Utility\Html; use Drupal\Core\File\FileExists; use Drupal\Core\Url; use Drupal\file\Entity\File; @@ -738,8 +738,8 @@ class SaveUploadTest extends FileManagedTestBase { $content = (string) $response->getBody(); $this->htmlOutput($content); - $error_text = new FormattableMarkup('The file %filename could not be uploaded because the name is invalid.', ['%filename' => $filename]); - $this->assertStringContainsString((string) $error_text, $content); + $error_text = 'The file <em class="placeholder">' . Html::escape($filename) . '</em> could not be uploaded because the name is invalid.'; + $this->assertStringContainsString($error_text, $content); $this->assertStringContainsString('Epic upload FAIL!', $content); $this->assertFileDoesNotExist('temporary://' . $filename); } diff --git a/core/modules/help/src/HelpTopicTwigLoader.php b/core/modules/help/src/HelpTopicTwigLoader.php index fc2e61bbaaf..9178166597c 100644 --- a/core/modules/help/src/HelpTopicTwigLoader.php +++ b/core/modules/help/src/HelpTopicTwigLoader.php @@ -96,7 +96,7 @@ class HelpTopicTwigLoader extends FilesystemLoader { /** * {@inheritdoc} */ - protected function findTemplate($name, $throw = TRUE) { + protected function findTemplate($name, $throw = TRUE): ?string { if (!str_ends_with($name, '.html.twig')) { if (!$throw) { return NULL; diff --git a/core/modules/help/src/HelpTwigExtension.php b/core/modules/help/src/HelpTwigExtension.php index e41ad66503d..b8a77a914f6 100644 --- a/core/modules/help/src/HelpTwigExtension.php +++ b/core/modules/help/src/HelpTwigExtension.php @@ -41,7 +41,7 @@ class HelpTwigExtension extends AbstractExtension { /** * {@inheritdoc} */ - public function getFunctions() { + public function getFunctions(): array { return [ new TwigFunction('help_route_link', [$this, 'getRouteLink']), new TwigFunction('help_topic_link', [$this, 'getTopicLink']), diff --git a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php index f54e15e882a..abe16ebdb48 100644 --- a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php +++ b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php @@ -14,7 +14,7 @@ class HelpTestTwigExtension extends AbstractExtension { /** * {@inheritdoc} */ - public function getNodeVisitors() { + public function getNodeVisitors(): array { return [ new HelpTestTwigNodeVisitor(), ]; diff --git a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php index 953f2aa2ce4..9c53a2e0cf3 100644 --- a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php +++ b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php @@ -97,7 +97,7 @@ class HelpTestTwigNodeVisitor implements NodeVisitorInterface { /** * {@inheritdoc} */ - public function getPriority() { + public function getPriority(): int { return -100; } diff --git a/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php b/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php index 1e182076608..13e6bdffda1 100644 --- a/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php +++ b/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php @@ -6,8 +6,8 @@ namespace Drupal\Tests\help\Unit; use Drupal\Core\Cache\Cache; use Drupal\help\HelpTopicTwig; -use Drupal\Tests\Core\Template\StubTwigTemplate; use Drupal\Tests\UnitTestCase; +use Twig\Template; use Twig\TemplateWrapper; /** @@ -101,8 +101,8 @@ class HelpTopicTwigTest extends UnitTestCase { ->getMock(); $template = $this - ->getMockBuilder(StubTwigTemplate::class) - ->onlyMethods(['render']) + ->getMockBuilder(Template::class) + ->onlyMethods(['render', 'getTemplateName', 'getDebugInfo', 'getSourceContext', 'doDisplay']) ->setConstructorArgs([$twig]) ->getMock(); diff --git a/core/modules/image/config/install/image.style.large.yml b/core/modules/image/config/install/image.style.large.yml index e0b8394552e..1e327eea8e5 100644 --- a/core/modules/image/config/install/image.style.large.yml +++ b/core/modules/image/config/install/image.style.large.yml @@ -14,7 +14,7 @@ effects: upscale: false 6e8fe467-84c1-4ef0-a73b-7eccf1cc20e8: uuid: 6e8fe467-84c1-4ef0-a73b-7eccf1cc20e8 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/install/image.style.medium.yml b/core/modules/image/config/install/image.style.medium.yml index f096610c659..d7ea09a6789 100644 --- a/core/modules/image/config/install/image.style.medium.yml +++ b/core/modules/image/config/install/image.style.medium.yml @@ -14,7 +14,7 @@ effects: upscale: false c410ed2f-aa30-4d9c-a224-d2865d9188cd: uuid: c410ed2f-aa30-4d9c-a224-d2865d9188cd - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/install/image.style.thumbnail.yml b/core/modules/image/config/install/image.style.thumbnail.yml index c03c60e00e2..c2d7a4e5042 100644 --- a/core/modules/image/config/install/image.style.thumbnail.yml +++ b/core/modules/image/config/install/image.style.thumbnail.yml @@ -14,7 +14,7 @@ effects: upscale: false c4eb9942-2c9e-4a81-949f-6161a44b6559: uuid: c4eb9942-2c9e-4a81-949f-6161a44b6559 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/install/image.style.wide.yml b/core/modules/image/config/install/image.style.wide.yml index 8573ae26346..b62e05f3e38 100644 --- a/core/modules/image/config/install/image.style.wide.yml +++ b/core/modules/image/config/install/image.style.wide.yml @@ -14,7 +14,7 @@ effects: upscale: false 294c5f76-42a4-43ce-82c2-81c2f4723da0: uuid: 294c5f76-42a4-43ce-82c2-81c2f4723da0 - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/image/config/schema/image.schema.yml b/core/modules/image/config/schema/image.schema.yml index f805caa378c..68edccf507a 100644 --- a/core/modules/image/config/schema/image.schema.yml +++ b/core/modules/image/config/schema/image.schema.yml @@ -52,6 +52,10 @@ image.effect.image_convert: Choice: callback: 'Drupal\Core\ImageToolkit\ImageToolkitManager::getAllValidExtensions' +image.effect.image_convert_avif: + type: image.effect.image_convert + label: 'Convert to AVIF' + image.effect.image_resize: type: image_size label: 'Image resize' diff --git a/core/modules/image/src/Hook/ImageRequirements.php b/core/modules/image/src/Hook/ImageRequirements.php index cf631bfe375..e1018cf539b 100644 --- a/core/modules/image/src/Hook/ImageRequirements.php +++ b/core/modules/image/src/Hook/ImageRequirements.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\image\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\ImageToolkit\ImageToolkitManager; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -46,7 +47,7 @@ class ImageRequirements { 'title' => $this->t('Image toolkit'), 'value' => $this->t('None'), 'description' => $this->t("No image toolkit is configured on the site. Check PHP installed extensions or add a contributed toolkit that doesn't require a PHP extension. Make sure that at least one valid image toolkit is installed."), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], ]; } diff --git a/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php b/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php new file mode 100644 index 00000000000..595743eece7 --- /dev/null +++ b/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\image\Plugin\ImageEffect; + +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Image\ImageInterface; +use Drupal\Core\ImageToolkit\ImageToolkitManager; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\image\Attribute\ImageEffect; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Converts an image resource to AVIF, with fallback. + */ +#[ImageEffect( + id: "image_convert_avif", + label: new TranslatableMarkup("Convert to AVIF"), + description: new TranslatableMarkup("Converts an image to AVIF, with a fallback if AVIF is not supported."), +)] +class AvifImageEffect extends ConvertImageEffect { + + /** + * The image toolkit manager. + * + * @var \Drupal\Core\ImageToolkit\ImageToolkitManager + */ + protected ImageToolkitManager $imageToolkitManager; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition); + $instance->imageToolkitManager = $container->get(ImageToolkitManager::class); + return $instance; + } + + /** + * {@inheritdoc} + */ + public function applyEffect(ImageInterface $image) { + // If avif is not supported fallback to the parent. + if (!$this->isAvifSupported()) { + return parent::applyEffect($image); + } + + if (!$image->convert('avif')) { + $this->logger->error('Image convert failed using the %toolkit toolkit on %path (%mimetype)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType()]); + return FALSE; + } + + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function getDerivativeExtension($extension) { + return $this->isAvifSupported() ? 'avif' : $this->configuration['extension']; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + unset($form['extension']['#options']['avif']); + $form['extension']['#title'] = $this->t('Fallback format'); + $form['extension']['#description'] = $this->t('Format to use if AVIF is not available.'); + return $form; + } + + /** + * Is AVIF supported by the image toolkit. + */ + protected function isAvifSupported(): bool { + return in_array('avif', $this->imageToolkitManager->getDefaultToolkit()->getSupportedExtensions()); + } + +} diff --git a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php index 1e5c7533922..54130e7818b 100644 --- a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php +++ b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php @@ -120,6 +120,31 @@ class ImageEffectsTest extends KernelTestBase { } /** + * Tests the 'image_convert_avif' effect when avif is supported. + */ + public function testConvertAvifEffect(): void { + $this->container->get('keyvalue')->get('image_test')->set('avif_enabled', TRUE); + $this->assertImageEffect(['convert'], 'image_convert_avif', [ + 'extension' => 'webp', + ]); + + $calls = $this->imageTestGetAllCalls(); + $this->assertEquals('avif', $calls['convert'][0][0]); + } + + /** + * Tests the 'image_convert_avif' effect with webp fallback. + */ + public function testConvertAvifEffectFallback(): void { + $this->assertImageEffect(['convert'], 'image_convert_avif', [ + 'extension' => 'webp', + ]); + + $calls = $this->imageTestGetAllCalls(); + $this->assertEquals('webp', $calls['convert'][0][0]); + } + + /** * Tests the 'image_scale_and_crop' effect. */ public function testScaleAndCropEffect(): void { diff --git a/core/modules/jsonapi/src/Hook/JsonapiRequirements.php b/core/modules/jsonapi/src/Hook/JsonapiRequirements.php index 5cc0225e183..4903389fddf 100644 --- a/core/modules/jsonapi/src/Hook/JsonapiRequirements.php +++ b/core/modules/jsonapi/src/Hook/JsonapiRequirements.php @@ -6,6 +6,7 @@ namespace Drupal\jsonapi\Hook; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Url; @@ -40,7 +41,7 @@ class JsonapiRequirements { $requirements['jsonapi_multilingual_support'] = [ 'title' => $this->t('JSON:API multilingual support'), 'value' => $this->t('Limited'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => $this->t('Some multilingual features currently do not work well with JSON:API. See the <a href=":jsonapi-docs">JSON:API multilingual support documentation</a> for more information on the current status of multilingual support.', [ ':jsonapi-docs' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/translations', ]), @@ -49,7 +50,7 @@ class JsonapiRequirements { $requirements['jsonapi_revision_support'] = [ 'title' => $this->t('JSON:API revision support'), 'value' => $this->t('Limited'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => $this->t('Revision support is currently read-only and only for the "Content" and "Media" entity types in JSON:API. See the <a href=":jsonapi-docs">JSON:API revision support documentation</a> for more information on the current status of revision support.', [ ':jsonapi-docs' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/revisions', ]), @@ -57,7 +58,7 @@ class JsonapiRequirements { $requirements['jsonapi_read_only_mode'] = [ 'title' => $this->t('JSON:API allowed operations'), 'value' => $this->t('Read-only'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; if (!$this->configFactory->get('jsonapi.settings')->get('read_only')) { $requirements['jsonapi_read_only_mode']['value'] = $this->t('All (create, read, update, delete)'); diff --git a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageNegotiationSettingsTest.php b/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageNegotiationSettingsTest.php index 54fdf4b0d21..fe5ff0ca062 100644 --- a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageNegotiationSettingsTest.php +++ b/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageNegotiationSettingsTest.php @@ -10,6 +10,7 @@ use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase; /** * Tests the migration of language negotiation. * + * @group #slow * @group migrate_drupal_7 */ class MigrateLanguageNegotiationSettingsTest extends MigrateDrupal7TestBase { diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php index 4067edd2616..dc44888a8b2 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php @@ -7,7 +7,6 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Behat\Mink\Element\NodeElement; use Drupal\block_content\Entity\BlockContent; use Drupal\block_content\Entity\BlockContentType; -use Drupal\Component\Render\FormattableMarkup; use Drupal\FunctionalJavascriptTests\JSWebAssert; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; @@ -190,7 +189,7 @@ class LayoutBuilderDisableInteractionsTest extends WebDriverTestBase { try { $element->click(); $tag_name = $element->getTagName(); - $this->fail(new FormattableMarkup("@tag_name was clickable when it shouldn't have been", ['@tag_name' => $tag_name])); + $this->fail("$tag_name was clickable when it shouldn't have been"); } catch (\Exception $e) { $this->assertTrue(JSWebAssert::isExceptionNotClickable($e)); diff --git a/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php b/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php index d6b8ef7fc24..fd056c38354 100644 --- a/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php +++ b/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\layout_discovery\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Install time requirements for the layout_discovery module. @@ -19,7 +20,7 @@ class LayoutDiscoveryRequirements implements InstallRequirementsInterface { if (\Drupal::moduleHandler()->moduleExists('layout_plugin')) { $requirements['layout_discovery'] = [ 'description' => t('Layout Discovery cannot be installed because the Layout Plugin module is installed and incompatible.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } return $requirements; diff --git a/core/modules/link/tests/src/Functional/LinkFieldUITest.php b/core/modules/link/tests/src/Functional/LinkFieldUITest.php index 694fb6b3677..5c78abc2391 100644 --- a/core/modules/link/tests/src/Functional/LinkFieldUITest.php +++ b/core/modules/link/tests/src/Functional/LinkFieldUITest.php @@ -15,6 +15,7 @@ use Drupal\Tests\field_ui\Traits\FieldUiTestTrait; * Tests link field UI functionality. * * @group link + * @group #slow */ class LinkFieldUITest extends BrowserTestBase { diff --git a/core/modules/locale/src/Hook/LocaleRequirements.php b/core/modules/locale/src/Hook/LocaleRequirements.php index 6664a64d42b..988c5fcbdd3 100644 --- a/core/modules/locale/src/Hook/LocaleRequirements.php +++ b/core/modules/locale/src/Hook/LocaleRequirements.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\locale\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Link; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -46,7 +47,7 @@ class LocaleRequirements { $requirements['locale_translation'] = [ 'title' => $this->t('Translation update status'), 'value' => Link::fromTextAndUrl($this->t('Updates available'), Url::fromRoute('locale.translate_status'))->toString(), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => $this->t('Updates available for: @languages. See the <a href=":updates">Available translation updates</a> page for more information.', ['@languages' => implode(', ', $available_updates), ':updates' => Url::fromRoute('locale.translate_status')->toString()]), ]; } @@ -54,7 +55,7 @@ class LocaleRequirements { $requirements['locale_translation'] = [ 'title' => $this->t('Translation update status'), 'value' => $this->t('Missing translations'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => $this->t('Missing translations for: @languages. See the <a href=":updates">Available translation updates</a> page for more information.', ['@languages' => implode(', ', $untranslated), ':updates' => Url::fromRoute('locale.translate_status')->toString()]), ]; } @@ -63,7 +64,7 @@ class LocaleRequirements { $requirements['locale_translation'] = [ 'title' => $this->t('Translation update status'), 'value' => $this->t('Up to date'), - 'severity' => REQUIREMENT_OK, + 'severity' => RequirementSeverity::OK, ]; } } @@ -71,7 +72,7 @@ class LocaleRequirements { $requirements['locale_translation'] = [ 'title' => $this->t('Translation update status'), 'value' => Link::fromTextAndUrl($this->t('Can not determine status'), Url::fromRoute('locale.translate_status'))->toString(), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => $this->t('No translation status is available. See the <a href=":updates">Available translation updates</a> page for more information.', [':updates' => Url::fromRoute('locale.translate_status')->toString()]), ]; } diff --git a/core/modules/locale/src/Hook/LocaleThemeHooks.php b/core/modules/locale/src/Hook/LocaleThemeHooks.php index d1e438f50ac..4ef5ca0b498 100644 --- a/core/modules/locale/src/Hook/LocaleThemeHooks.php +++ b/core/modules/locale/src/Hook/LocaleThemeHooks.php @@ -2,7 +2,7 @@ namespace Drupal\locale\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Language\LanguageManagerInterface; @@ -18,7 +18,7 @@ class LocaleThemeHooks { /** * Implements hook_preprocess_HOOK() for node templates. */ - #[Preprocess('node')] + #[Hook('preprocess_node')] public function preprocessNode(&$variables): void { /** @var \Drupal\node\NodeInterface $node */ $node = $variables['node']; diff --git a/core/modules/mailer/mailer.info.yml b/core/modules/mailer/mailer.info.yml new file mode 100644 index 00000000000..40c9afb713a --- /dev/null +++ b/core/modules/mailer/mailer.info.yml @@ -0,0 +1,6 @@ +name: Mailer +type: module +description: 'Provides an experimental API to build and deliver email messages.' +package: Core (Experimental) +lifecycle: experimental +version: VERSION diff --git a/core/modules/mailer/mailer.services.yml b/core/modules/mailer/mailer.services.yml new file mode 100644 index 00000000000..d69c04a8461 --- /dev/null +++ b/core/modules/mailer/mailer.services.yml @@ -0,0 +1,48 @@ +services: + _defaults: + autoconfigure: true + Symfony\Component\Mailer\Transport\AbstractTransportFactory: + abstract: true + arguments: + - '@Psr\EventDispatcher\EventDispatcherInterface' + - '@?Symfony\Contracts\HttpClient\HttpClientInterface' + # No logger injected on purpose. Log messages generated by transports are + # of little practical use and can lead to errors when a transport instance + # is destructed at the end of a request. + # See: https://www.drupal.org/i/3420372 + - null + public: false + Symfony\Component\Mailer\Transport\NativeTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory } + Symfony\Component\Mailer\Transport\NullTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory } + Symfony\Component\Mailer\Transport\SendmailTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory } + Drupal\Core\Mailer\Transport\SendmailCommandValidationTransportFactory: + decorates: Symfony\Component\Mailer\Transport\SendmailTransportFactory + autowire: true + public: false + Symfony\Component\Mailer\Transport\Smtp\EsmtpTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory, priority: -100 } + Drupal\Core\Mailer\TransportServiceFactory: + autowire: true + public: false + Drupal\Core\Mailer\TransportServiceFactoryInterface: '@Drupal\Core\Mailer\TransportServiceFactory' + Symfony\Component\Mailer\Transport\TransportInterface: + factory: ['@Drupal\Core\Mailer\TransportServiceFactoryInterface', 'createTransport'] + Symfony\Component\Mailer\Messenger\MessageHandler: + autowire: true + public: false + tags: + - { name: messenger.message_handler } + Symfony\Component\Mailer\Mailer: + autowire: true + Symfony\Component\Mailer\MailerInterface: '@Symfony\Component\Mailer\Mailer' diff --git a/core/modules/mailer/src/Hook/MailerHooks.php b/core/modules/mailer/src/Hook/MailerHooks.php new file mode 100644 index 00000000000..6e1b22e3380 --- /dev/null +++ b/core/modules/mailer/src/Hook/MailerHooks.php @@ -0,0 +1,35 @@ +<?php + +namespace Drupal\mailer\Hook; + +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * Hook implementations for mailer. + */ +class MailerHooks { + + use StringTranslationTrait; + + /** + * Implements hook_help(). + */ + #[Hook('help')] + public function help($route_name, RouteMatchInterface $route_match) : ?string { + switch ($route_name) { + case 'help.page.mailer': + $output = ''; + $output .= '<h3>' . $this->t('About') . '</h3>'; + $output .= '<p>' . $this->t('The Mailer module provides an experimental API to build and deliver email messages based on Symfony mailer component. For more information, see the <a href=":mailer">online documentation for the Mailer module</a>.', [ + ':mailer' => 'https://www.drupal.org/docs/core-modules-and-themes/experimental-extensions/experimental-modules/mailer', + ]) . '</p>'; + return $output; + + default: + return NULL; + } + } + +} diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml new file mode 100644 index 00000000000..731d5b9bfee --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml @@ -0,0 +1,5 @@ +name: 'Mailer transport factory functional test' +type: module +description: 'Support module for mailer transport factory functional testing.' +package: Testing +version: VERSION diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml new file mode 100644 index 00000000000..8294939c42f --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml @@ -0,0 +1,6 @@ +mailer_transport_factory_functional_test.transport_info: + path: '/mailer-transport-factory-functional-test/transport-info' + defaults: + _controller: '\Drupal\mailer_transport_factory_functional_test\Controller\TransportInfoController::transportInfo' + requirements: + _access: 'TRUE' diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php new file mode 100644 index 00000000000..6f26f95ee81 --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mailer_transport_factory_functional_test\Controller; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * Returns responses for transport info routes. + */ +class TransportInfoController implements ContainerInjectionInterface { + + /** + * Constructs a new transport info controller. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory. + * @param \Symfony\Component\Mailer\Transport\TransportInterface $transport + * The mailer transport. + */ + public function __construct( + protected ConfigFactoryInterface $configFactory, + protected TransportInterface $transport, + ) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): static { + return new static( + $container->get(ConfigFactoryInterface::class), + $container->get(TransportInterface::class) + ); + } + + /** + * Returns info about the configured mailer dsn and the resulting transport. + */ + public function transportInfo(): Response { + $mailerDsn = $this->configFactory->get('system.mail')->get('mailer_dsn'); + return new JsonResponse([ + 'mailerDsn' => $mailerDsn, + 'mailerTransportClass' => $this->transport::class, + ]); + } + +} diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml new file mode 100644 index 00000000000..cbe2e01e9b4 --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml @@ -0,0 +1,5 @@ +name: 'Mailer transport factory kernel test' +type: module +description: 'Support module for mailer transport factory kernel testing.' +package: Testing +version: VERSION diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml new file mode 100644 index 00000000000..1d9dec1cd6b --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml @@ -0,0 +1,7 @@ +services: + _defaults: + autoconfigure: true + Drupal\mailer_transport_factory_kernel_test\Transport\CanaryTransportFactory: + parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory + tags: + - { name: mailer.transport_factory } diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php new file mode 100644 index 00000000000..a13c57e140a --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mailer_transport_factory_kernel_test\Transport; + +use Symfony\Component\Mailer\SentMessage; +use Symfony\Component\Mailer\Transport\AbstractTransport; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * A transport only used to test the transport factory adapter. + */ +class CanaryTransport extends AbstractTransport implements TransportInterface { + + protected function doSend(SentMessage $message): void { + } + + /** + * {@inheritdoc} + */ + public function __toString(): string { + return 'drupal.test-canary://default'; + } + +} diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php new file mode 100644 index 00000000000..4ffc33dfe8a --- /dev/null +++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mailer_transport_factory_kernel_test\Transport; + +use Symfony\Component\Mailer\Exception\UnsupportedSchemeException; +use Symfony\Component\Mailer\Transport\AbstractTransportFactory; +use Symfony\Component\Mailer\Transport\Dsn; +use Symfony\Component\Mailer\Transport\TransportFactoryInterface; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * A transport factory only used to test the transport factory adapter. + */ +class CanaryTransportFactory extends AbstractTransportFactory implements TransportFactoryInterface { + + protected function getSupportedSchemes(): array { + return ['drupal.test-canary']; + } + + /** + * {@inheritdoc} + */ + public function create(Dsn $dsn): TransportInterface { + if ($dsn->getScheme() === 'drupal.test-canary') { + return new CanaryTransport($this->dispatcher, $this->logger); + } + + throw new UnsupportedSchemeException($dsn, 'test_canary', $this->getSupportedSchemes()); + } + +} diff --git a/core/modules/mailer/tests/src/Functional/GenericTest.php b/core/modules/mailer/tests/src/Functional/GenericTest.php new file mode 100644 index 00000000000..e6c24144c70 --- /dev/null +++ b/core/modules/mailer/tests/src/Functional/GenericTest.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mailer\Functional; + +use Drupal\Tests\system\Functional\Module\GenericModuleTestBase; + +/** + * Generic module test for mailer. + * + * @group mailer + */ +class GenericTest extends GenericModuleTestBase {} diff --git a/core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php b/core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php new file mode 100644 index 00000000000..318b60829db --- /dev/null +++ b/core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mailer\Functional; + +use Drupal\Tests\BrowserTestBase; +use Symfony\Component\Mailer\Transport\NullTransport; + +/** + * Tests the transport service factory in the child site of browser tests. + * + * @group mailer + */ +class TransportServiceFactoryTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'mailer', + 'mailer_transport_factory_functional_test', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Test that the transport is set to null://null by default in the child site. + * + * The mailer configuration is set to a safe default during test setUp by + * FunctionalTestSetupTrait::initConfig(). This is in order to prevent tests + * from accidentally sending out emails. This test ensures that the transport + * service is configured correctly in the test child site. + */ + public function testDefaultTestMailFactory(): void { + $response = $this->drupalGet('mailer-transport-factory-functional-test/transport-info'); + $actual = json_decode($response, TRUE); + + $expected = [ + 'mailerDsn' => [ + 'scheme' => 'null', + 'host' => 'null', + 'user' => NULL, + 'password' => NULL, + 'port' => NULL, + 'options' => [], + ], + 'mailerTransportClass' => NullTransport::class, + ]; + $this->assertEquals($expected, $actual); + } + +} diff --git a/core/modules/mailer/tests/src/Kernel/TransportTest.php b/core/modules/mailer/tests/src/Kernel/TransportTest.php new file mode 100644 index 00000000000..f686fe86cc3 --- /dev/null +++ b/core/modules/mailer/tests/src/Kernel/TransportTest.php @@ -0,0 +1,160 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mailer\Kernel; + +use Drupal\Core\Site\Settings; +use Drupal\KernelTests\KernelTestBase; +use Drupal\mailer_transport_factory_kernel_test\Transport\CanaryTransport; +use PHPUnit\Framework\Attributes\After; +use Symfony\Component\Mailer\Transport\NullTransport; +use Symfony\Component\Mailer\Transport\SendmailTransport; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mailer\Transport\TransportInterface; + +/** + * Tests the transport factory service. + * + * @group mailer + * @coversDefaultClass \Drupal\Core\Mailer\TransportServiceFactory + */ +class TransportTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['mailer', 'system']; + + /** + * Sets up a mailer DSN config override. + * + * @param string $scheme + * The mailer DSN scheme. + * @param string $host + * The mailer DSN host. + * @param string|null $user + * The mailer DSN username. + * @param string|null $password + * The mailer DSN password. + * @param int|null $port + * The mailer DSN port. + * @param array<string, mixed> $options + * Options for the mailer transport. + */ + protected function setUpMailerDsnConfigOverride( + string $scheme, + string $host, + ?string $user = NULL, + #[\SensitiveParameter] ?string $password = NULL, + ?int $port = NULL, + array $options = [], + ): void { + $GLOBALS['config']['system.mail']['mailer_dsn'] = [ + 'scheme' => $scheme, + 'host' => $host, + 'user' => $user, + 'password' => $password, + 'port' => $port, + 'options' => $options, + ]; + } + + /** + * Resets a mailer DSN config override. + * + * Clean up the globals modified by setUpMailerDsnConfigOverride() during a + * test. + */ + #[After] + protected function resetMailerDsnConfigOverride(): void { + $this->setUpMailerDsnConfigOverride('null', 'null'); + } + + /** + * @covers ::createTransport + */ + public function testDefaultTestMailFactory(): void { + $actual = $this->container->get(TransportInterface::class); + $this->assertInstanceOf(NullTransport::class, $actual); + } + + /** + * @dataProvider providerTestBuiltinFactory + * @covers ::createTransport + */ + public function testBuiltinFactory(string $schema, string $host, string $expected): void { + $this->setUpMailerDsnConfigOverride($schema, $host); + + $actual = $this->container->get(TransportInterface::class); + $this->assertInstanceOf($expected, $actual); + } + + /** + * Provides test data for testBuiltinFactory(). + */ + public static function providerTestBuiltinFactory(): iterable { + yield ['null', 'null', NullTransport::class]; + yield ['sendmail', 'default', SendmailTransport::class]; + yield ['smtp', 'default', EsmtpTransport::class]; + } + + /** + * @covers ::createTransport + * @covers \Drupal\Core\Mailer\Transport\SendmailCommandValidationTransportFactory::create + */ + public function testSendmailFactoryAllowedCommand(): void { + // Test sendmail command allowlist. + $settings = Settings::getAll(); + $settings['mailer_sendmail_commands'] = ['/usr/local/bin/sendmail -bs']; + new Settings($settings); + + // Test allowlisted command. + $this->setUpMailerDsnConfigOverride('sendmail', 'default', options: [ + 'command' => '/usr/local/bin/sendmail -bs', + ]); + $actual = $this->container->get(TransportInterface::class); + $this->assertInstanceOf(SendmailTransport::class, $actual); + } + + /** + * @covers ::createTransport + * @covers \Drupal\Core\Mailer\Transport\SendmailCommandValidationTransportFactory::create + */ + public function testSendmailFactoryUnlistedCommand(): void { + // Test sendmail command allowlist. + $settings = Settings::getAll(); + $settings['mailer_sendmail_commands'] = ['/usr/local/bin/sendmail -bs']; + new Settings($settings); + + // Test unlisted command. + $this->setUpMailerDsnConfigOverride('sendmail', 'default', options: [ + 'command' => '/usr/bin/bc', + ]); + $this->expectExceptionMessage('Unsafe sendmail command /usr/bin/bc'); + $this->container->get(TransportInterface::class); + } + + /** + * @covers ::createTransport + */ + public function testMissingFactory(): void { + $this->setUpMailerDsnConfigOverride('drupal.no-transport', 'default'); + + $this->expectExceptionMessage('The "drupal.no-transport" scheme is not supported'); + $this->container->get(TransportInterface::class); + } + + /** + * @covers ::createTransport + */ + public function testThirdPartyFactory(): void { + $this->enableModules(['mailer_transport_factory_kernel_test']); + + $this->setUpMailerDsnConfigOverride('drupal.test-canary', 'default'); + + $actual = $this->container->get(TransportInterface::class); + $this->assertInstanceOf(CanaryTransport::class, $actual); + } + +} diff --git a/core/modules/media/src/Hook/MediaRequirementsHooks.php b/core/modules/media/src/Hook/MediaRequirementsHooks.php index cedbb2fd820..f431134b6f4 100644 --- a/core/modules/media/src/Hook/MediaRequirementsHooks.php +++ b/core/modules/media/src/Hook/MediaRequirementsHooks.php @@ -4,6 +4,7 @@ namespace Drupal\media\Hook; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -45,7 +46,7 @@ class MediaRequirementsHooks { '%type' => $type->label(), ] ), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; continue; } @@ -88,7 +89,7 @@ class MediaRequirementsHooks { '%type' => $type->label(), ] ), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } diff --git a/core/modules/media/src/Install/Requirements/MediaRequirements.php b/core/modules/media/src/Install/Requirements/MediaRequirements.php index a69a79aaf81..9fa100ab974 100644 --- a/core/modules/media/src/Install/Requirements/MediaRequirements.php +++ b/core/modules/media/src/Install/Requirements/MediaRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\media\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\File\FileSystemInterface; /** @@ -31,7 +32,7 @@ class MediaRequirements implements InstallRequirementsInterface { $description = t('An automated attempt to create this directory failed, possibly due to a permissions problem. To proceed with the installation, either create the directory and modify its permissions manually or ensure that the installer has the permissions to create it automatically. For more information, see INSTALL.txt or the <a href=":handbook_url">online handbook</a>.', [':handbook_url' => 'https://www.drupal.org/server-permissions']); $description = $error . ' ' . $description; $requirements['media']['description'] = $description; - $requirements['media']['severity'] = REQUIREMENT_ERROR; + $requirements['media']['severity'] = RequirementSeverity::Error; } return $requirements; } diff --git a/core/modules/media_library/config/install/image.style.media_library.yml b/core/modules/media_library/config/install/image.style.media_library.yml index 5da64cfdcc3..4383a8c2cba 100644 --- a/core/modules/media_library/config/install/image.style.media_library.yml +++ b/core/modules/media_library/config/install/image.style.media_library.yml @@ -17,7 +17,7 @@ effects: upscale: false 1021da71-fc2a-43d0-be5d-efaf1c79e2ea: uuid: 1021da71-fc2a-43d0-be5d-efaf1c79e2ea - id: image_convert + id: image_convert_avif weight: 2 data: extension: webp diff --git a/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php index 3a1cb8a1b69..77c8b45d00f 100644 --- a/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php +++ b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php @@ -609,15 +609,15 @@ abstract class SourcePluginBase extends PluginBase implements MigrateSourceInter * {@inheritdoc} */ public function preRollback(MigrateRollbackEvent $event) { - // Nothing to do in this implementation. + // Reset the high-water mark. + $this->saveHighWater(NULL); } /** * {@inheritdoc} */ public function postRollback(MigrateRollbackEvent $event) { - // Reset the high-water mark. - $this->saveHighWater(NULL); + // Nothing to do in this implementation. } /** diff --git a/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php b/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php index 2f0b85ffbc4..e344e3e23e8 100644 --- a/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php +++ b/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php @@ -9,6 +9,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; use Drupal\Core\KeyValueStore\KeyValueStoreInterface; +use Drupal\migrate\Event\MigrateRollbackEvent; use Drupal\migrate\MigrateException; use Drupal\migrate\MigrateExecutable; use Drupal\migrate\MigrateSkipRowException; @@ -448,6 +449,32 @@ class MigrateSourceTest extends MigrateTestCase { return new MigrateExecutable($migration, $message, $event_dispatcher); } + /** + * @covers ::preRollback + */ + public function testPreRollback(): void { + $this->migrationConfiguration['id'] = 'test_migration'; + $plugin_id = 'test_migration'; + $migration = $this->getMigration(); + + // Verify that preRollback() sets the high water mark to NULL. + $key_value = $this->createMock(KeyValueStoreInterface::class); + $key_value->expects($this->once()) + ->method('set') + ->with($plugin_id, NULL); + $key_value_factory = $this->createMock(KeyValueFactoryInterface::class); + $key_value_factory->expects($this->once()) + ->method('get') + ->with('migrate:high_water') + ->willReturn($key_value); + $container = new ContainerBuilder(); + $container->set('keyvalue', $key_value_factory); + \Drupal::setContainer($container); + + $source = new StubSourceGeneratorPlugin([], $plugin_id, [], $migration); + $source->preRollback(new MigrateRollbackEvent($migration)); + } + } /** diff --git a/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php b/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php index 1f54f94848e..efe2b150928 100644 --- a/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php +++ b/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php @@ -18,6 +18,7 @@ use Drupal\field_discovery_test\FieldDiscoveryTestClass; * Test FieldDiscovery Service against Drupal 7. * * @group migrate_drupal + * @group #slow * @coversDefaultClass \Drupal\migrate_drupal\FieldDiscovery */ class FieldDiscoveryTest extends MigrateDrupal7TestBase { diff --git a/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php b/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php index ca8a9a0d06b..27ab60bc0c0 100644 --- a/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php +++ b/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php @@ -16,6 +16,7 @@ use Drupal\Tests\migrate_drupal\Traits\CreateTestContentEntitiesTrait; * Tests the migration auditor for ID conflicts. * * @group migrate_drupal + * @group #slow */ class MigrateDrupal7AuditIdsTest extends MigrateDrupal7TestBase { diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php index 64dc7a1ea86..daf06a65468 100644 --- a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php +++ b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php @@ -73,7 +73,7 @@ class Upgrade6Test extends MigrateUpgradeExecuteTestBase { */ protected function getEntityCounts(): array { return [ - 'block' => 37, + 'block' => 36, 'block_content' => 2, 'block_content_type' => 1, 'comment' => 8, diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php b/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php index 46b3447e159..f9b702d22e3 100644 --- a/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php +++ b/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php @@ -76,7 +76,7 @@ class Upgrade7Test extends MigrateUpgradeExecuteTestBase { */ protected function getEntityCounts(): array { return [ - 'block' => 27, + 'block' => 26, 'block_content' => 1, 'block_content_type' => 1, 'comment' => 4, diff --git a/core/modules/mysql/src/Hook/MysqlRequirements.php b/core/modules/mysql/src/Hook/MysqlRequirements.php index ef305d41a34..c3dfb10ca43 100644 --- a/core/modules/mysql/src/Hook/MysqlRequirements.php +++ b/core/modules/mysql/src/Hook/MysqlRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\mysql\Hook; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Render\Markup; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -46,18 +47,18 @@ class MysqlRequirements { $description = []; if ($isolation_level == 'READ-COMMITTED') { if (empty($tables_missing_primary_key)) { - $severity_level = REQUIREMENT_OK; + $severity_level = RequirementSeverity::OK; } else { - $severity_level = REQUIREMENT_ERROR; + $severity_level = RequirementSeverity::Error; } } else { if ($isolation_level == 'REPEATABLE-READ') { - $severity_level = REQUIREMENT_WARNING; + $severity_level = RequirementSeverity::Warning; } else { - $severity_level = REQUIREMENT_ERROR; + $severity_level = RequirementSeverity::Error; $description[] = $this->t('This is not supported by Drupal.'); } $description[] = $this->t('The recommended level for Drupal is "READ COMMITTED".'); diff --git a/core/modules/navigation/src/Hook/NavigationRequirements.php b/core/modules/navigation/src/Hook/NavigationRequirements.php index ae04608bfd6..f72877c04e4 100644 --- a/core/modules/navigation/src/Hook/NavigationRequirements.php +++ b/core/modules/navigation/src/Hook/NavigationRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\navigation\Hook; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -29,7 +30,7 @@ class NavigationRequirements { $requirements['toolbar'] = [ 'title' => $this->t('Toolbar and Navigation modules are both installed'), 'value' => $this->t('The Navigation module is a complete replacement for the Toolbar module and disables its functionality when both modules are installed. If you are planning to continue using Navigation module, you can uninstall the Toolbar module now.'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } return $requirements; diff --git a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php index 2371bef31aa..5bf9d2477f0 100644 --- a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php +++ b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php @@ -73,14 +73,14 @@ class PerformanceTest extends PerformanceTestBase { $expected = [ 'QueryCount' => 4, - 'CacheGetCount' => 48, + 'CacheGetCount' => 47, 'CacheGetCountByBin' => [ 'config' => 11, 'data' => 4, 'discovery' => 10, 'bootstrap' => 6, 'dynamic_page_cache' => 1, - 'render' => 15, + 'render' => 14, 'menu' => 1, ], 'CacheSetCount' => 2, @@ -89,9 +89,9 @@ class PerformanceTest extends PerformanceTestBase { ], 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, - 'CacheTagLookupQueryCount' => 14, + 'CacheTagLookupQueryCount' => 13, 'ScriptCount' => 3, - 'ScriptBytes' => 213500, + 'ScriptBytes' => 167569, 'StylesheetCount' => 2, 'StylesheetBytes' => 46000, ]; diff --git a/core/modules/node/js/node.preview.js b/core/modules/node/js/node.preview.js index 50bc58ade77..e23be0b71e2 100644 --- a/core/modules/node/js/node.preview.js +++ b/core/modules/node/js/node.preview.js @@ -34,13 +34,13 @@ const $previewDialog = $( `<div>${Drupal.theme('nodePreviewModal')}</div>`, ).appendTo('body'); - Drupal.dialog($previewDialog, { + const confirmationDialog = Drupal.dialog($previewDialog, { title: Drupal.t('Leave preview?'), buttons: [ { text: Drupal.t('Cancel'), click() { - $(this).dialog('close'); + confirmationDialog.close(); }, }, { @@ -50,7 +50,8 @@ }, }, ], - }).showModal(); + }); + confirmationDialog.showModal(); } } diff --git a/core/modules/node/src/Controller/NodeController.php b/core/modules/node/src/Controller/NodeController.php index e860d0c1d2a..d5a35f64285 100644 --- a/core/modules/node/src/Controller/NodeController.php +++ b/core/modules/node/src/Controller/NodeController.php @@ -3,7 +3,6 @@ namespace Drupal\node\Controller; use Drupal\Component\Utility\Xss; -use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Datetime\DateFormatterInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; @@ -197,10 +196,12 @@ class NodeController extends ControllerBase implements ContainerInjectionInterfa 'username' => $this->renderer->renderInIsolation($username), 'message' => ['#markup' => $revision->revision_log->value, '#allowed_tags' => Xss::getHtmlTagList()], ], + // @todo Fix this properly in https://www.drupal.org/project/drupal/issues/3227637. + '#cache' => [ + 'max-age' => 0, + ], ], ]; - // @todo Simplify once https://www.drupal.org/node/2334319 lands. - $this->renderer->addCacheableDependency($column['data'], CacheableMetadata::createFromRenderArray($username)); $row[] = $column; if ($is_current_revision) { diff --git a/core/modules/node/src/Hook/NodeHooks.php b/core/modules/node/src/Hook/NodeHooks.php index d5f84e0359b..8a6b4d887c8 100644 --- a/core/modules/node/src/Hook/NodeHooks.php +++ b/core/modules/node/src/Hook/NodeHooks.php @@ -66,4 +66,13 @@ class NodeHooks { } } + /** + * Implements hook_block_alter(). + */ + #[Hook('block_alter')] + public function blockAlter(&$definitions): void { + // Hide the deprecated Syndicate block from the UI. + $definitions['node_syndicate_block']['_block_ui_hidden'] = TRUE; + } + } diff --git a/core/modules/node/src/Hook/NodeRequirements.php b/core/modules/node/src/Hook/NodeRequirements.php index aa8b39d5682..84f74aee98c 100644 --- a/core/modules/node/src/Hook/NodeRequirements.php +++ b/core/modules/node/src/Hook/NodeRequirements.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\node\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Extension\ModuleExtensionList; @@ -144,7 +145,7 @@ class NodeRequirements { 'title' => $this->t('Content status filter'), 'value' => $this->t('Redundant filters detected'), 'description' => $node_status_filter_description, - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } } diff --git a/core/modules/node/src/Hook/NodeThemeHooks.php b/core/modules/node/src/Hook/NodeThemeHooks.php index 7ee443c458f..7ed0ef91f5f 100644 --- a/core/modules/node/src/Hook/NodeThemeHooks.php +++ b/core/modules/node/src/Hook/NodeThemeHooks.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\node\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for the node module. @@ -14,7 +14,7 @@ class NodeThemeHooks { /** * Implements hook_preprocess_HOOK() for node field templates. */ - #[Preprocess('field__node')] + #[Hook('preprocess_field__node')] public function preprocessFieldNode(&$variables): void { // Set a variable 'is_inline' in cases where inline markup is required, // without any block elements such as <div>. diff --git a/core/modules/node/src/NodeAccessControlHandler.php b/core/modules/node/src/NodeAccessControlHandler.php index 963ab53ded4..7121f62e283 100644 --- a/core/modules/node/src/NodeAccessControlHandler.php +++ b/core/modules/node/src/NodeAccessControlHandler.php @@ -223,7 +223,16 @@ class NodeAccessControlHandler extends EntityAccessControlHandler implements Nod return NULL; } + // When access is granted due to the 'view own unpublished content' + // permission and for no other reason, node grants are bypassed. However, + // to ensure the full set of cacheable metadata is available to variation + // cache, additionally add the node_grants cache context so that if the + // status or the owner of the node changes, cache redirects will continue to + // reflect the latest state without needing to be invalidated. $cacheability->addCacheContexts(['user']); + if ($this->moduleHandler->hasImplementations('node_grants')) { + $cacheability->addCacheContexts(['user.node_grants:view']); + } if ($account->id() != $node->getOwnerId()) { return NULL; } diff --git a/core/modules/node/src/NodePermissions.php b/core/modules/node/src/NodePermissions.php index e913f5326f3..5f651830192 100644 --- a/core/modules/node/src/NodePermissions.php +++ b/core/modules/node/src/NodePermissions.php @@ -2,6 +2,9 @@ namespace Drupal\node; +use Drupal\Core\DependencyInjection\AutowireTrait; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\BundlePermissionHandlerTrait; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\node\Entity\NodeType; @@ -9,19 +12,34 @@ use Drupal\node\Entity\NodeType; /** * Provides dynamic permissions for nodes of different types. */ -class NodePermissions { +class NodePermissions implements ContainerInjectionInterface { + + use AutowireTrait; use BundlePermissionHandlerTrait; use StringTranslationTrait; + public function __construct( + protected ?EntityTypeManagerInterface $entityTypeManager = NULL, + ) { + if ($entityTypeManager === NULL) { + @trigger_error('Calling ' . __METHOD__ . ' without the $entityTypeManager argument is deprecated in drupal:11.2.0 and it will be required in drupal:12.0.0. See https://www.drupal.org/node/3515921', E_USER_DEPRECATED); + $this->entityTypeManager = \Drupal::entityTypeManager(); + } + } + /** * Returns an array of node type permissions. * * @return array * The node type permissions. - * @see \Drupal\user\PermissionHandlerInterface::getPermissions() + * + * @see \Drupal\user\PermissionHandlerInterface::getPermissions() */ public function nodeTypePermissions() { - return $this->generatePermissions(NodeType::loadMultiple(), [$this, 'buildPermissions']); + return $this->generatePermissions( + $this->entityTypeManager->getStorage('node_type')->loadMultiple(), + [$this, 'buildPermissions'] + ); } /** diff --git a/core/modules/node/src/Plugin/Block/SyndicateBlock.php b/core/modules/node/src/Plugin/Block/SyndicateBlock.php index b10c63527e5..45cfe1eb45c 100644 --- a/core/modules/node/src/Plugin/Block/SyndicateBlock.php +++ b/core/modules/node/src/Plugin/Block/SyndicateBlock.php @@ -14,6 +14,11 @@ use Drupal\Core\Url; /** * Provides a 'Syndicate' block that links to the site's RSS feed. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. + * + * @see https://www.drupal.org/node/3519248 */ #[Block( id: "node_syndicate_block", @@ -43,6 +48,7 @@ class SyndicateBlock extends BlockBase implements ContainerFactoryPluginInterfac * The config factory. */ public function __construct(array $configuration, $plugin_id, $plugin_definition, ConfigFactoryInterface $configFactory) { + @trigger_error('The Syndicate block is deprecated in drupal:11.2.0 and will be removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3519248', E_USER_DEPRECATED); parent::__construct($configuration, $plugin_id, $plugin_definition); $this->configFactory = $configFactory; } diff --git a/core/modules/node/src/Plugin/views/UidRevisionTrait.php b/core/modules/node/src/Plugin/views/UidRevisionTrait.php new file mode 100644 index 00000000000..5cbf21d56d4 --- /dev/null +++ b/core/modules/node/src/Plugin/views/UidRevisionTrait.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\node\Plugin\views; + +/** + * Checks for nodes that a user posted or created a revision on. + */ +trait UidRevisionTrait { + + /** + * Checks for nodes that a user posted or created a revision on. + * + * @param array $uids + * A list of user ids. + * @param int $group + * See \Drupal\views\Plugin\views\query\Sql::addWhereExpression() $group. + */ + public function uidRevisionQuery(array $uids, int $group = 0): void { + $this->ensureMyTable(); + + // As per https://www.php.net/manual/en/pdo.prepare.php "you cannot use a + // named parameter marker of the same name more than once in a prepared + // statement". + $placeholder_1 = $this->placeholder() . '[]'; + $placeholder_2 = $this->placeholder() . '[]'; + + $args = array_values($uids); + + $this->query->addWhereExpression($group, "$this->tableAlias.uid IN ($placeholder_1) OR + EXISTS (SELECT 1 FROM {node_revision} nr WHERE nr.revision_uid IN ($placeholder_2) AND nr.nid = $this->tableAlias.nid)", [ + $placeholder_1 => $args, + $placeholder_2 => $args, + ]); + } + +} diff --git a/core/modules/node/src/Plugin/views/argument/UidRevision.php b/core/modules/node/src/Plugin/views/argument/UidRevision.php index 982152080a6..9be0cc9d7b6 100644 --- a/core/modules/node/src/Plugin/views/argument/UidRevision.php +++ b/core/modules/node/src/Plugin/views/argument/UidRevision.php @@ -2,6 +2,7 @@ namespace Drupal\node\Plugin\views\argument; +use Drupal\node\Plugin\views\UidRevisionTrait; use Drupal\user\Plugin\views\argument\Uid; use Drupal\views\Attribute\ViewsArgument; @@ -15,13 +16,13 @@ use Drupal\views\Attribute\ViewsArgument; )] class UidRevision extends Uid { + use UidRevisionTrait; + /** * {@inheritdoc} */ public function query($group_by = FALSE) { - $this->ensureMyTable(); - $placeholder = $this->placeholder(); - $this->query->addWhereExpression(0, "$this->tableAlias.uid = $placeholder OR ((SELECT COUNT(DISTINCT vid) FROM {node_revision} nr WHERE nr.revision_uid = $placeholder AND nr.nid = $this->tableAlias.nid) > 0)", [$placeholder => $this->argument]); + $this->uidRevisionQuery([$this->argument]); } } diff --git a/core/modules/node/src/Plugin/views/filter/UidRevision.php b/core/modules/node/src/Plugin/views/filter/UidRevision.php index b7f186fa07d..cf962a2897e 100644 --- a/core/modules/node/src/Plugin/views/filter/UidRevision.php +++ b/core/modules/node/src/Plugin/views/filter/UidRevision.php @@ -2,6 +2,7 @@ namespace Drupal\node\Plugin\views\filter; +use Drupal\node\Plugin\views\UidRevisionTrait; use Drupal\user\Plugin\views\filter\Name; use Drupal\views\Attribute\ViewsFilter; @@ -13,19 +14,13 @@ use Drupal\views\Attribute\ViewsFilter; #[ViewsFilter("node_uid_revision")] class UidRevision extends Name { + use UidRevisionTrait; + /** * {@inheritdoc} */ public function query($group_by = FALSE) { - $this->ensureMyTable(); - - $placeholder = $this->placeholder() . '[]'; - - $args = array_values($this->value); - - $this->query->addWhereExpression($this->options['group'], "$this->tableAlias.uid IN($placeholder) OR - ((SELECT COUNT(DISTINCT vid) FROM {node_revision} nr WHERE nr.revision_uid IN ($placeholder) AND nr.nid = $this->tableAlias.nid) > 0)", [$placeholder => $args], - $args); + $this->uidRevisionQuery($this->value, $this->options['group']); } } diff --git a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php new file mode 100644 index 00000000000..0d49a7c416c --- /dev/null +++ b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\node\Functional; + +/** + * Tests the node access grants cache context service. + * + * @group node + * @group Cache + */ +class NodeAccessCacheRedirectWarningTest extends NodeTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'node_access_test_empty']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + node_access_rebuild(); + } + + /** + * Ensures that node access checks don't cause cache redirect warnings. + * + * @covers \Drupal\node\NodeAccessControlHandler + */ + public function testNodeAccessCacheRedirectWarning(): void { + $this->drupalPlaceBlock('local_tasks_block'); + + // Ensure that both a node_grants implementation exists, and that the + // current user has 'view own unpublished nodes' permission. Node's access + // control handler bypasses node grants when 'view own published nodes' is + // granted and the node is unpublished, which means that the code path is + // significantly different when a node is published vs. unpublished, and + // that cache contexts vary depend on the state of the node. + $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('node_grants')); + + $author = $this->drupalCreateUser([ + 'create page content', + 'edit any page content', + 'view own unpublished content', + ]); + $this->drupalLogin($author); + + $node = $this->drupalCreateNode(['uid' => $author->id(), 'status' => 0]); + + $this->drupalGet($node->toUrl()); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains($node->label()); + + $node->setPublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains($node->label()); + + // When the node has been viewed in both the unpublished and published state + // a cache redirect should exist for the local tasks block. Repeating the + // process of changing the node status and viewing the node will test that + // no stale redirect is found. + $node->setUnpublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains($node->label()); + + $node->setPublished(); + $node->save(); + + $this->drupalGet($node->toUrl()); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains($node->label()); + } + +} diff --git a/core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php b/core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php new file mode 100644 index 00000000000..5a930df3e2d --- /dev/null +++ b/core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\node\Functional; + +use Drupal\Core\Url; + +/** + * Tests reverting node revisions correctly sets authorship information. + * + * @group node + */ +class NodeRevisionsAuthorTest extends NodeTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests node authorship is retained after reverting revisions. + */ + public function testNodeRevisionRevertAuthors(): void { + // Create and log in user. + $initialUser = $this->drupalCreateUser([ + 'view page revisions', + 'revert page revisions', + 'edit any page content', + ]); + $initialRevisionUser = $this->drupalCreateUser(); + // Third user is an author only and needs no permissions + $initialRevisionAuthor = $this->drupalCreateUser(); + + // Create initial node (author: $user1). + $this->drupalLogin($initialUser); + $node = $this->drupalCreateNode(); + $originalRevisionId = $node->getRevisionId(); + $originalBody = $node->body->value; + $originalTitle = $node->getTitle(); + + // Create a revision (as $initialUser) showing $initialRevisionAuthor + // as author. + $node->setRevisionLogMessage('Changed author'); + $revisedTitle = $this->randomMachineName(); + $node->setTitle($revisedTitle); + $revisedBody = $this->randomMachineName(32); + $node->set('body', [ + 'value' => $revisedBody, + 'format' => filter_default_format(), + ]); + $node->setOwnerId($initialRevisionAuthor->id()); + $node->setRevisionUserId($initialRevisionUser->id()); + $node->setNewRevision(); + $node->save(); + $revisedRevisionId = $node->getRevisionId(); + + $nodeStorage = \Drupal::entityTypeManager()->getStorage('node'); + + self::assertEquals($node->getOwnerId(), $initialRevisionAuthor->id()); + self::assertEquals($node->getRevisionUserId(), $initialRevisionUser->id()); + + // Revert to the original node revision. + $this->drupalGet(Url::fromRoute('node.revision_revert_confirm', [ + 'node' => $node->id(), + 'node_revision' => $originalRevisionId, + ])); + $this->submitForm([], 'Revert'); + $this->assertSession()->pageTextContains(\sprintf('Basic page %s has been reverted', $originalTitle)); + + // With the revert done, reload the node and verify that the authorship + // fields have reverted correctly. + $nodeStorage->resetCache([$node->id()]); + /** @var \Drupal\node\NodeInterface $revertedNode */ + $revertedNode = $nodeStorage->load($node->id()); + self::assertEquals($originalBody, $revertedNode->body->value); + self::assertEquals($initialUser->id(), $revertedNode->getOwnerId()); + self::assertEquals($initialUser->id(), $revertedNode->getRevisionUserId()); + + // Revert again to the revised version and check that node author and + // revision author fields are correct. + // Revert to the original node. + $this->drupalGet(Url::fromRoute('node.revision_revert_confirm', [ + 'node' => $revertedNode->id(), + 'node_revision' => $revisedRevisionId, + ])); + $this->submitForm([], 'Revert'); + $this->assertSession()->pageTextContains(\sprintf('Basic page %s has been reverted', $revisedTitle)); + + // With the reversion done, reload the node and verify that the + // authorship fields have reverted correctly. + $nodeStorage->resetCache([$revertedNode->id()]); + /** @var \Drupal\node\NodeInterface $re_reverted_node */ + $re_reverted_node = $nodeStorage->load($revertedNode->id()); + self::assertEquals($revisedBody, $re_reverted_node->body->value); + self::assertEquals($initialRevisionAuthor->id(), $re_reverted_node->getOwnerId()); + // The new revision user will be the current logged in user as set in + // NodeRevisionRevertForm. + self::assertEquals($initialUser->id(), $re_reverted_node->getRevisionUserId()); + } + +} diff --git a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php index 201d4b6c7d2..88fe3e34e3e 100644 --- a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php +++ b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php @@ -215,20 +215,4 @@ class NodeRevisionsUiTest extends NodeTestBase { $this->assertSession()->elementsCount('xpath', $xpath, 1); } - /** - * Tests the node revisions page is cacheable by dynamic page cache. - */ - public function testNodeRevisionsCacheability(): void { - $this->drupalLogin($this->editor); - $node = $this->drupalCreateNode(); - // Admin paths are always uncacheable by dynamic page cache, swap node - // to non admin theme to test cacheability. - $this->config('node.settings')->set('use_admin_theme', FALSE)->save(); - \Drupal::service('router.builder')->rebuild(); - $this->drupalGet($node->toUrl('version-history')); - $this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', 'MISS'); - $this->drupalGet($node->toUrl('version-history')); - $this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', 'HIT'); - } - } diff --git a/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php b/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php index c3a3d46b496..f8d52b06ecb 100644 --- a/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php +++ b/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php @@ -8,6 +8,7 @@ namespace Drupal\Tests\node\Functional; * Tests if the syndicate block is available. * * @group node + * @group legacy */ class NodeSyndicateBlockTest extends NodeTestBase { @@ -40,6 +41,7 @@ class NodeSyndicateBlockTest extends NodeTestBase { $this->drupalPlaceBlock('node_syndicate_block', ['id' => 'test_syndicate_block', 'label' => 'Subscribe to RSS Feed']); $this->drupalGet(''); $this->assertSession()->elementExists('xpath', '//div[@id="block-test-syndicate-block"]/*'); + $this->expectDeprecation('The Syndicate block is deprecated in drupal:11.2.0 and will be removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3519248'); // Verify syndicate block title. $this->assertSession()->pageTextContains('Subscribe to RSS Feed'); diff --git a/core/modules/node/tests/src/Functional/NodeTranslationUITest.php b/core/modules/node/tests/src/Functional/NodeTranslationUITest.php index 2bb252f7c6e..ac1e8664bad 100644 --- a/core/modules/node/tests/src/Functional/NodeTranslationUITest.php +++ b/core/modules/node/tests/src/Functional/NodeTranslationUITest.php @@ -242,21 +242,19 @@ class NodeTranslationUITest extends ContentTranslationUITestBase { // Set up the default admin theme and use it for node editing. $this->container->get('theme_installer')->install(['claro']); - $edit = []; - $edit['admin_theme'] = 'claro'; - $edit['use_admin_theme'] = TRUE; - $this->drupalGet('admin/appearance'); - $this->submitForm($edit, 'Save configuration'); - $this->drupalGet('node/' . $article->id() . '/translations'); + $this->config('system.theme')->set('admin', 'claro')->save(); + // Verify that translation uses the admin theme if edit is admin. + $this->drupalGet('node/' . $article->id() . '/translations'); $this->assertSession()->responseContains('core/themes/claro/css/base/elements.css'); // Turn off admin theme for editing, assert inheritance to translations. - $edit['use_admin_theme'] = FALSE; - $this->drupalGet('admin/appearance'); - $this->submitForm($edit, 'Save configuration'); - $this->drupalGet('node/' . $article->id() . '/translations'); + $this->config('node.settings')->set('use_admin_theme', FALSE)->save(); + // Changing node.settings:use_admin_theme requires a route rebuild. + $this->container->get('router.builder')->rebuild(); + // Verify that translation uses the frontend theme if edit is frontend. + $this->drupalGet('node/' . $article->id() . '/translations'); $this->assertSession()->responseNotContains('core/themes/claro/css/base/elements.css'); // Assert presence of translation page itself (vs. DisabledBundle below). @@ -561,12 +559,10 @@ class NodeTranslationUITest extends ContentTranslationUITestBase { 'translatable' => TRUE, ])->save(); - $this->drupalLogin($this->administrator); // Make the image field a multi-value field in order to display a // details form element. - $edit = ['field_storage[subform][cardinality_number]' => 2]; - $this->drupalGet('admin/structure/types/manage/article/fields/node.article.field_image'); - $this->submitForm($edit, 'Save'); + $fieldStorage = FieldStorageConfig::loadByName('node', 'field_image'); + $fieldStorage->setCardinality(2)->save(); // Enable the display of the image field. EntityFormDisplay::load('node.article.default') diff --git a/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php b/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php index cbe9b346623..ac47588d5ec 100644 --- a/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php +++ b/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php @@ -18,6 +18,7 @@ use Drupal\Tests\migrate_drupal\Traits\NodeMigrateTypeTestTrait; * Test class for a complete node migration for Drupal 7. * * @group migrate_drupal_7 + * @group #slow */ class MigrateNodeCompleteTest extends MigrateDrupal7TestBase { diff --git a/core/modules/node/tests/src/Kernel/NodeRequirementsStatusFilterWarningTest.php b/core/modules/node/tests/src/Kernel/NodeRequirementsStatusFilterWarningTest.php index 60ce5c7cdb0..b86b69e8ad1 100644 --- a/core/modules/node/tests/src/Kernel/NodeRequirementsStatusFilterWarningTest.php +++ b/core/modules/node/tests/src/Kernel/NodeRequirementsStatusFilterWarningTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\node\Kernel; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\KernelTests\KernelTestBase; use Drupal\Tests\user\Traits\UserCreationTrait; use Drupal\views\Entity\View; @@ -77,7 +78,7 @@ class NodeRequirementsStatusFilterWarningTest extends KernelTestBase { $requirements = $this->getRequirements(); $this->assertArrayHasKey('node_status_filter', $requirements); - $this->assertEquals(REQUIREMENT_WARNING, $requirements['node_status_filter']['severity']); + $this->assertEquals(RequirementSeverity::Warning, $requirements['node_status_filter']['severity']); } /** @@ -102,7 +103,7 @@ class NodeRequirementsStatusFilterWarningTest extends KernelTestBase { $requirements = $this->getRequirements(); $this->assertArrayHasKey('node_status_filter', $requirements); - $this->assertEquals(REQUIREMENT_WARNING, $requirements['node_status_filter']['severity']); + $this->assertEquals(RequirementSeverity::Warning, $requirements['node_status_filter']['severity']); } /** diff --git a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php index 80f92c2d286..28dc50ef60f 100644 --- a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php +++ b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php @@ -321,13 +321,7 @@ JS; ], 'List string' => [ 'list_string', - ['first' => 'First', 'second' => 'Second', 'third' => 'Third'], - TRUE, - ], - // Example with empty key and label values like string '0'. - 'List string with 0 value' => [ - 'list_string', - ['0' => '0', '1' => '1', '2' => '2'], + ['0' => '0', '1' => '1', 'two' => 'two'], TRUE, ], ]; diff --git a/core/modules/package_manager/package_manager.api.php b/core/modules/package_manager/package_manager.api.php index 216737e1573..9fa34742ef9 100644 --- a/core/modules/package_manager/package_manager.api.php +++ b/core/modules/package_manager/package_manager.api.php @@ -95,6 +95,8 @@ * for event subscribers to flag errors before the active directory is * modified, because once that has happened, the changes cannot be undone. * This event may be dispatched multiple times during the stage life cycle. + * Note that this event is NOT dispatched when the sandbox manager is + * operating in direct-write mode. * * - \Drupal\package_manager\Event\PostApplyEvent * Dispatched after changes in the stage directory have been copied to the @@ -109,6 +111,11 @@ * life cycle, and should *never* be used for schema changes (i.e., operations * that should happen in `hook_update_N()` or a post-update function). * + * Since the apply events are not dispatched in direct-write mode, event + * subscribers that want to prevent a sandbox from moving through its life cycle + * in direct-write mode should do it by subscribing to PreCreateEvent or + * StatusCheckEvent. + * * @section sec_stage_api Stage API: Public methods * The public API of any stage consists of the following methods: * diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml index 54c8fb846e0..d7bbaf94820 100644 --- a/core/modules/package_manager/package_manager.services.yml +++ b/core/modules/package_manager/package_manager.services.yml @@ -47,6 +47,7 @@ services: Drupal\package_manager\EventSubscriber\ChangeLogger: calls: - [setLogger, ['@logger.channel.package_manager_change_log']] + Drupal\package_manager\EventSubscriber\DirectWriteSubscriber: {} Drupal\package_manager\ComposerInspector: {} # Validators. @@ -201,3 +202,9 @@ services: PhpTuf\ComposerStager\Internal\Translation\Service\SymfonyTranslatorProxyInterface: class: PhpTuf\ComposerStager\Internal\Translation\Service\SymfonyTranslatorProxy public: false + + Drupal\package_manager\DirectWritePreconditionBypass: + decorates: 'PhpTuf\ComposerStager\API\Precondition\Service\ActiveAndStagingDirsAreDifferentInterface' + arguments: + - '@.inner' + public: false diff --git a/core/modules/package_manager/src/Attribute/AllowDirectWrite.php b/core/modules/package_manager/src/Attribute/AllowDirectWrite.php new file mode 100644 index 00000000000..d41de1a87e4 --- /dev/null +++ b/core/modules/package_manager/src/Attribute/AllowDirectWrite.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\Attribute; + +/** + * Identifies sandbox managers which can operate on the running code base. + * + * Package Manager normally creates and operates on a fully separate, sandboxed + * copy of the site. This is pretty safe, but not always necessary for certain + * kinds of operations (e.g., adding a new module to the site). + * SandboxManagerBase subclasses with this attribute are allowed to skip the + * sandboxing and operate directly on the live site, but ONLY if the + * `package_manager_allow_direct_write` setting is set to TRUE. + * + * @see \Drupal\package_manager\SandboxManagerBase::isDirectWrite() + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class AllowDirectWrite { +} diff --git a/core/modules/package_manager/src/ComposerInspector.php b/core/modules/package_manager/src/ComposerInspector.php index 69d30738850..32bde1002ea 100644 --- a/core/modules/package_manager/src/ComposerInspector.php +++ b/core/modules/package_manager/src/ComposerInspector.php @@ -54,7 +54,7 @@ class ComposerInspector implements LoggerAwareInterface { * * @var string */ - final public const SUPPORTED_VERSION = '^2.6'; + final public const SUPPORTED_VERSION = '^2.7'; public function __construct( private readonly ComposerProcessRunnerInterface $runner, diff --git a/core/modules/package_manager/src/DirectWritePreconditionBypass.php b/core/modules/package_manager/src/DirectWritePreconditionBypass.php new file mode 100644 index 00000000000..ba456d270d7 --- /dev/null +++ b/core/modules/package_manager/src/DirectWritePreconditionBypass.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Precondition\Service\ActiveAndStagingDirsAreDifferentInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface; + +/** + * Allows certain Composer Stager preconditions to be bypassed. + * + * Only certain preconditions can be bypassed; this class implements all of + * those interfaces, and only accepts them in its constructor. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class DirectWritePreconditionBypass implements ActiveAndStagingDirsAreDifferentInterface { + + use StringTranslationTrait; + + /** + * Whether or not the decorated precondition is being bypassed. + * + * @var bool + */ + private static bool $isBypassed = FALSE; + + public function __construct( + private readonly ActiveAndStagingDirsAreDifferentInterface $decorated, + ) {} + + /** + * Bypasses the decorated precondition. + */ + public static function activate(): void { + static::$isBypassed = TRUE; + } + + /** + * {@inheritdoc} + */ + public function getName(): TranslatableInterface { + return $this->decorated->getName(); + } + + /** + * {@inheritdoc} + */ + public function getDescription(): TranslatableInterface { + return $this->decorated->getDescription(); + } + + /** + * {@inheritdoc} + */ + public function getStatusMessage(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): TranslatableInterface { + if (static::$isBypassed) { + return new TranslatableStringAdapter('This precondition has been skipped because it is not needed in direct-write mode.'); + } + return $this->decorated->getStatusMessage($activeDir, $stagingDir, $exclusions, $timeout); + } + + /** + * {@inheritdoc} + */ + public function isFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): bool { + if (static::$isBypassed) { + return TRUE; + } + return $this->decorated->isFulfilled($activeDir, $stagingDir, $exclusions, $timeout); + } + + /** + * {@inheritdoc} + */ + public function assertIsFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + if (static::$isBypassed) { + return; + } + $this->decorated->assertIsFulfilled($activeDir, $stagingDir, $exclusions, $timeout); + } + + /** + * {@inheritdoc} + */ + public function getLeaves(): array { + return [$this]; + } + +} diff --git a/core/modules/package_manager/src/Event/SandboxValidationEvent.php b/core/modules/package_manager/src/Event/SandboxValidationEvent.php index 0dad6829486..df5bc1c2bbc 100644 --- a/core/modules/package_manager/src/Event/SandboxValidationEvent.php +++ b/core/modules/package_manager/src/Event/SandboxValidationEvent.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Drupal\package_manager\Event; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\package_manager\ValidationResult; -use Drupal\system\SystemManager; /** * Base class for events dispatched before a stage life cycle operation. @@ -75,7 +75,7 @@ abstract class SandboxValidationEvent extends SandboxEvent { */ public function addResult(ValidationResult $result): void { // Only errors are allowed for this event. - if ($result->severity !== SystemManager::REQUIREMENT_ERROR) { + if ($result->severity !== RequirementSeverity::Error->value) { throw new \InvalidArgumentException('Only errors are allowed.'); } $this->results[] = $result; @@ -85,7 +85,7 @@ abstract class SandboxValidationEvent extends SandboxEvent { * {@inheritdoc} */ public function stopPropagation(): void { - if (empty($this->getResults(SystemManager::REQUIREMENT_ERROR))) { + if (empty($this->getResults(RequirementSeverity::Error->value))) { $this->addErrorFromThrowable(new \LogicException('Event propagation stopped without any errors added to the event. This bypasses the package_manager validation system.')); } parent::stopPropagation(); diff --git a/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php b/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php index 703dbf4603b..c8c19324c87 100644 --- a/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php +++ b/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php @@ -85,15 +85,21 @@ final class ChangeLogger implements EventSubscriberInterface, LoggerAwareInterfa $event->getDevPackages(), ); $event->sandboxManager->setMetadata(static::REQUESTED_PACKAGES_KEY, $requested_packages); + + // If we're in direct-write mode, the changes have already been made, so + // we should log them right away. + if ($event->sandboxManager->isDirectWrite()) { + $this->logChanges($event); + } } /** * Logs changes made by Package Manager. * - * @param \Drupal\package_manager\Event\PostApplyEvent $event + * @param \Drupal\package_manager\Event\PostApplyEvent|\Drupal\package_manager\Event\PostRequireEvent $event * The event being handled. */ - public function logChanges(PostApplyEvent $event): void { + public function logChanges(PostApplyEvent|PostRequireEvent $event): void { $installed_at_start = $event->sandboxManager->getMetadata(static::INSTALLED_PACKAGES_KEY); $installed_post_apply = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); diff --git a/core/modules/package_manager/src/EventSubscriber/DirectWriteSubscriber.php b/core/modules/package_manager/src/EventSubscriber/DirectWriteSubscriber.php new file mode 100644 index 00000000000..c2340c39783 --- /dev/null +++ b/core/modules/package_manager/src/EventSubscriber/DirectWriteSubscriber.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Handles sandbox events when direct-write is enabled. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class DirectWriteSubscriber implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The state key which holds the original status of maintenance mode. + * + * @var string + */ + private const STATE_KEY = 'package_manager.maintenance_mode'; + + public function __construct(private readonly StateInterface $state) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + StatusCheckEvent::class => 'warnAboutDirectWrite', + // We want to go into maintenance mode after other subscribers, to give + // them a chance to flag errors. + PreRequireEvent::class => ['enterMaintenanceMode', -10000], + // We want to exit maintenance mode as early as possible. + PostRequireEvent::class => ['exitMaintenanceMode', 10000], + ]; + } + + /** + * Logs a warning about direct-write mode, if it is in use. + * + * @param \Drupal\package_manager\Event\StatusCheckEvent $event + * The event being handled. + */ + public function warnAboutDirectWrite(StatusCheckEvent $event): void { + if ($event->sandboxManager->isDirectWrite()) { + $event->addWarning([ + $this->t('Direct-write mode is enabled, which means that changes will be made without sandboxing them first. This can be risky and is not recommended for production environments. For safety, your site will be put into maintenance mode while dependencies are updated.'), + ]); + } + } + + /** + * Enters maintenance mode before a direct-mode require operation. + * + * @param \Drupal\package_manager\Event\PreRequireEvent $event + * The event being handled. + */ + public function enterMaintenanceMode(PreRequireEvent $event): void { + $errors = $event->getResults(RequirementSeverity::Error->value); + + if (empty($errors) && $event->sandboxManager->isDirectWrite()) { + $this->state->set(static::STATE_KEY, (bool) $this->state->get('system.maintenance_mode')); + $this->state->set('system.maintenance_mode', TRUE); + } + } + + /** + * Leaves maintenance mode after a direct-mode require operation. + * + * @param \Drupal\package_manager\Event\PreRequireEvent $event + * The event being handled. + */ + public function exitMaintenanceMode(PostRequireEvent $event): void { + if ($event->sandboxManager->isDirectWrite()) { + $this->state->set('system.maintenance_mode', $this->state->get(static::STATE_KEY)); + $this->state->delete(static::STATE_KEY); + } + } + +} diff --git a/core/modules/package_manager/src/Hook/PackageManagerRequirementsHooks.php b/core/modules/package_manager/src/Hook/PackageManagerRequirementsHooks.php index 4d315a94330..52cc10bc4e9 100644 --- a/core/modules/package_manager/src/Hook/PackageManagerRequirementsHooks.php +++ b/core/modules/package_manager/src/Hook/PackageManagerRequirementsHooks.php @@ -2,6 +2,7 @@ namespace Drupal\package_manager\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Site\Settings; @@ -41,7 +42,7 @@ class PackageManagerRequirementsHooks { '@version' => $this->composerInspector->getVersion(), '@path' => $this->executableFinder->find('composer'), ]), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; } catch (\Throwable $e) { @@ -55,7 +56,7 @@ class PackageManagerRequirementsHooks { 'description' => $this->t('Composer was not found. The error message was: @message', [ '@message' => $message, ]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } @@ -90,7 +91,7 @@ class PackageManagerRequirementsHooks { $requirements['testing_package_manager'] = [ 'title' => 'Package Manager', 'description' => $this->t("Package Manager is available for early testing. To install the module set the value of 'testing_package_manager' to TRUE in your settings.php file."), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } @@ -125,7 +126,7 @@ class PackageManagerRequirementsHooks { $requirements['package_manager_failure_marker'] = [ 'title' => $this->t('Failed Package Manager update detected'), 'description' => $exception->getMessage(), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } diff --git a/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php b/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php index 45e0166ed87..aac542e6275 100644 --- a/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php +++ b/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\package_manager\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Site\Settings; use Drupal\package_manager\Exception\FailureMarkerExistsException; use Drupal\package_manager\FailureMarker; @@ -24,7 +25,7 @@ class PackageManagerRequirements implements InstallRequirementsInterface { $requirements['testing_package_manager'] = [ 'title' => 'Package Manager', 'description' => t("Package Manager is available for early testing. To install the module set the value of 'testing_package_manager' to TRUE in your settings.php file."), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } @@ -41,7 +42,7 @@ class PackageManagerRequirements implements InstallRequirementsInterface { $requirements['package_manager_failure_marker'] = [ 'title' => t('Failed Package Manager update detected'), 'description' => $exception->getMessage(), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } diff --git a/core/modules/package_manager/src/SandboxManagerBase.php b/core/modules/package_manager/src/SandboxManagerBase.php index 4b3c6065432..15836def8f8 100644 --- a/core/modules/package_manager/src/SandboxManagerBase.php +++ b/core/modules/package_manager/src/SandboxManagerBase.php @@ -8,11 +8,13 @@ use Composer\Semver\VersionParser; use Drupal\Component\Datetime\TimeInterface; use Drupal\Component\Utility\Random; use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\Site\Settings; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\TempStore\SharedTempStore; use Drupal\Core\TempStore\SharedTempStoreFactory; use Drupal\Core\Utility\Error; +use Drupal\package_manager\Attribute\AllowDirectWrite; use Drupal\package_manager\Event\CollectPathsToExcludeEvent; use Drupal\package_manager\Event\PostApplyEvent; use Drupal\package_manager\Event\PostCreateEvent; @@ -147,9 +149,9 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { * * Consists of a unique random string and the current class name. * - * @var string[] + * @var string[]|null */ - private $lock; + private ?array $lock = NULL; /** * The shared temp store. @@ -338,6 +340,7 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { $id, static::class, $this->getType(), + $this->isDirectWrite(), ]); $this->claim($id); @@ -351,7 +354,12 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { $this->dispatch($event, [$this, 'markAsAvailable']); try { - $this->beginner->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout); + if ($this->isDirectWrite()) { + $this->logger?->info($this->t('Direct-write is enabled. Skipping sandboxing.')); + } + else { + $this->beginner->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout); + } } catch (\Throwable $error) { $this->destroy(); @@ -372,7 +380,10 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { } /** - * Adds or updates packages in the stage directory. + * Adds or updates packages in the sandbox directory. + * + * If this sandbox manager is running in direct-write mode, the changes will + * be made in the active directory. * * @param string[] $runtime * The packages to add as regular top-level dependencies, in the form @@ -430,8 +441,18 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { // If constraints were changed, update those packages. if ($runtime || $dev) { - $command = array_merge(['update', '--with-all-dependencies', '--optimize-autoloader'], $runtime, $dev); - $do_stage($command); + $do_stage([ + 'update', + // Allow updating top-level dependencies. + '--with-all-dependencies', + // Always optimize the autoloader for better site performance. + '--optimize-autoloader', + // For extra safety and speed, make Composer do only the necessary + // changes to transitive (indirect) dependencies. + '--minimal-changes', + ...$runtime, + ...$dev, + ]); } $this->dispatch(new PostRequireEvent($this, $runtime, $dev)); } @@ -458,6 +479,13 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { * a failed commit operation. */ public function apply(?int $timeout = 600): void { + // In direct-write mode, changes are made directly to the running code base, + // so there is nothing to do. + if ($this->isDirectWrite()) { + $this->logger?->info($this->t('Direct-write is enabled. Changes have been made to the running code base.')); + return; + } + $this->checkOwnership(); $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot()); @@ -556,7 +584,7 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { // If the stage directory exists, queue it to be automatically cleaned up // later by a queue (which may or may not happen during cron). // @see \Drupal\package_manager\Plugin\QueueWorker\Cleaner - if ($this->sandboxDirectoryExists()) { + if ($this->sandboxDirectoryExists() && !$this->isDirectWrite()) { $this->queueFactory->get('package_manager_cleanup') ->createItem($this->getSandboxDirectory()); } @@ -659,8 +687,14 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { )->render()); } - if ($stored_lock === [$unique_id, static::class, $this->getType()]) { + if (array_slice($stored_lock, 0, 3) === [$unique_id, static::class, $this->getType()]) { $this->lock = $stored_lock; + + if ($this->isDirectWrite()) { + // Bypass a hard-coded set of Composer Stager preconditions that prevent + // the active directory from being modified directly. + DirectWritePreconditionBypass::activate(); + } return $this; } @@ -717,7 +751,9 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { * Returns the path of the directory where changes should be staged. * * @return string - * The absolute path of the directory where changes should be staged. + * The absolute path of the directory where changes should be staged. If + * this sandbox manager is operating in direct-write mode, this will be + * path of the active directory. * * @throws \LogicException * If this method is called before the stage has been created or claimed. @@ -726,6 +762,10 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { if (!$this->lock) { throw new \LogicException(__METHOD__ . '() cannot be called because the stage has not been created or claimed.'); } + + if ($this->isDirectWrite()) { + return $this->pathLocator->getProjectRoot(); + } return $this->getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0]; } @@ -848,4 +888,26 @@ abstract class SandboxManagerBase implements LoggerAwareInterface { $this->tempStore->set(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $id, $message); } + /** + * Indicates whether the active directory will be changed directly. + * + * This can only happen if direct-write is globally enabled by the + * `package_manager_allow_direct_write` setting, AND this class explicitly + * allows it (by adding the AllowDirectWrite attribute). + * + * @return bool + * TRUE if the sandbox manager is operating in direct-write mode, otherwise + * FALSE. + */ + final public function isDirectWrite(): bool { + // The use of direct-write is stored as part of the lock so that it will + // remain consistent during the sandbox's entire life cycle, even if the + // underlying global settings are changed. + if ($this->lock) { + return $this->lock[3]; + } + $reflector = new \ReflectionClass($this); + return Settings::get('package_manager_allow_direct_write', FALSE) && $reflector->getAttributes(AllowDirectWrite::class); + } + } diff --git a/core/modules/package_manager/src/ValidationResult.php b/core/modules/package_manager/src/ValidationResult.php index be540eb7a73..3c29c2cc013 100644 --- a/core/modules/package_manager/src/ValidationResult.php +++ b/core/modules/package_manager/src/ValidationResult.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Drupal\package_manager; use Drupal\Component\Assertion\Inspector; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\system\SystemManager; use PhpTuf\ComposerStager\API\Exception\ExceptionInterface; /** @@ -22,6 +22,7 @@ final class ValidationResult { * @param int $severity * The severity of the result. Should be one of the * SystemManager::REQUIREMENT_* constants. + * @todo Refactor this to use RequirementSeverity in https://www.drupal.org/i/3525121. * @param \Drupal\Core\StringTranslation\TranslatableMarkup[]|string[] $messages * The result messages. * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary @@ -76,7 +77,7 @@ final class ValidationResult { // All Composer Stager exceptions are translatable. $is_translatable = $throwable instanceof ExceptionInterface; $message = $is_translatable ? $throwable->getTranslatableMessage() : $throwable->getMessage(); - return new static(SystemManager::REQUIREMENT_ERROR, [$message], $summary, $is_translatable); + return new static(RequirementSeverity::Error->value, [$message], $summary, $is_translatable); } /** @@ -90,7 +91,7 @@ final class ValidationResult { * @return static */ public static function createError(array $messages, ?TranslatableMarkup $summary = NULL): static { - return new static(SystemManager::REQUIREMENT_ERROR, $messages, $summary, TRUE); + return new static(RequirementSeverity::Error->value, $messages, $summary, TRUE); } /** @@ -104,7 +105,7 @@ final class ValidationResult { * @return static */ public static function createWarning(array $messages, ?TranslatableMarkup $summary = NULL): static { - return new static(SystemManager::REQUIREMENT_WARNING, $messages, $summary, TRUE); + return new static(RequirementSeverity::Warning->value, $messages, $summary, TRUE); } /** @@ -119,12 +120,12 @@ final class ValidationResult { */ public static function getOverallSeverity(array $results): int { foreach ($results as $result) { - if ($result->severity === SystemManager::REQUIREMENT_ERROR) { - return SystemManager::REQUIREMENT_ERROR; + if ($result->severity === RequirementSeverity::Error->value) { + return RequirementSeverity::Error->value; } } // If there were no errors, then any remaining results must be warnings. - return $results ? SystemManager::REQUIREMENT_WARNING : SystemManager::REQUIREMENT_OK; + return $results ? RequirementSeverity::Warning->value : RequirementSeverity::OK->value; } /** diff --git a/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php b/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php index 765fccd20cf..9de2911fb23 100644 --- a/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php +++ b/core/modules/package_manager/src/Validator/BaseRequirementsFulfilledValidator.php @@ -2,12 +2,12 @@ namespace Drupal\package_manager\Validator; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Event\PreCreateEvent; -use Drupal\package_manager\Event\SandboxValidationEvent; use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\SandboxValidationEvent; use Drupal\package_manager\Event\StatusCheckEvent; -use Drupal\system\SystemManager; use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** @@ -51,7 +51,7 @@ final class BaseRequirementsFulfilledValidator implements EventSubscriberInterfa // If there are any errors from the validators which ran before this one, // base requirements are not fulfilled. Stop any further validators from // running. - if ($event->getResults(SystemManager::REQUIREMENT_ERROR)) { + if ($event->getResults(RequirementSeverity::Error->value)) { $event->stopPropagation(); } } diff --git a/core/modules/package_manager/src/Validator/LockFileValidator.php b/core/modules/package_manager/src/Validator/LockFileValidator.php index ead8740ba84..c63b283b238 100644 --- a/core/modules/package_manager/src/Validator/LockFileValidator.php +++ b/core/modules/package_manager/src/Validator/LockFileValidator.php @@ -111,6 +111,12 @@ final class LockFileValidator implements EventSubscriberInterface { public function validate(SandboxValidationEvent $event): void { $sandbox_manager = $event->sandboxManager; + // If we're going to change the active directory directly, we don't need to + // validate the lock file's consistency, since there is no separate + // sandbox directory to compare against. + if ($sandbox_manager->isDirectWrite()) { + return; + } // Early return if the stage is not already created. if ($event instanceof StatusCheckEvent && $sandbox_manager->isAvailable()) { return; diff --git a/core/modules/package_manager/src/Validator/RsyncValidator.php b/core/modules/package_manager/src/Validator/RsyncValidator.php index 37fe6eb76a5..eeb3f3a8b56 100644 --- a/core/modules/package_manager/src/Validator/RsyncValidator.php +++ b/core/modules/package_manager/src/Validator/RsyncValidator.php @@ -38,6 +38,12 @@ final class RsyncValidator implements EventSubscriberInterface { * The event being handled. */ public function validate(SandboxValidationEvent $event): void { + // If the we are going to change the active directory directly, we don't + // need rsync. + if ($event->sandboxManager->isDirectWrite()) { + return; + } + try { $this->executableFinder->find('rsync'); $rsync_found = TRUE; diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php index be088454061..20194d5c678 100644 --- a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php @@ -7,6 +7,7 @@ namespace Drupal\package_manager_test_api; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Queue\QueueFactory; use Drupal\Core\Url; +use Drupal\package_manager\Attribute\AllowDirectWrite; use Drupal\package_manager\FailureMarker; use Drupal\package_manager\PathLocator; use Drupal\package_manager\SandboxManagerBase; @@ -91,7 +92,7 @@ class ApiController extends ControllerBase { public function finish(string $id): Response { $this->stage->claim($id)->postApply(); $this->stage->destroy(); - return new Response(); + return new Response('Finished'); } /** @@ -142,6 +143,7 @@ class ApiController extends ControllerBase { * * @see \Drupal\package_manager\SandboxManagerBase::claim() */ +#[AllowDirectWrite] final class ControllerSandboxManager extends SandboxManagerBase { /** diff --git a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php index ec53f485dfb..bea2c0d4024 100644 --- a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php +++ b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php @@ -15,9 +15,14 @@ class PackageInstallTest extends TemplateProjectTestBase { /** * Tests installing packages in a stage directory. + * + * @testWith [true] + * [false] */ - public function testPackageInstall(): void { + public function testPackageInstall(bool $allow_direct_write): void { $this->createTestProject('RecommendedProject'); + $allow_direct_write = var_export($allow_direct_write, TRUE); + $this->writeSettings("\n\$settings['package_manager_allow_direct_write'] = $allow_direct_write;"); $this->setReleaseMetadata([ 'alpha' => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml', diff --git a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php index 16cd486ad75..dcc5b879a2d 100644 --- a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php +++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php @@ -347,7 +347,7 @@ END; $this->assertDirectoryIsWritable($log); $log .= '/' . str_replace('\\', '_', static::class) . '-' . $this->name(); if ($this->usesDataProvider()) { - $log .= '-' . preg_replace('/[^a-z0-9]+/i', '_', $this->dataName()); + $log .= '-' . preg_replace('/[^a-z0-9]+/i', '_', (string) $this->dataName()); } $code .= <<<END \$config['package_manager.settings']['log'] = '$log-package_manager.log'; @@ -441,6 +441,8 @@ END; $requirements['symfony/polyfill-php81'], $requirements['symfony/polyfill-php82'], $requirements['symfony/polyfill-php83'], + // Needed for PHP 8.4 features while PHP 8.3 is the minimum. + $requirements['symfony/polyfill-php84'], ); // If this package requires any Drupal core packages, ensure it allows // any version. @@ -719,6 +721,9 @@ END; $this->serverErrorLog, ); $this->assertSame(200, $session->getStatusCode(), $message); + // Sometimes we get a 200 response after a PHP timeout or OOM error, so we + // also check the page content to ensure it's what we expect. + $this->assertSame('Finished', $session->getPage()->getText()); } /** diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php index 0411978a175..61f922824bd 100644 --- a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php @@ -230,7 +230,7 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase { * ["2.5.0", "<default>"] * ["2.5.5", "<default>"] * ["2.5.11", "<default>"] - * ["2.6.0", null] + * ["2.7.0", null] * ["2.2.11", "<default>"] * ["2.2.0-dev", "<default>"] * ["2.3.6", "<default>"] diff --git a/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php b/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php new file mode 100644 index 00000000000..3208fddbbf4 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php @@ -0,0 +1,234 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\SandboxEvent; +use Drupal\package_manager\Exception\SandboxEventException; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\StatusCheckTrait; +use Drupal\package_manager\ValidationResult; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @covers \Drupal\package_manager\EventSubscriber\DirectWriteSubscriber + * @covers \Drupal\package_manager\SandboxManagerBase::isDirectWrite + * + * @group package_manager + */ +class DirectWriteTest extends PackageManagerKernelTestBase implements EventSubscriberInterface { + + use StatusCheckTrait; + use StringTranslationTrait; + + /** + * Whether we are in maintenance mode before a require operation. + * + * @var bool|null + * + * @see ::onPreRequire() + */ + private ?bool $preRequireMaintenanceMode = NULL; + + /** + * Whether we are in maintenance mode after a require operation. + * + * @var bool|null + * + * @see ::onPostRequire() + */ + private ?bool $postRequireMaintenanceMode = NULL; + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + // The pre-require and post-require listeners need to run after + // \Drupal\package_manager\EventSubscriber\DirectWriteSubscriber. + PreRequireEvent::class => ['onPreRequire', -10001], + PostRequireEvent::class => ['onPostRequire', 9999], + PreApplyEvent::class => 'assertNotDirectWrite', + ]; + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->container->get(EventDispatcherInterface::class) + ->addSubscriber($this); + } + + /** + * Event listener that asserts the sandbox manager isn't in direct-write mode. + * + * @param \Drupal\package_manager\Event\SandboxEvent $event + * The event being handled. + */ + public function assertNotDirectWrite(SandboxEvent $event): void { + $this->assertFalse($event->sandboxManager->isDirectWrite()); + } + + /** + * Event listener that records the maintenance mode flag on pre-require. + */ + public function onPreRequire(): void { + $this->preRequireMaintenanceMode = (bool) $this->container->get(StateInterface::class) + ->get('system.maintenance_mode'); + } + + /** + * Event listener that records the maintenance mode flag on post-require. + */ + public function onPostRequire(): void { + $this->postRequireMaintenanceMode = (bool) $this->container->get(StateInterface::class) + ->get('system.maintenance_mode'); + } + + /** + * Tests that direct-write does not work if it is globally disabled. + */ + public function testSiteSandboxedIfDirectWriteGloballyDisabled(): void { + // Even if we use a sandbox manager that supports direct write, it should + // not be enabled. + $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class); + $logger = new TestLogger(); + $sandbox_manager->setLogger($logger); + $this->assertFalse($sandbox_manager->isDirectWrite()); + $sandbox_manager->create(); + $this->assertTrue($sandbox_manager->sandboxDirectoryExists()); + $this->assertNotSame( + $this->container->get(PathLocator::class)->getProjectRoot(), + $sandbox_manager->getSandboxDirectory(), + ); + $this->assertFalse($logger->hasRecords('info')); + } + + /** + * Tests direct-write mode when globally enabled. + */ + public function testSiteNotSandboxedIfDirectWriteGloballyEnabled(): void { + $mock_beginner = $this->createMock(BeginnerInterface::class); + $mock_beginner->expects($this->never()) + ->method('begin') + ->withAnyParameters(); + $this->container->set(BeginnerInterface::class, $mock_beginner); + + $mock_committer = $this->createMock(CommitterInterface::class); + $mock_committer->expects($this->never()) + ->method('commit') + ->withAnyParameters(); + $this->container->set(CommitterInterface::class, $mock_committer); + + $this->setSetting('package_manager_allow_direct_write', TRUE); + + $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class); + $logger = new TestLogger(); + $sandbox_manager->setLogger($logger); + $this->assertTrue($sandbox_manager->isDirectWrite()); + + // A status check should flag a warning about running in direct-write mode. + $expected_results = [ + ValidationResult::createWarning([ + $this->t('Direct-write mode is enabled, which means that changes will be made without sandboxing them first. This can be risky and is not recommended for production environments. For safety, your site will be put into maintenance mode while dependencies are updated.'), + ]), + ]; + $actual_results = $this->runStatusCheck($sandbox_manager); + $this->assertValidationResultsEqual($expected_results, $actual_results); + + $sandbox_manager->create(); + // In direct-write mode, the active and sandbox directories are the same. + $this->assertTrue($sandbox_manager->sandboxDirectoryExists()); + $this->assertSame( + $this->container->get(PathLocator::class)->getProjectRoot(), + $sandbox_manager->getSandboxDirectory(), + ); + + // Do a require operation so we can assert that we are kicked into, and out + // of, maintenance mode. + $sandbox_manager->require(['ext-json:*']); + $this->assertTrue($this->preRequireMaintenanceMode); + $this->assertFalse($this->postRequireMaintenanceMode); + + $sandbox_manager->apply(); + $sandbox_manager->postApply(); + // Destroying the sandbox should not populate the clean-up queue. + $sandbox_manager->destroy(); + /** @var \Drupal\Core\Queue\QueueInterface $queue */ + $queue = $this->container->get(QueueFactory::class) + ->get('package_manager_cleanup'); + $this->assertSame(0, $queue->numberOfItems()); + + $records = $logger->recordsByLevel['info']; + $this->assertCount(2, $records); + $this->assertSame('Direct-write is enabled. Skipping sandboxing.', (string) $records[0]['message']); + $this->assertSame('Direct-write is enabled. Changes have been made to the running code base.', (string) $records[1]['message']); + + // A sandbox manager that doesn't support direct-write should not be + // influenced by the setting. + $this->assertFalse($this->createStage()->isDirectWrite()); + } + + /** + * Tests that pre-require errors prevent maintenance mode during direct-write. + */ + public function testMaintenanceModeNotEnteredIfErrorOnPreRequire(): void { + $this->setSetting('package_manager_allow_direct_write', TRUE); + // Sanity check: we shouldn't be in maintenance mode to begin with. + $state = $this->container->get(StateInterface::class); + $this->assertEmpty($state->get('system.maintenance_mode')); + + // Set up an event subscriber which will flag an error. + $this->container->get(EventDispatcherInterface::class) + ->addListener(PreRequireEvent::class, function (PreRequireEvent $event): void { + $event->addError([ + $this->t('Maintenance mode should not happen.'), + ]); + }); + + $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class); + $sandbox_manager->create(); + try { + $sandbox_manager->require(['ext-json:*']); + $this->fail('Expected an exception to be thrown on pre-require.'); + } + catch (SandboxEventException $e) { + $this->assertSame("Maintenance mode should not happen.\n", $e->getMessage()); + // We should never have entered maintenance mode. + $this->assertFalse($this->preRequireMaintenanceMode); + // Sanity check: the post-require event should never have been dispatched. + $this->assertNull($this->postRequireMaintenanceMode); + } + } + + /** + * Tests that the sandbox's direct-write status is part of its locking info. + */ + public function testDirectWriteFlagIsLocked(): void { + $this->setSetting('package_manager_allow_direct_write', TRUE); + $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class); + $this->assertTrue($sandbox_manager->isDirectWrite()); + $sandbox_manager->create(); + $this->setSetting('package_manager_allow_direct_write', FALSE); + $this->assertTrue($sandbox_manager->isDirectWrite()); + // Only once the sandbox is destroyed should the sandbox manager reflect the + // changed setting. + $sandbox_manager->destroy(); + $this->assertFalse($sandbox_manager->isDirectWrite()); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php index 3c2e32b1e7c..5bcc43a8138 100644 --- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -12,6 +12,7 @@ use Drupal\Core\Queue\QueueFactory; use Drupal\Core\Site\Settings; use Drupal\fixture_manipulator\StageFixtureManipulator; use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\Attribute\AllowDirectWrite; use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Event\SandboxValidationEvent; use Drupal\package_manager\Exception\SandboxEventException; @@ -173,11 +174,15 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { /** * Creates a stage object for testing purposes. * + * @param class-string $class + * (optional) The class of the sandbox manager to create. Defaults to + * \Drupal\Tests\package_manager\Kernel\TestSandboxManager. + * * @return \Drupal\Tests\package_manager\Kernel\TestSandboxManager * A stage object, with test-only modifications. */ - protected function createStage(): TestSandboxManager { - return new TestSandboxManager( + protected function createStage(?string $class = TestSandboxManager::class): TestSandboxManager { + return new $class( $this->container->get(PathLocator::class), $this->container->get(BeginnerInterface::class), $this->container->get(StagerInterface::class), @@ -476,6 +481,19 @@ class TestSandboxManager extends SandboxManagerBase { } /** + * Defines a test-only sandbox manager that allows direct-write. + */ +#[AllowDirectWrite] +class TestDirectWriteSandboxManager extends TestSandboxManager { + + /** + * {@inheritdoc} + */ + protected string $type = 'package_manager:test_direct_write'; + +} + +/** * A test version of the disk space validator to bypass system-level functions. */ class TestDiskSpaceValidator extends DiskSpaceValidator { diff --git a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php index 188c654929d..02be8f298aa 100644 --- a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php @@ -76,4 +76,13 @@ class RsyncValidatorTest extends PackageManagerKernelTestBase { $this->assertResults([$result], PreCreateEvent::class); } + /** + * Tests that the presence of rsync is not checked in direct-write mode. + */ + public function testRsyncNotNeededForDirectWrite(): void { + $this->executableFinder->find('rsync')->shouldNotBeCalled(); + $this->setSetting('package_manager_allow_direct_write', TRUE); + $this->createStage(TestDirectWriteSandboxManager::class)->create(); + } + } diff --git a/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php index 90348cdfdd3..2e9a0977fa3 100644 --- a/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php @@ -13,7 +13,6 @@ use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; * @coversDefaultClass \Drupal\package_manager\Validator\SupportedReleaseValidator * @group #slow * @group package_manager - * @group #slow * @internal */ class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase { diff --git a/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php index 2b46e1de9c8..00366b8c318 100644 --- a/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php +++ b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Drupal\Tests\package_manager\Unit; -use Drupal\package_manager\ValidationResult; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\system\SystemManager; +use Drupal\package_manager\ValidationResult; use Drupal\Tests\UnitTestCase; /** @@ -25,7 +25,7 @@ class ValidationResultTest extends UnitTestCase { // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString, DrupalPractice.Objects.GlobalFunction $summary = $summary ? t($summary) : NULL; $result = ValidationResult::createWarning($messages, $summary); - $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_WARNING); + $this->assertResultValid($result, $messages, $summary, RequirementSeverity::Warning->value); } /** @@ -39,16 +39,17 @@ class ValidationResultTest extends UnitTestCase { ValidationResult::createWarning([t('Moo!')]), // phpcs:enable DrupalPractice.Objects.GlobalFunction ]; - $this->assertSame(SystemManager::REQUIREMENT_ERROR, ValidationResult::getOverallSeverity($results)); + $this->assertSame(RequirementSeverity::Error->value, ValidationResult::getOverallSeverity($results)); // If there are no results, but no errors, the results should be counted as // a warning. array_shift($results); - $this->assertSame(SystemManager::REQUIREMENT_WARNING, ValidationResult::getOverallSeverity($results)); + $this->assertSame(RequirementSeverity::Warning->value, ValidationResult::getOverallSeverity($results)); - // If there are just plain no results, we should get REQUIREMENT_OK. + // If there are just plain no results, we should get + // RequirementSeverity::OK. array_shift($results); - $this->assertSame(SystemManager::REQUIREMENT_OK, ValidationResult::getOverallSeverity($results)); + $this->assertSame(RequirementSeverity::OK->value, ValidationResult::getOverallSeverity($results)); } /** @@ -60,7 +61,7 @@ class ValidationResultTest extends UnitTestCase { // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString, DrupalPractice.Objects.GlobalFunction $summary = $summary ? t($summary) : NULL; $result = ValidationResult::createError($messages, $summary); - $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_ERROR); + $this->assertResultValid($result, $messages, $summary, RequirementSeverity::Error->value); } /** diff --git a/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php b/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php index 41f2e8b8e4f..da6d22bfb05 100644 --- a/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php +++ b/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php @@ -155,7 +155,6 @@ class PageCacheTagsIntegrationTest extends BrowserTestBase { 'config:block.block.olivero_messages', 'config:block.block.olivero_primary_local_tasks', 'config:block.block.olivero_secondary_local_tasks', - 'config:block.block.olivero_syndicate', 'config:block.block.olivero_primary_admin_actions', 'config:block.block.olivero_page_title', 'node_view', @@ -195,7 +194,6 @@ class PageCacheTagsIntegrationTest extends BrowserTestBase { 'config:block.block.olivero_messages', 'config:block.block.olivero_primary_local_tasks', 'config:block.block.olivero_secondary_local_tasks', - 'config:block.block.olivero_syndicate', 'config:block.block.olivero_primary_admin_actions', 'config:block.block.olivero_page_title', 'node_view', diff --git a/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php b/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php index d5cc9759ab1..be5d811fe54 100644 --- a/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php +++ b/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php @@ -13,6 +13,7 @@ use Drupal\Tests\Traits\Core\PathAliasTestTrait; /** * URL alias migration. * + * @group #slow * @group migrate_drupal_6 */ class MigrateUrlAliasTest extends MigrateDrupal6TestBase { diff --git a/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php b/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php index 66b8e2dfea0..65fa78a5e71 100644 --- a/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php +++ b/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php @@ -3,6 +3,7 @@ namespace Drupal\pgsql\Hook; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Hook\Attribute\Hook; @@ -29,7 +30,7 @@ class PgsqlRequirementsHooks { // Set the requirement just for postgres. if ($connection->driver() == 'pgsql') { $requirements['pgsql_extension_pg_trgm'] = [ - 'severity' => REQUIREMENT_OK, + 'severity' => RequirementSeverity::OK, 'title' => $this->t('PostgreSQL pg_trgm extension'), 'value' => $this->t('Available'), 'description' => $this->t('The pg_trgm PostgreSQL extension is present.'), @@ -37,7 +38,7 @@ class PgsqlRequirementsHooks { // If the extension is not available, set the requirement error. if (!$connection->schema()->extensionExists('pg_trgm')) { - $requirements['pgsql_extension_pg_trgm']['severity'] = REQUIREMENT_ERROR; + $requirements['pgsql_extension_pg_trgm']['severity'] = RequirementSeverity::Error; $requirements['pgsql_extension_pg_trgm']['value'] = $this->t('Not created'); $requirements['pgsql_extension_pg_trgm']['description'] = $this->t('The <a href=":pg_trgm">pg_trgm</a> PostgreSQL extension is not present. The extension is required by Drupal to improve performance when using PostgreSQL. See <a href=":requirements">Drupal database server requirements</a> for more information.', [ ':pg_trgm' => 'https://www.postgresql.org/docs/current/pgtrgm.html', diff --git a/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php b/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php index a2f7771575e..ab4b936dcba 100644 --- a/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php +++ b/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php @@ -6,6 +6,7 @@ namespace Drupal\pgsql\Install\Requirements; use Drupal\Core\Database\Database; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Install time requirements for the pgsql module. @@ -24,7 +25,7 @@ class PgsqlRequirements implements InstallRequirementsInterface { // Set the requirement just for postgres. if ($connection->driver() == 'pgsql') { $requirements['pgsql_extension_pg_trgm'] = [ - 'severity' => REQUIREMENT_OK, + 'severity' => RequirementSeverity::OK, 'title' => t('PostgreSQL pg_trgm extension'), 'value' => t('Available'), 'description' => t('The pg_trgm PostgreSQL extension is present.'), @@ -32,7 +33,7 @@ class PgsqlRequirements implements InstallRequirementsInterface { // If the extension is not available, set the requirement error. if (!$connection->schema()->extensionExists('pg_trgm')) { - $requirements['pgsql_extension_pg_trgm']['severity'] = REQUIREMENT_ERROR; + $requirements['pgsql_extension_pg_trgm']['severity'] = RequirementSeverity::Error; $requirements['pgsql_extension_pg_trgm']['value'] = t('Not created'); $requirements['pgsql_extension_pg_trgm']['description'] = t('The <a href=":pg_trgm">pg_trgm</a> PostgreSQL extension is not present. The extension is required by Drupal to improve performance when using PostgreSQL. See <a href=":requirements">Drupal database server requirements</a> for more information.', [ ':pg_trgm' => 'https://www.postgresql.org/docs/current/pgtrgm.html', diff --git a/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php b/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php index 5d862a86421..5c5e6be5838 100644 --- a/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php +++ b/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php @@ -328,7 +328,7 @@ class ResponsiveImageFieldDisplayTest extends ImageFieldTestBase { if (!$empty_styles) { $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:image.style.medium'); $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:image.style.thumbnail'); - $this->assertSession()->responseContains('type="image/webp"'); + $this->assertSession()->responseContains('type="image/avif"'); } $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:image.style.large'); @@ -504,7 +504,7 @@ class ResponsiveImageFieldDisplayTest extends ImageFieldTestBase { // Assert the picture tag has source tags that include dimensions. $this->drupalGet('node/' . $nid); - $this->assertSession()->responseMatches('/<picture>\s+<source srcset="' . \preg_quote($large_transform_url, '/') . ' 1x" media="\(min-width: 851px\)" type="image\/webp" width="480" height="480"\/>\s+<source srcset="' . \preg_quote($medium_transform_url, '/') . ' 1x, ' . \preg_quote($large_transform_url, '/') . ' 1.5x, ' . \preg_quote($large_transform_url, '/') . ' 2x" type="image\/webp" width="220" height="220"\/>\s+<img loading="eager" width="480" height="480" src="' . \preg_quote($large_transform_url, '/') . '" alt="\w+" \/>\s+<\/picture>/'); + $this->assertSession()->responseMatches('/<picture>\s+<source srcset="' . \preg_quote($large_transform_url, '/') . ' 1x" media="\(min-width: 851px\)" type="image\/avif" width="480" height="480"\/>\s+<source srcset="' . \preg_quote($medium_transform_url, '/') . ' 1x, ' . \preg_quote($large_transform_url, '/') . ' 1.5x, ' . \preg_quote($large_transform_url, '/') . ' 2x" type="image\/avif" width="220" height="220"\/>\s+<img loading="eager" width="480" height="480" src="' . \preg_quote($large_transform_url, '/') . '" alt="\w+" \/>\s+<\/picture>/'); } /** diff --git a/core/modules/search/src/Hook/SearchRequirements.php b/core/modules/search/src/Hook/SearchRequirements.php index 4fd79e64031..14e7dcb1649 100644 --- a/core/modules/search/src/Hook/SearchRequirements.php +++ b/core/modules/search/src/Hook/SearchRequirements.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\search\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\search\SearchPageRepositoryInterface; @@ -42,7 +43,7 @@ class SearchRequirements { $requirements['search_status'] = [ 'title' => $this->t('Search index progress'), 'value' => $this->t('@percent% (@remaining remaining)', ['@percent' => $percent, '@remaining' => $remaining]), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; return $requirements; } diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php index 8158de67c50..46bea0731d0 100644 --- a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php +++ b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php @@ -415,7 +415,7 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn public function prepareStatement(string $query, array $options, bool $allow_row_count = FALSE): StatementInterface { assert(!isset($options['return']), 'Passing "return" option to prepareStatement() has no effect. See https://www.drupal.org/node/3185520'); if (isset($options['fetch']) && is_int($options['fetch'])) { - @trigger_error("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); } try { diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php b/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php index 1c7378a0173..c3060a57234 100644 --- a/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php +++ b/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php @@ -87,7 +87,7 @@ class Statement extends StatementPrefetchIterator implements StatementInterface */ public function execute($args = [], $options = []) { if (isset($options['fetch']) && is_int($options['fetch'])) { - @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); } try { diff --git a/core/modules/system/css/components/position-container.module.css b/core/modules/system/css/components/position-container.module.css deleted file mode 100644 index ae209f3aa61..00000000000 --- a/core/modules/system/css/components/position-container.module.css +++ /dev/null @@ -1,8 +0,0 @@ -/* - * @file - * Contain positioned elements. - */ - -.position-container { - position: relative; -} diff --git a/core/modules/system/src/Controller/DbUpdateController.php b/core/modules/system/src/Controller/DbUpdateController.php index 131b6a075d5..2f5ae051204 100644 --- a/core/modules/system/src/Controller/DbUpdateController.php +++ b/core/modules/system/src/Controller/DbUpdateController.php @@ -7,6 +7,7 @@ use Drupal\Core\Batch\BatchBuilder; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface; use Drupal\Core\Render\BareHtmlPageRendererInterface; use Drupal\Core\Session\AccountInterface; @@ -166,8 +167,8 @@ class DbUpdateController extends ControllerBase { $regions = []; $requirements = update_check_requirements(); - $severity = drupal_requirements_severity($requirements); - if ($severity == REQUIREMENT_ERROR || ($severity == REQUIREMENT_WARNING && !$request->getSession()->has('update_ignore_warnings'))) { + $severity = RequirementSeverity::maxSeverityFromRequirements($requirements); + if ($severity === RequirementSeverity::Error || ($severity === RequirementSeverity::Warning && !$request->getSession()->has('update_ignore_warnings'))) { $regions['sidebar_first'] = $this->updateTasksList('requirements'); $output = $this->requirements($severity, $requirements, $request); } @@ -543,7 +544,7 @@ class DbUpdateController extends ControllerBase { * A render array. */ public function requirements($severity, array $requirements, Request $request) { - $options = $severity == REQUIREMENT_WARNING ? ['continue' => 1] : []; + $options = $severity === RequirementSeverity::Warning ? ['continue' => 1] : []; // @todo Revisit once https://www.drupal.org/node/2548095 is in. Something // like Url::fromRoute('system.db_update')->setOptions() should then be // possible. diff --git a/core/modules/system/src/Element/StatusReportPage.php b/core/modules/system/src/Element/StatusReportPage.php index 90a878831ea..2d6494f2fe3 100644 --- a/core/modules/system/src/Element/StatusReportPage.php +++ b/core/modules/system/src/Element/StatusReportPage.php @@ -2,9 +2,9 @@ namespace Drupal\system\Element; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Render\Attribute\RenderElement; use Drupal\Core\Render\Element\RenderElementBase; -use Drupal\Core\Render\Element\StatusReport; use Drupal\Core\StringTranslation\PluralTranslatableMarkup; /** @@ -37,6 +37,7 @@ class StatusReportPage extends RenderElementBase { '#theme' => 'status_report_general_info', ]; // Loop through requirements and pull out items. + RequirementSeverity::convertLegacyIntSeveritiesToEnums($element['#requirements'], __METHOD__); foreach ($element['#requirements'] as $key => $requirement) { switch ($key) { case 'cron': @@ -59,10 +60,10 @@ class StatusReportPage extends RenderElementBase { case 'php': case 'php_memory_limit': $element['#general_info']['#' . $key] = $requirement; - if (isset($requirement['severity']) && $requirement['severity'] < REQUIREMENT_WARNING) { - if (empty($requirement['severity']) || $requirement['severity'] == REQUIREMENT_OK) { - unset($element['#requirements'][$key]); - } + if (isset($requirement['severity']) && + in_array($requirement['severity'], [RequirementSeverity::Info, RequirementSeverity::OK], TRUE) + ) { + unset($element['#requirements'][$key]); } break; } @@ -94,18 +95,18 @@ class StatusReportPage extends RenderElementBase { ], ]; - $severities = StatusReport::getSeverities(); + RequirementSeverity::convertLegacyIntSeveritiesToEnums($element['#requirements'], __METHOD__); foreach ($element['#requirements'] as $key => &$requirement) { - $severity = $severities[REQUIREMENT_INFO]; + $severity = RequirementSeverity::Info; if (isset($requirement['severity'])) { - $severity = $severities[(int) $requirement['severity']]; + $severity = $requirement['severity']; } elseif (defined('MAINTENANCE_MODE') && MAINTENANCE_MODE == 'install') { - $severity = $severities[REQUIREMENT_OK]; + $severity = RequirementSeverity::OK; } - if (isset($counters[$severity['status']])) { - $counters[$severity['status']]['amount']++; + if (isset($counters[$severity->status()])) { + $counters[$severity->status()]['amount']++; } } diff --git a/core/modules/system/src/Hook/SystemHooks.php b/core/modules/system/src/Hook/SystemHooks.php index ae3e8d71074..86d18164623 100644 --- a/core/modules/system/src/Hook/SystemHooks.php +++ b/core/modules/system/src/Hook/SystemHooks.php @@ -276,7 +276,11 @@ class SystemHooks { // before doing so. Also add the loaded libraries to ajaxPageState. /** @var \Drupal\Core\Asset\LibraryDependencyResolver $library_dependency_resolver */ $library_dependency_resolver = \Drupal::service('library.dependency_resolver'); - if (isset($settings['ajaxPageState']) || in_array('core/drupal.ajax', $library_dependency_resolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries()))) { + $loaded_libraries = []; + if (!isset($settings['ajaxPageState'])) { + $loaded_libraries = $library_dependency_resolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries()); + } + if (isset($settings['ajaxPageState']) || in_array('core/drupal.ajax', $loaded_libraries) || in_array('core/drupal.htmx', $loaded_libraries)) { if (!defined('MAINTENANCE_MODE')) { // The theme token is only validated when the theme requested is not the // default, so don't generate it unless necessary. diff --git a/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php b/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php index 670fbc06cf7..98c1a093a44 100644 --- a/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php +++ b/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php @@ -4,6 +4,7 @@ namespace Drupal\system\Plugin\ImageToolkit; use Drupal\Component\Utility\Color; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\FileExists; use Drupal\Core\File\FileSystemInterface; @@ -439,9 +440,6 @@ class GDToolkit extends ImageToolkitBase { IMG_AVIF => 'AVIF', ]; $supported_formats = array_filter($check_formats, fn($type) => imagetypes() & $type, ARRAY_FILTER_USE_KEY); - if (isset($supported_formats[IMG_AVIF]) && !$this->checkAvifSupport()) { - unset($supported_formats[IMG_AVIF]); - } $unsupported_formats = array_diff_key($check_formats, $supported_formats); $descriptions = []; @@ -454,7 +452,7 @@ class GDToolkit extends ImageToolkitBase { ); } if ($unsupported_formats) { - $requirements['version']['severity'] = REQUIREMENT_WARNING; + $requirements['version']['severity'] = RequirementSeverity::Warning; $unsupported = $this->formatPlural( count($unsupported_formats), 'Unsupported image file format: %formats.', @@ -475,7 +473,7 @@ class GDToolkit extends ImageToolkitBase { // Check for filter and rotate support. if (!function_exists('imagefilter') || !function_exists('imagerotate')) { - $requirements['version']['severity'] = REQUIREMENT_WARNING; + $requirements['version']['severity'] = RequirementSeverity::Warning; $descriptions[] = $this->t('The GD Library for PHP is enabled, but was compiled without support for functions used by the rotate and desaturate effects. It was probably compiled using the official GD libraries from the <a href="https://libgd.github.io/">gdLibrary site</a> instead of the GD library bundled with PHP. You should recompile PHP --with-gd using the bundled GD library. See <a href="https://www.php.net/manual/book.image.php">the PHP manual</a>.'); } @@ -556,7 +554,7 @@ class GDToolkit extends ImageToolkitBase { * @return bool * TRUE if AVIF is fully supported, FALSE otherwise. */ - protected function checkAvifSupport(): bool { + protected static function checkAvifSupport(): bool { static $supported = NULL; if ($supported !== NULL) { @@ -578,13 +576,16 @@ class GDToolkit extends ImageToolkitBase { * IMAGETYPE_* constant (e.g. IMAGETYPE_JPEG, IMAGETYPE_PNG, etc.). */ protected static function supportedTypes() { - return [ + $types = [ IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF, IMAGETYPE_WEBP, - IMAGETYPE_AVIF, ]; + if (static::checkAvifSupport()) { + $types[] = IMAGETYPE_AVIF; + } + return $types; } } diff --git a/core/modules/system/src/SystemManager.php b/core/modules/system/src/SystemManager.php index 5534e70147b..43a53fe0542 100644 --- a/core/modules/system/src/SystemManager.php +++ b/core/modules/system/src/SystemManager.php @@ -2,11 +2,12 @@ namespace Drupal\system; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Menu\MenuActiveTrailInterface; -use Drupal\Core\Menu\MenuLinkTreeInterface; use Drupal\Core\Menu\MenuLinkInterface; +use Drupal\Core\Menu\MenuLinkTreeInterface; use Drupal\Core\Menu\MenuTreeParameters; -use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Symfony\Component\HttpFoundation\RequestStack; @@ -54,16 +55,31 @@ class SystemManager { /** * Requirement severity -- Requirement successfully met. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::OK instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_OK = 0; /** * Requirement severity -- Warning condition; proceed but flag warning. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::Warning instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_WARNING = 1; /** * Requirement severity -- Error condition; abort installation. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::Error instead. + * + * @see https://www.drupal.org/node/3410939 */ const REQUIREMENT_ERROR = 2; @@ -94,7 +110,7 @@ class SystemManager { */ public function checkRequirements() { $requirements = $this->listRequirements(); - return $this->getMaxSeverity($requirements) == static::REQUIREMENT_ERROR; + return RequirementSeverity::maxSeverityFromRequirements($requirements) === RequirementSeverity::Error; } /** @@ -136,15 +152,16 @@ class SystemManager { * * @return int * The highest severity in the array. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use + * \Drupal\Core\Extension\Requirement\RequirementSeverity::getMaxSeverity() + * instead. + * + * @see https://www.drupal.org/node/3410939 */ public function getMaxSeverity(&$requirements) { - $severity = static::REQUIREMENT_OK; - foreach ($requirements as $requirement) { - if (isset($requirement['severity'])) { - $severity = max($severity, $requirement['severity']); - } - } - return $severity; + @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use ' . RequirementSeverity::class . '::maxSeverityFromRequirements() instead. See https://www.drupal.org/node/3410939', \E_USER_DEPRECATED); + return RequirementSeverity::maxSeverityFromRequirements($requirements)->value; } /** diff --git a/core/modules/system/system.install b/core/modules/system/system.install index 9b8c25c157e..431651d08e2 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -14,9 +14,9 @@ use Drupal\Component\Utility\Unicode; use Drupal\Core\Database\Database; use Drupal\Core\DrupalKernel; use Drupal\Core\Extension\ExtensionLifecycle; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Link; -use Drupal\Core\Utility\PhpRequirements; use Drupal\Core\Render\Markup; use Drupal\Core\Site\Settings; use Drupal\Core\StreamWrapper\PrivateStream; @@ -27,6 +27,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Update\EquivalentUpdate; use Drupal\Core\Url; use Drupal\Core\Utility\Error; +use Drupal\Core\Utility\PhpRequirements; use Psr\Http\Client\ClientExceptionInterface; use Symfony\Component\HttpFoundation\Request; @@ -83,7 +84,7 @@ function system_requirements($phase): array { $requirements['drupal'] = [ 'title' => t('Drupal'), 'value' => \Drupal::VERSION, - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'weight' => -10, ]; @@ -99,7 +100,7 @@ function system_requirements($phase): array { '%profile' => $profile, '%version' => !empty($info['version']) ? '-' . $info['version'] : '', ]), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'weight' => -9, ]; } @@ -129,7 +130,7 @@ function system_requirements($phase): array { $requirements['experimental_modules'] = [ 'title' => t('Experimental modules installed'), 'value' => t('Experimental modules found: %module_list. <a href=":url">Experimental modules</a> are provided for testing purposes only. Use at your own risk.', ['%module_list' => implode(', ', $experimental_modules), ':url' => 'https://www.drupal.org/core/experimental']), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } // Warn if any deprecated modules are installed. @@ -142,7 +143,7 @@ function system_requirements($phase): array { 'value' => t('Deprecated modules found: %module_list.', [ '%module_list' => Markup::create(implode(', ', $deprecated_modules_link_list)), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -170,7 +171,7 @@ function system_requirements($phase): array { $requirements['experimental_themes'] = [ 'title' => t('Experimental themes installed'), 'value' => t('Experimental themes found: %theme_list. Experimental themes are provided for testing purposes only. Use at your own risk.', ['%theme_list' => implode(', ', $experimental_themes)]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -185,7 +186,7 @@ function system_requirements($phase): array { 'value' => t('Deprecated themes found: %theme_list.', [ '%theme_list' => Markup::create(implode(', ', $deprecated_themes_link_list)), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -200,7 +201,7 @@ function system_requirements($phase): array { '%extensions' => Markup::create(implode(', ', $obsolete_extensions_link_list)), ':uninstall_url' => Url::fromRoute('system.modules_uninstall')->toString(), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } _system_advisories_requirements($requirements); @@ -264,7 +265,7 @@ function system_requirements($phase): array { $requirements['apache_version'] = [ 'title' => t('Apache version'), 'value' => $apache_version_string, - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('Due to the settings for ServerTokens in httpd.conf, it is impossible to accurately determine the version of Apache running on this server. The reported value is @reported, to run Drupal without mod_rewrite, a minimum version of 2.2.16 is needed.', ['@reported' => $apache_version_string]), ]; } @@ -273,7 +274,7 @@ function system_requirements($phase): array { $requirements['Apache version'] = [ 'title' => t('Apache version'), 'value' => $apache_version_string, - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The minimum version of Apache needed to run Drupal without mod_rewrite enabled is 2.2.16. See the <a href=":link">enabling clean URLs</a> page for more information on mod_rewrite.', [':link' => 'https://www.drupal.org/docs/8/clean-urls-in-drupal-8']), ]; } @@ -282,7 +283,7 @@ function system_requirements($phase): array { $requirements['rewrite_module'] = [ 'title' => t('Clean URLs'), 'value' => t('Disabled'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('Your server is capable of using clean URLs, but it is not enabled. Using clean URLs gives an improved user experience and is recommended. <a href=":link">Enable clean URLs</a>', [':link' => 'https://www.drupal.org/docs/8/clean-urls-in-drupal-8']), ]; } @@ -319,19 +320,19 @@ function system_requirements($phase): array { // safe to continue with the requirements check, and should always be an // error. if (version_compare($phpversion, \Drupal::MINIMUM_PHP) < 0) { - $requirements['php']['severity'] = REQUIREMENT_ERROR; + $requirements['php']['severity'] = RequirementSeverity::Error; return $requirements; } // Otherwise, the message should be an error at runtime, and a warning // during installation or update. - $requirements['php']['severity'] = ($phase === 'runtime') ? REQUIREMENT_ERROR : REQUIREMENT_WARNING; + $requirements['php']['severity'] = ($phase === 'runtime') ? RequirementSeverity::Error : RequirementSeverity::Warning; } // For PHP versions that are still supported but no longer recommended, // inform users of what's recommended, allowing them to take action before it // becomes urgent. elseif ($phase === 'runtime' && version_compare($phpversion, \Drupal::RECOMMENDED_PHP) < 0) { $requirements['php']['description'] = t('It is recommended to upgrade to PHP version %recommended or higher for the best ongoing support. See <a href="http://php.net/supported-versions.php">PHP\'s version support documentation</a> and the <a href=":php_requirements">Drupal PHP requirements</a> page for more information.', ['%recommended' => \Drupal::RECOMMENDED_PHP, ':php_requirements' => 'https://www.drupal.org/docs/system-requirements/php-requirements']); - $requirements['php']['severity'] = REQUIREMENT_INFO; + $requirements['php']['severity'] = RequirementSeverity::Info; } // Test for PHP extensions. @@ -381,7 +382,7 @@ function system_requirements($phase): array { ]; $requirements['php_extensions']['value'] = t('Disabled'); - $requirements['php_extensions']['severity'] = REQUIREMENT_ERROR; + $requirements['php_extensions']['severity'] = RequirementSeverity::Error; $requirements['php_extensions']['description'] = $description; } else { @@ -393,7 +394,7 @@ function system_requirements($phase): array { if (!OpCodeCache::isEnabled()) { $requirements['php_opcache'] = [ 'value' => t('Not enabled'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('PHP OPcode caching can improve your site\'s performance considerably. It is <strong>highly recommended</strong> to have <a href="http://php.net/manual/opcache.installation.php" target="_blank">OPcache</a> installed on your server.'), ]; } @@ -413,7 +414,7 @@ function system_requirements($phase): array { $apcu_recommended_size = '32 MB'; $requirements['php_apcu_enabled']['value'] = t('Enabled (@size)', ['@size' => $apcu_actual_size]); if (Bytes::toNumber(ini_get('apc.shm_size')) * ini_get('apc.shm_segments') < Bytes::toNumber($apcu_recommended_size)) { - $requirements['php_apcu_enabled']['severity'] = REQUIREMENT_WARNING; + $requirements['php_apcu_enabled']['severity'] = RequirementSeverity::Warning; $requirements['php_apcu_enabled']['description'] = t('Depending on your configuration, Drupal can run with a @apcu_size APCu limit. However, a @apcu_default_size APCu limit (the default) or above is recommended, especially if your site uses additional custom or contributed modules.', [ '@apcu_size' => $apcu_actual_size, '@apcu_default_size' => $apcu_recommended_size, @@ -422,19 +423,19 @@ function system_requirements($phase): array { else { $memory_available = $memory_info['avail_mem'] / ($memory_info['seg_size'] * $memory_info['num_seg']); if ($memory_available < 0.1) { - $requirements['php_apcu_available']['severity'] = REQUIREMENT_ERROR; + $requirements['php_apcu_available']['severity'] = RequirementSeverity::Error; $requirements['php_apcu_available']['description'] = t('APCu is using over 90% of its allotted memory (@apcu_actual_size). To improve APCu performance, consider increasing this limit.', [ '@apcu_actual_size' => $apcu_actual_size, ]); } elseif ($memory_available < 0.25) { - $requirements['php_apcu_available']['severity'] = REQUIREMENT_WARNING; + $requirements['php_apcu_available']['severity'] = RequirementSeverity::Warning; $requirements['php_apcu_available']['description'] = t('APCu is using over 75% of its allotted memory (@apcu_actual_size). To improve APCu performance, consider increasing this limit.', [ '@apcu_actual_size' => $apcu_actual_size, ]); } else { - $requirements['php_apcu_available']['severity'] = REQUIREMENT_OK; + $requirements['php_apcu_available']['severity'] = RequirementSeverity::OK; } $requirements['php_apcu_available']['value'] = t('Memory available: @available.', [ '@available' => ByteSizeMarkup::create($memory_info['avail_mem']), @@ -444,7 +445,7 @@ function system_requirements($phase): array { else { $requirements['php_apcu_enabled'] += [ 'value' => t('Not enabled'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => t('PHP APCu caching can improve your site\'s performance considerably. It is <strong>highly recommended</strong> to have <a href="https://www.php.net/manual/apcu.installation.php" target="_blank">APCu</a> installed on your server.'), ]; } @@ -484,7 +485,7 @@ function system_requirements($phase): array { $requirements['php_random_bytes']['description'] = t('Drupal is unable to generate highly randomized numbers, which means certain security features like password reset URLs are not as secure as they should be. Instead, only a slow, less-secure fallback generator is available. See the <a href=":drupal-php">system requirements</a> page for more information. %exception_message', $args); } $requirements['php_random_bytes']['value'] = t('Less secure'); - $requirements['php_random_bytes']['severity'] = REQUIREMENT_ERROR; + $requirements['php_random_bytes']['severity'] = RequirementSeverity::Error; } } @@ -493,7 +494,7 @@ function system_requirements($phase): array { $requirements['output_buffering'] = [ 'title' => t('Output Buffering'), 'error_value' => t('Not enabled'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('<a href="https://www.php.net/manual/en/function.ob-start.php">Output buffering</a> is not enabled. This may degrade Drupal\'s performance. You can enable output buffering by default <a href="https://www.php.net/manual/en/outcontrol.configuration.php#ini.output-buffering">in your PHP settings</a>.'), ]; } @@ -532,7 +533,7 @@ function system_requirements($phase): array { if (!$database_ok) { $requirements['database_extensions']['value'] = t('Disabled'); - $requirements['database_extensions']['severity'] = REQUIREMENT_ERROR; + $requirements['database_extensions']['severity'] = RequirementSeverity::Error; $requirements['database_extensions']['description'] = $pdo_message; } else { @@ -563,7 +564,7 @@ function system_requirements($phase): array { // Use the comma-list style to display a single error without bullets. '#context' => ['list_style' => $error_count === 1 ? 'comma-list' : ''], ]; - $requirements['database_system_version']['severity'] = REQUIREMENT_ERROR; + $requirements['database_system_version']['severity'] = RequirementSeverity::Error; $requirements['database_system_version']['description'] = $error_message; } } @@ -572,14 +573,14 @@ function system_requirements($phase): array { // Test database JSON support. $requirements['database_support_json'] = [ 'title' => t('Database support for JSON'), - 'severity' => REQUIREMENT_OK, + 'severity' => RequirementSeverity::OK, 'value' => t('Available'), 'description' => t('Drupal requires databases that support JSON storage.'), ]; if (!Database::getConnection()->hasJson()) { $requirements['database_support_json']['value'] = t('Not available'); - $requirements['database_support_json']['severity'] = REQUIREMENT_ERROR; + $requirements['database_support_json']['severity'] = RequirementSeverity::Error; } } @@ -623,7 +624,7 @@ function system_requirements($phase): array { ]; $requirements['php_memory_limit']['description'] = $description; - $requirements['php_memory_limit']['severity'] = REQUIREMENT_WARNING; + $requirements['php_memory_limit']['severity'] = RequirementSeverity::Warning; } } @@ -645,12 +646,12 @@ function system_requirements($phase): array { $error_value = t('Protection disabled'); // If permissions hardening is disabled, then only show a warning for a // writable file, as a reminder, rather than an error. - $file_protection_severity = REQUIREMENT_WARNING; + $file_protection_severity = RequirementSeverity::Warning; } else { $error_value = t('Not protected'); // In normal operation, writable files or directories are an error. - $file_protection_severity = REQUIREMENT_ERROR; + $file_protection_severity = RequirementSeverity::Error; if (!drupal_verify_install_file($site_path, FILE_NOT_WRITABLE, 'dir')) { $conf_errors[] = t("The directory %file is not protected from modifications and poses a security risk. You must change the directory's permissions to be non-writable.", ['%file' => $site_path]); } @@ -709,7 +710,7 @@ function system_requirements($phase): array { // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString 'title' => new TranslatableMarkup($protected_dir->getTitle()), 'value' => t('Not fully protected'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('See <a href=":url">@url</a> for information about the recommended .htaccess file which should be added to the %directory directory to help protect against arbitrary code execution.', [':url' => $url, '@url' => $url, '%directory' => $protected_dir->getPath()]), ]; } @@ -731,13 +732,13 @@ function system_requirements($phase): array { } // Determine severity based on time since cron last ran. - $severity = REQUIREMENT_INFO; + $severity = RequirementSeverity::Info; $request_time = \Drupal::time()->getRequestTime(); if ($request_time - $cron_last > $threshold_error) { - $severity = REQUIREMENT_ERROR; + $severity = RequirementSeverity::Error; } elseif ($request_time - $cron_last > $threshold_warning) { - $severity = REQUIREMENT_WARNING; + $severity = RequirementSeverity::Warning; } // Set summary and description based on values determined above. @@ -748,7 +749,7 @@ function system_requirements($phase): array { 'severity' => $severity, 'value' => $summary, ]; - if ($severity != REQUIREMENT_INFO) { + if ($severity != RequirementSeverity::Info) { $requirements['cron']['description'][] = [ [ '#markup' => t('Cron has not run recently.'), @@ -833,7 +834,7 @@ function system_requirements($phase): array { $requirements['config sync directory'] = [ 'title' => t('Configuration sync directory'), 'description' => $description, - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -842,7 +843,7 @@ function system_requirements($phase): array { 'title' => t('Configuration sync directory'), 'value' => t('Not present'), 'description' => t("Your %file file must define the %setting setting as a string containing the directory in which configuration files can be found.", ['%file' => $site_path . '/settings.php', '%setting' => "\$settings['config_sync_directory']"]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } @@ -891,7 +892,7 @@ function system_requirements($phase): array { ], ]; $requirements['file system']['description'] = $description; - $requirements['file system']['severity'] = REQUIREMENT_ERROR; + $requirements['file system']['severity'] = RequirementSeverity::Error; } } else { @@ -937,7 +938,7 @@ function system_requirements($phase): array { } if ($has_pending_updates) { - $requirements['update']['severity'] = REQUIREMENT_ERROR; + $requirements['update']['severity'] = RequirementSeverity::Error; $requirements['update']['value'] = t('Out of date'); $requirements['update']['description'] = t('Some modules have database schema updates to install. You should run the <a href=":update">database update script</a> immediately.', [':update' => Url::fromRoute('system.db_update')->toString()]); } @@ -959,7 +960,7 @@ function system_requirements($phase): array { } $entity_update_issues = \Drupal::service('renderer')->renderInIsolation($build); - $requirements['entity_update']['severity'] = REQUIREMENT_ERROR; + $requirements['entity_update']['severity'] = RequirementSeverity::Error; $requirements['entity_update']['value'] = t('Mismatched entity and/or field definitions'); $requirements['entity_update']['description'] = t('The following changes were detected in the entity type and field definitions. @updates', ['@updates' => $entity_update_issues]); } @@ -971,7 +972,7 @@ function system_requirements($phase): array { $requirements['deployment identifier'] = [ 'title' => t('Deployment identifier'), 'value' => $deployment_identifier, - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, ]; } } @@ -981,7 +982,7 @@ function system_requirements($phase): array { if (Settings::get('update_free_access')) { $requirements['update access'] = [ 'value' => t('Not protected'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The update.php script is accessible to everyone without authentication check, which is a security risk. You must change the @settings_name value in your settings.php back to FALSE.', ['@settings_name' => '$settings[\'update_free_access\']']), ]; } @@ -1023,7 +1024,7 @@ function system_requirements($phase): array { '#markup' => $message, ], ], - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; }; $profile = \Drupal::installProfile(); @@ -1058,7 +1059,7 @@ function system_requirements($phase): array { 'title' => t('Unresolved dependency'), 'description' => t('@name requires this module.', ['@name' => $name]), 'value' => t('@required_name (Missing)', ['@required_name' => $required_module]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; continue; } @@ -1072,7 +1073,7 @@ function system_requirements($phase): array { 'title' => t('Unresolved dependency'), 'description' => t('@name requires this module and version. Currently using @required_name version @version', ['@name' => $name, '@required_name' => $required_name, '@version' => $version]), 'value' => t('@required_name (Version @compatibility required)', ['@required_name' => $required_name, '@compatibility' => $requirement->getConstraintString()]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; continue; } @@ -1272,9 +1273,9 @@ function system_requirements($phase): array { Unicode::STATUS_ERROR => t('Error'), ]; $severities = [ - Unicode::STATUS_SINGLEBYTE => REQUIREMENT_WARNING, + Unicode::STATUS_SINGLEBYTE => RequirementSeverity::Warning, Unicode::STATUS_MULTIBYTE => NULL, - Unicode::STATUS_ERROR => REQUIREMENT_ERROR, + Unicode::STATUS_ERROR => RequirementSeverity::Error, ]; $failed_check = Unicode::check(); $library = Unicode::getStatus(); @@ -1299,7 +1300,7 @@ function system_requirements($phase): array { if (!\Drupal::moduleHandler()->moduleExists('update')) { $requirements['update status'] = [ 'value' => t('Not enabled'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, 'description' => t('Update notifications are not enabled. It is <strong>highly recommended</strong> that you install the Update Status module from the <a href=":module">module administration page</a> in order to stay up-to-date on new releases. For more information, <a href=":update">Update status handbook page</a>.', [ ':update' => 'https://www.drupal.org/documentation/modules/update', ':module' => Url::fromRoute('system.modules_list')->toString(), @@ -1317,7 +1318,7 @@ function system_requirements($phase): array { $requirements['rebuild access'] = [ 'title' => t('Rebuild access'), 'value' => t('Enabled'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The rebuild_access setting is enabled in settings.php. It is recommended to have this setting disabled unless you are performing a rebuild.'), ]; } @@ -1338,7 +1339,7 @@ function system_requirements($phase): array { $requirements['php_session_samesite'] = [ 'title' => t('SameSite cookie attribute'), 'value' => $samesite, - 'severity' => $valid ? REQUIREMENT_OK : REQUIREMENT_WARNING, + 'severity' => $valid ? RequirementSeverity::OK : RequirementSeverity::Warning, 'description' => t('This attribute should be explicitly set to Lax, Strict or None. If set to None then the request must be made via HTTPS. See <a href=":url" target="_blank">PHP documentation</a>', [ ':url' => 'https://www.php.net/manual/en/session.configuration.php#ini.session.cookie-samesite', ]), @@ -1354,7 +1355,7 @@ function system_requirements($phase): array { 'title' => t('Trusted Host Settings'), 'value' => t('Not enabled'), 'description' => t('The trusted_host_patterns setting is not configured in settings.php. This can lead to security vulnerabilities. It is <strong>highly recommended</strong> that you configure this. See <a href=":url">Protecting against HTTP HOST Header attacks</a> for more information.', [':url' => 'https://www.drupal.org/docs/installing-drupal/trusted-host-settings']), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } else { @@ -1383,7 +1384,7 @@ function system_requirements($phase): array { 'title' => t('Database driver provided by module'), 'value' => t('Not installed'), 'description' => t('The current database driver is provided by the module: %module. The module is currently not installed. You should immediately <a href=":install">install</a> the module.', ['%module' => $provider, ':install' => Url::fromRoute('system.modules_list')->toString()]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1403,7 +1404,7 @@ function system_requirements($phase): array { 'title' => t('Xdebug settings'), 'value' => t('xdebug.max_nesting_level is set to %value.', ['%value' => $current_nesting_level]), 'description' => t('Set <code>xdebug.max_nesting_level=@level</code> in your PHP configuration as some pages in your Drupal site will not work when this setting is too low.', ['@level' => $minimum_nesting_level]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1420,7 +1421,7 @@ function system_requirements($phase): array { $requirements['max_path_on_windows'] = [ 'title' => t('Windows installation depth'), 'description' => t('The public files directory path is %depth characters. Paths longer than 120 characters will cause problems on Windows.', ['%depth' => $depth]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1430,7 +1431,7 @@ function system_requirements($phase): array { 'title' => t('Limited date range'), 'value' => t('Your PHP installation has a limited date range.'), 'description' => t('You are running on a system where PHP is compiled or limited to using 32-bit integers. This will limit the range of dates and timestamps to the years 1901-2038. Read about the <a href=":url">limitations of 32-bit PHP</a>.', [':url' => 'https://www.drupal.org/docs/system-requirements/limitations-of-32-bit-php']), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -1443,7 +1444,7 @@ function system_requirements($phase): array { 'title' => t('Configuration install'), 'value' => $install_state['parameters']['profile'], 'description' => t('The selected profile has a hook_install() implementation and therefore can not be installed from configuration.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1457,7 +1458,7 @@ function system_requirements($phase): array { $requirements['install_profile_in_settings'] = [ 'title' => t('Install profile in settings'), 'value' => t("Drupal 9 no longer uses the \$settings['install_profile'] value in settings.php and it should be removed."), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } } @@ -1501,7 +1502,7 @@ function system_requirements($phase): array { '@previous_major' => 9, ':url' => 'https://www.drupal.org/docs/upgrading-drupal/drupal-8-and-higher', ]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } else { @@ -1513,7 +1514,7 @@ function system_requirements($phase): array { '@last_removed_version' => $data['last_removed'], '@installed_version' => $data['installed_version'], ]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1541,7 +1542,7 @@ function system_requirements($phase): array { $requirements[$module . '_post_update_removed'] = [ 'title' => t('Missing updates for: @module', ['@module' => $module_info->info['name']]), 'description' => $description, - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; } } @@ -1575,7 +1576,7 @@ function system_requirements($phase): array { '@future_update' => $future_update, '@future_version_string' => $future_version_string, ]), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; break; } @@ -1598,7 +1599,7 @@ function system_requirements($phase): array { 'system.development_settings', )->toString(), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } $render_cache_disabled = $development_settings->get('disable_rendered_output_cache_bins', FALSE); @@ -1611,7 +1612,7 @@ function system_requirements($phase): array { 'system.development_settings', )->toString(), ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } } @@ -1702,7 +1703,7 @@ function _system_advisories_requirements(array &$requirements): void { } catch (ClientExceptionInterface $exception) { $requirements['system_advisories']['title'] = t('Critical security announcements'); - $requirements['system_advisories']['severity'] = REQUIREMENT_WARNING; + $requirements['system_advisories']['severity'] = RequirementSeverity::Warning; $requirements['system_advisories']['description'] = ['#theme' => 'system_security_advisories_fetch_error_message']; Error::logException(\Drupal::logger('system'), $exception, 'Failed to retrieve security advisory data.'); return; @@ -1710,10 +1711,10 @@ function _system_advisories_requirements(array &$requirements): void { if (!empty($advisories)) { $advisory_links = []; - $severity = REQUIREMENT_WARNING; + $severity = RequirementSeverity::Warning; foreach ($advisories as $advisory) { if (!$advisory->isPsa()) { - $severity = REQUIREMENT_ERROR; + $severity = RequirementSeverity::Error; } $advisory_links[] = new Link($advisory->getTitle(), Url::fromUri($advisory->getUrl())); } diff --git a/core/modules/system/system.libraries.yml b/core/modules/system/system.libraries.yml index 03baf83d3bb..af0eeea05d2 100644 --- a/core/modules/system/system.libraries.yml +++ b/core/modules/system/system.libraries.yml @@ -9,7 +9,6 @@ base: css/components/hidden.module.css: { weight: -10 } css/components/item-list.module.css: { weight: -10 } css/components/js.module.css: { weight: -10 } - css/components/position-container.module.css: { weight: -10 } css/components/reset-appearance.module.css: { weight: -10 } admin: diff --git a/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz b/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz Binary files differindex 5d8c9974469..077d0645ddc 100644 --- a/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz +++ b/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz diff --git a/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz b/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz Binary files differindex 423f49a1d40..5db0b3a5aae 100644 --- a/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz +++ b/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz diff --git a/core/modules/system/tests/modules/experimental_module_requirements_test/experimental_module_requirements_test.install b/core/modules/system/tests/modules/experimental_module_requirements_test/experimental_module_requirements_test.install index beaa3cd15b7..483a1d01717 100644 --- a/core/modules/system/tests/modules/experimental_module_requirements_test/experimental_module_requirements_test.install +++ b/core/modules/system/tests/modules/experimental_module_requirements_test/experimental_module_requirements_test.install @@ -7,6 +7,8 @@ declare(strict_types=1); +use Drupal\Core\Extension\Requirement\RequirementSeverity; + /** * Implements hook_requirements(). */ @@ -14,7 +16,7 @@ function experimental_module_requirements_test_requirements(): array { $requirements = []; if (\Drupal::state()->get('experimental_module_requirements_test_requirements', FALSE)) { $requirements['experimental_module_requirements_test_requirements'] = [ - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('The Experimental Test Requirements module can not be installed.'), ]; } diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php index 542c4e162e2..78328f9f8e4 100644 --- a/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php +++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php @@ -35,32 +35,28 @@ class FormTestClickedButtonForm extends FormBase { '#type' => 'textfield', ]; + // Get button configurations, filter out NULL values. + $args = array_filter([$first, $second, $third]); + + // Define button types for each argument. + $button_types = [ + 's' => 'submit', + 'i' => 'image_button', + 'b' => 'button', + ]; + // Loop through each path argument, adding buttons based on the information // in the argument. For example, if the path is // form-test/clicked-button/s/i/rb, then 3 buttons are added: a 'submit', an // 'image_button', and a 'button' with #access=FALSE. This enables form.test // to test a variety of combinations. - $i = 0; - $args = [$first, $second, $third]; - foreach ($args as $arg) { - $name = 'button' . ++$i; - // 's', 'b', or 'i' in the argument define the button type wanted. - if (!is_string($arg)) { - $type = NULL; - } - elseif (str_contains($arg, 's')) { - $type = 'submit'; - } - elseif (str_contains($arg, 'b')) { - $type = 'button'; - } - elseif (str_contains($arg, 'i')) { - $type = 'image_button'; - } - else { - $type = NULL; - } - if (isset($type)) { + foreach ($args as $index => $arg) { + // Get the button type based on the index of the argument. + $type = $button_types[$arg] ?? NULL; + $name = 'button' . ($index + 1); + + if ($type) { + // Define the button. $form[$name] = [ '#type' => $type, '#name' => $name, diff --git a/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php b/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php index 908d0d8d454..09dbf982cf7 100644 --- a/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php +++ b/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php @@ -253,7 +253,11 @@ class TestToolkit extends ImageToolkitBase { * IMAGETYPE_* constant (e.g. IMAGETYPE_JPEG, IMAGETYPE_PNG, etc.). */ protected static function supportedTypes() { - return [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF]; + $types = [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF]; + if (\Drupal::keyValue('image_test')->get('avif_enabled', FALSE)) { + $types[] = IMAGETYPE_AVIF; + } + return $types; } /** diff --git a/core/modules/system/tests/modules/module_install_unmet_requirements/src/Install/Requirements/ModuleInstallUnmetRequirementsRequirements.php b/core/modules/system/tests/modules/module_install_unmet_requirements/src/Install/Requirements/ModuleInstallUnmetRequirementsRequirements.php index 4d7367ff414..b6a532a1802 100644 --- a/core/modules/system/tests/modules/module_install_unmet_requirements/src/Install/Requirements/ModuleInstallUnmetRequirementsRequirements.php +++ b/core/modules/system/tests/modules/module_install_unmet_requirements/src/Install/Requirements/ModuleInstallUnmetRequirementsRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\module_install_unmet_requirements\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Provides method for checking requirements during install time. @@ -17,7 +18,7 @@ class ModuleInstallUnmetRequirementsRequirements implements InstallRequirementsI public static function getRequirements(): array { $requirements['testing_requirements'] = [ 'title' => t('Testing requirements'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('Testing requirements failed requirements.'), ]; diff --git a/core/modules/system/tests/modules/module_runtime_requirements/src/Hook/ModuleRuntimeRequirementsHooks.php b/core/modules/system/tests/modules/module_runtime_requirements/src/Hook/ModuleRuntimeRequirementsHooks.php index 0fa4b2f6f80..31358a595d7 100644 --- a/core/modules/system/tests/modules/module_runtime_requirements/src/Hook/ModuleRuntimeRequirementsHooks.php +++ b/core/modules/system/tests/modules/module_runtime_requirements/src/Hook/ModuleRuntimeRequirementsHooks.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\module_runtime_requirements\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -24,13 +25,13 @@ class ModuleRuntimeRequirementsHooks { 'title' => $this->t('RuntimeError'), 'value' => $this->t('None'), 'description' => $this->t('Runtime Error.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], 'test.runtime.error.alter' => [ 'title' => $this->t('RuntimeError'), 'value' => $this->t('None'), 'description' => $this->t('Runtime Error.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], ]; } @@ -44,7 +45,7 @@ class ModuleRuntimeRequirementsHooks { 'title' => $this->t('RuntimeWarning'), 'value' => $this->t('None'), 'description' => $this->t('Runtime Warning.'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } diff --git a/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php b/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php index 1cbb9e6b422..db923382a21 100644 --- a/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php +++ b/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php @@ -4,19 +4,19 @@ declare(strict_types=1); namespace Drupal\module_test_oop_preprocess\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for module_test_oop_preprocess. */ class ModuleTestOopPreprocessThemeHooks { - #[Preprocess] + #[Hook('preprocess')] public function rootPreprocess($arg): mixed { return $arg; } - #[Preprocess('test')] + #[Hook('preprocess_test')] public function preprocessTest($arg): mixed { return $arg; } diff --git a/core/modules/system/tests/modules/module_update_requirements/src/Hook/ModuleUpdateRequirementsHooks.php b/core/modules/system/tests/modules/module_update_requirements/src/Hook/ModuleUpdateRequirementsHooks.php index f0666222f14..073baba95c9 100644 --- a/core/modules/system/tests/modules/module_update_requirements/src/Hook/ModuleUpdateRequirementsHooks.php +++ b/core/modules/system/tests/modules/module_update_requirements/src/Hook/ModuleUpdateRequirementsHooks.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\module_update_requirements\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -24,13 +25,13 @@ class ModuleUpdateRequirementsHooks { 'title' => $this->t('UpdateError'), 'value' => $this->t('None'), 'description' => $this->t('Update Error.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], 'test.update.error.alter' => [ 'title' => $this->t('UpdateError'), 'value' => $this->t('None'), 'description' => $this->t('Update Error.'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ], ]; } @@ -44,7 +45,7 @@ class ModuleUpdateRequirementsHooks { 'title' => $this->t('UpdateWarning'), 'value' => $this->t('None'), 'description' => $this->t('Update Warning.'), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } diff --git a/core/modules/system/tests/modules/requirements1_test/requirements1_test.install b/core/modules/system/tests/modules/requirements1_test/requirements1_test.install index fb84be133cd..a93f726fafd 100644 --- a/core/modules/system/tests/modules/requirements1_test/requirements1_test.install +++ b/core/modules/system/tests/modules/requirements1_test/requirements1_test.install @@ -7,6 +7,8 @@ declare(strict_types=1); +use Drupal\Core\Extension\Requirement\RequirementSeverity; + /** * Implements hook_requirements(). * @@ -19,20 +21,20 @@ function requirements1_test_requirements($phase): array { if ('install' == $phase) { $requirements['requirements1_test'] = [ 'title' => t('Requirements 1 Test'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('Requirements 1 Test failed requirements.'), ]; } $requirements['requirements1_test_alterable'] = [ 'title' => t('Requirements 1 Test Alterable'), - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('A requirement that will be altered.'), ]; $requirements['requirements1_test_deletable'] = [ 'title' => t('Requirements 1 Test Deletable'), - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => t('A requirement that will be deleted.'), ]; diff --git a/core/modules/system/tests/modules/requirements1_test/src/Hook/Requirements1TestHooks.php b/core/modules/system/tests/modules/requirements1_test/src/Hook/Requirements1TestHooks.php index c766f6f423a..ce3eebfb35b 100644 --- a/core/modules/system/tests/modules/requirements1_test/src/Hook/Requirements1TestHooks.php +++ b/core/modules/system/tests/modules/requirements1_test/src/Hook/Requirements1TestHooks.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\requirements1_test\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -22,7 +23,7 @@ class Requirements1TestHooks { // Change the title. $requirements['requirements1_test_alterable']['title'] = $this->t('Requirements 1 Test - Changed'); // Decrease the severity. - $requirements['requirements1_test_alterable']['severity'] = REQUIREMENT_WARNING; + $requirements['requirements1_test_alterable']['severity'] = RequirementSeverity::Warning; // Delete 'requirements1_test_deletable', unset($requirements['requirements1_test_deletable']); } diff --git a/core/modules/system/tests/modules/session_test/session_test.routing.yml b/core/modules/system/tests/modules/session_test/session_test.routing.yml index fe85de11032..f11bd86b4d7 100644 --- a/core/modules/system/tests/modules/session_test/session_test.routing.yml +++ b/core/modules/system/tests/modules/session_test/session_test.routing.yml @@ -179,3 +179,25 @@ session_test.trigger_write_exception: no_cache: TRUE requirements: _access: 'TRUE' + +session_test.legacy_get: + path: '/session-test/legacy-get' + defaults: + _title: 'Legacy session value' + _controller: '\Drupal\session_test\Controller\LegacySessionTestController::get' + options: + no_cache: TRUE + requirements: + _access: 'TRUE' + +session_test.legacy_set: + path: '/session-test/legacy-set/{test_value}' + defaults: + _title: 'Set legacy session value' + _controller: '\Drupal\session_test\Controller\LegacySessionTestController::set' + options: + no_cache: TRUE + converters: + test_value: '\s+' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php b/core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php new file mode 100644 index 00000000000..a1438a0108e --- /dev/null +++ b/core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\session_test\Controller; + +use Drupal\Core\Controller\ControllerBase; + +/** + * Controller providing page callbacks for legacy session tests. + */ +class LegacySessionTestController extends ControllerBase { + + /** + * Prints the stored session value to the screen. + */ + public function get(): array { + return empty($_SESSION['legacy_test_value']) + ? [] + : ['#markup' => $this->t('The current value of the stored session variable is: %val', ['%val' => $_SESSION['legacy_test_value']])]; + } + + /** + * Stores a value in $_SESSION['legacy_test_value']. + * + * @param string $test_value + * A session value. + */ + public function set(string $test_value): array { + $_SESSION['legacy_test_value'] = $test_value; + + return ['#markup' => $this->t('The current value of the stored session variable has been set to %val', ['%val' => $test_value])]; + } + +} diff --git a/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php b/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php index 9c7bb97e24b..461581abaa7 100644 --- a/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php +++ b/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php @@ -11,20 +11,21 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; /** - * Controller providing page callbacks for the action admin interface. + * Controller providing page callbacks for session tests. */ class SessionTestController extends ControllerBase { /** * Prints the stored session value to the screen. * - * @return string - * A notification message. + * @param \Symfony\Component\HttpFoundation\Request $request + * The incoming request. */ - public function get() { - return empty($_SESSION['session_test_value']) + public function get(Request $request): array { + $value = $request->getSession()->get('session_test_value'); + return empty($value) ? [] - : ['#markup' => $this->t('The current value of the stored session variable is: %val', ['%val' => $_SESSION['session_test_value']])]; + : ['#markup' => $this->t('The current value of the stored session variable is: %val', ['%val' => $value])]; } /** @@ -32,11 +33,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The incoming request. - * - * @return string - * A notification message. */ - public function getFromSessionObject(Request $request) { + public function getFromSessionObject(Request $request): array { $value = $request->getSession()->get("session_test_key"); return empty($value) ? [] @@ -48,16 +46,13 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The incoming request. - * - * @return string - * A notification message with session ID. */ - public function getId(Request $request) { - // Set a value in $_SESSION, so that SessionManager::save() will start + public function getId(Request $request): array { + // Set a value in session, so that SessionManager::save() will start // a session. - $_SESSION['test'] = 'test'; - - $request->getSession()->save(); + $session = $request->getSession(); + $session->set('test', 'test'); + $session->save(); return ['#markup' => 'session_id:' . session_id() . "\n"]; } @@ -67,11 +62,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return string - * A notification message with session ID. */ - public function getIdFromCookie(Request $request) { + public function getIdFromCookie(Request $request): array { return [ '#markup' => 'session_id:' . $request->cookies->get(session_name()) . "\n", '#cache' => ['contexts' => ['cookies:' . session_name()]], @@ -79,16 +71,15 @@ class SessionTestController extends ControllerBase { } /** - * Stores a value in $_SESSION['session_test_value']. + * Stores a value in 'session_test_value' session attribute. * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. * @param string $test_value * A session value. - * - * @return string - * A notification message. */ - public function set($test_value) { - $_SESSION['session_test_value'] = $test_value; + public function set(Request $request, $test_value): array { + $request->getSession()->set('session_test_value', $test_value); return ['#markup' => $this->t('The current value of the stored session variable has been set to %val', ['%val' => $test_value])]; } @@ -96,25 +87,21 @@ class SessionTestController extends ControllerBase { /** * Turns off session saving and then tries to save a value anyway. * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. * @param string $test_value * A session value. - * - * @return string - * A notification message. */ - public function noSet($test_value) { + public function noSet(Request $request, $test_value): array { \Drupal::service('session_handler.write_safe')->setSessionWritable(FALSE); - $this->set($test_value); + $this->set($request, $test_value); return ['#markup' => $this->t('session saving was disabled, and then %val was set', ['%val' => $test_value])]; } /** * Sets a message to me displayed on the following page. - * - * @return string - * A notification message. */ - public function setMessage() { + public function setMessage(): Response { $this->messenger()->addStatus($this->t('This is a dummy message.')); return new Response((string) $this->t('A message was set.')); // Do not return anything, so the current request does not result in a @@ -124,11 +111,8 @@ class SessionTestController extends ControllerBase { /** * Sets a message but call drupal_save_session(FALSE). - * - * @return string - * A notification message. */ - public function setMessageButDoNotSave() { + public function setMessageButDoNotSave(): array { \Drupal::service('session_handler.write_safe')->setSessionWritable(FALSE); $this->setMessage(); return ['#markup' => '']; @@ -136,11 +120,8 @@ class SessionTestController extends ControllerBase { /** * Only available if current user is logged in. - * - * @return string - * A notification message. */ - public function isLoggedIn() { + public function isLoggedIn(): array { return ['#markup' => $this->t('User is logged in.')]; } @@ -149,20 +130,13 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The incoming request. - * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * The response. */ - public function traceHandler(Request $request) { - // Start a session if necessary, set a value and then save and close it. - $request->getSession()->start(); - if (empty($_SESSION['trace-handler'])) { - $_SESSION['trace-handler'] = 1; - } - else { - $_SESSION['trace-handler']++; - } - $request->getSession()->save(); + public function traceHandler(Request $request): Response { + // Increment trace-handler counter and save the session. + $session = $request->getSession(); + $counter = $session->get('trace-handler', 0); + $session->set('trace-handler', $counter + 1); + $session->save(); // Collect traces and return them in JSON format. $trace = \Drupal::service('session_test.session_handler_proxy_trace')->getArrayCopy(); @@ -182,15 +156,13 @@ class SessionTestController extends ControllerBase { * @param \Symfony\Component\HttpFoundation\Request $request * The incoming request. * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * The response. - * * @throws \AssertionError */ - public function traceHandlerRewriteUnmodified(Request $request) { + public function traceHandlerRewriteUnmodified(Request $request): Response { // Assert that there is an existing session with stacked handler trace data. + $session = $request->getSession(); assert( - is_int($_SESSION['trace-handler']) && $_SESSION['trace-handler'] > 0, + is_int($session->get('trace-handler')) && $session->get('trace-handler') > 0, 'Existing stacked session handler trace not found' ); @@ -199,7 +171,7 @@ class SessionTestController extends ControllerBase { ini_get('session.lazy_write'), 'session.lazy_write must be enabled to invoke updateTimestamp()' ); - $request->getSession()->save(); + $session->save(); // Collect traces and return them in JSON format. $trace = \Drupal::service('session_test.session_handler_proxy_trace')->getArrayCopy(); @@ -212,11 +184,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * A response object containing the session values and the user ID. */ - public function getSession(Request $request) { + public function getSession(Request $request): Response { return new JsonResponse(['session' => $request->getSession()->all(), 'user' => $this->currentUser()->id()]); } @@ -227,11 +196,8 @@ class SessionTestController extends ControllerBase { * The request object. * @param string $test_value * A value to set on the session. - * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * A response object containing the session values and the user ID. */ - public function setSession(Request $request, $test_value) { + public function setSession(Request $request, $test_value): Response { $session = $request->getSession(); $session->set('test_value', $test_value); return new JsonResponse(['session' => $session->all(), 'user' => $this->currentUser()->id()]); @@ -242,11 +208,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return \Symfony\Component\HttpFoundation\Response - * The response object. */ - public function setSessionBagFlag(Request $request) { + public function setSessionBagFlag(Request $request): Response { /** @var \Drupal\session_test\Session\TestSessionBag */ $bag = $request->getSession()->getBag(TestSessionBag::BAG_NAME); $bag->setFlag(); @@ -258,11 +221,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return \Symfony\Component\HttpFoundation\Response - * The response object. */ - public function clearSessionBagFlag(Request $request) { + public function clearSessionBagFlag(Request $request): Response { /** @var \Drupal\session_test\Session\TestSessionBag */ $bag = $request->getSession()->getBag(TestSessionBag::BAG_NAME); $bag->clearFlag(); @@ -274,11 +234,8 @@ class SessionTestController extends ControllerBase { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * - * @return \Symfony\Component\HttpFoundation\Response - * The response object. */ - public function hasSessionBagFlag(Request $request) { + public function hasSessionBagFlag(Request $request): Response { /** @var \Drupal\session_test\Session\TestSessionBag */ $bag = $request->getSession()->getBag(TestSessionBag::BAG_NAME); return new Response(empty($bag->hasFlag()) @@ -293,7 +250,7 @@ class SessionTestController extends ControllerBase { * @param \Symfony\Component\HttpFoundation\Request $request * The request object. */ - public function triggerWriteException(Request $request) { + public function triggerWriteException(Request $request): Response { $session = $request->getSession(); $session->set('test_value', 'Ensure session contains some data'); diff --git a/core/modules/system/tests/modules/test_htmx/css/style.css b/core/modules/system/tests/modules/test_htmx/css/style.css new file mode 100644 index 00000000000..75b757dbe3c --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/css/style.css @@ -0,0 +1,3 @@ +.ajax-content { + background-color: red; +} diff --git a/core/modules/system/tests/modules/test_htmx/js/behavior.js b/core/modules/system/tests/modules/test_htmx/js/behavior.js new file mode 100644 index 00000000000..5ca13501cee --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/js/behavior.js @@ -0,0 +1,14 @@ +((Drupal, once) => { + Drupal.behaviors.htmx_test = { + attach(context, settings) { + once('htmx-init', '.ajax-content', context).forEach((el) => { + el.innerText = 'initialized'; + }); + }, + detach(context, settings, trigger) { + once.remove('htmx-init', '.ajax-content', context).forEach((el) => { + el.remove(); + }); + }, + }; +})(Drupal, once); diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php new file mode 100644 index 00000000000..9045a4c8b9c --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\test_htmx\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Url; + +/** + * Returns responses for HTMX Test Attachments routes. + */ +final class HtmxTestAttachmentsController extends ControllerBase { + + /** + * Builds the response. + * + * @return mixed[] + * A render array. + */ + public function page(): array { + return self::generateHtmxButton(); + } + + /** + * Builds the HTMX response. + * + * @return mixed[] + * A render array. + */ + public function replace(): array { + $build['content'] = [ + '#type' => 'container', + '#attached' => [ + 'library' => ['test_htmx/assets'], + ], + '#attributes' => [ + 'class' => ['ajax-content'], + ], + 'example' => ['#markup' => 'Initial Content'], + ]; + + return $build; + } + + /** + * Static helper to for reusable render array. + * + * @return array + * The render array. + */ + public static function generateHtmxButton(): array { + $url = Url::fromRoute('test_htmx.attachments.replace'); + $build['replace'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#attributes' => [ + 'type' => 'button', + 'name' => 'replace', + 'data-hx-get' => $url->toString(), + 'data-hx-select' => 'div.ajax-content', + 'data-hx-target' => '[data-drupal-htmx-target]', + ], + '#value' => 'Click this', + '#attached' => [ + 'library' => [ + 'core/drupal.htmx', + ], + ], + ]; + + $build['content'] = [ + '#type' => 'container', + '#attributes' => [ + 'data-drupal-htmx-target' => TRUE, + 'class' => ['htmx-test-container'], + ], + ]; + + return $build; + } + +} diff --git a/core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestAjaxForm.php b/core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestAjaxForm.php new file mode 100644 index 00000000000..8fffbbc5f40 --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestAjaxForm.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\test_htmx\Form; + +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\test_htmx\Controller\HtmxTestAttachmentsController; + +/** + * A small form used to insert an HTMX powered element using ajax API. + */ +class HtmxTestAjaxForm extends FormBase { + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'htmx_test_ajax_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $build = [ + 'ajax-button' => [ + '#type' => 'button', + '#value' => 'Trigger Ajax', + '#submit_button' => FALSE, + '#ajax' => [ + 'callback' => [ + HtmxTestAttachmentsController::class, + 'generateHtmxButton', + ], + 'wrapper' => 'ajax-test-container', + ], + ], + '#suffix' => '<div id="ajax-test-container"></div>', + ]; + + return $build; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void {} + +} diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml new file mode 100644 index 00000000000..c713e0624d9 --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml @@ -0,0 +1,4 @@ +name: 'HTMX Test Fixtures' +type: module +description: 'Test fixtures for HTMX integration' +package: Testing diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml new file mode 100644 index 00000000000..31ac1d2b8ab --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml @@ -0,0 +1,10 @@ +assets: + version: VERSION + js: + js/behavior.js: {} + css: + theme: + css/style.css: {} + dependencies: + - core/drupal + - core/once diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml new file mode 100644 index 00000000000..406c3027f3b --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml @@ -0,0 +1,23 @@ +test_htmx.attachments.page: + path: '/htmx-test-attachments/page' + defaults: + _title: 'Page' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::page' + requirements: + _permission: 'access content' + +test_htmx.attachments.replace: + path: '/htmx-test-attachments/replace' + defaults: + _title: 'Ajax Content' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::replace' + requirements: + _permission: 'access content' + +test_htmx.attachments.ajax: + path: '/htmx-test-attachments/ajax' + defaults: + _title: 'Ajax' + _form: '\Drupal\test_htmx\Form\HtmxTestAjaxForm' + requirements: + _permission: 'access content' diff --git a/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php b/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php index fc48756de51..7bfc10ef0ef 100644 --- a/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php +++ b/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\theme_test\Hook; -use Drupal\Core\Hook\Attribute\Preprocess; +use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for theme_test. @@ -14,7 +14,7 @@ class ThemeTestThemeHooks { /** * Implements hook_preprocess_HOOK(). */ - #[Preprocess('theme_test_preprocess_suggestions__monkey')] + #[Hook('preprocess_theme_test_preprocess_suggestions__monkey')] public function preprocessTestSuggestions(&$variables): void { $variables['foo'] = 'Monkey'; } diff --git a/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php b/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php index 272ad65eff3..f5d0c150118 100644 --- a/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php +++ b/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php @@ -24,7 +24,7 @@ class TestLoader implements LoaderInterface { /** * {@inheritdoc} */ - public function exists(string $name) { + public function exists(string $name): bool { return TRUE; } diff --git a/core/modules/system/tests/modules/update_script_test/src/Hook/UpdateScriptTestRequirements.php b/core/modules/system/tests/modules/update_script_test/src/Hook/UpdateScriptTestRequirements.php index 5927e31e460..e93fe8bb80f 100644 --- a/core/modules/system/tests/modules/update_script_test/src/Hook/UpdateScriptTestRequirements.php +++ b/core/modules/system/tests/modules/update_script_test/src/Hook/UpdateScriptTestRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\update_script_test\Hook; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; /** @@ -25,21 +26,21 @@ class UpdateScriptTestRequirements { // Set a requirements warning or error when the test requests it. $requirement_type = $this->configFactory->get('update_script_test.settings')->get('requirement_type'); switch ($requirement_type) { - case REQUIREMENT_WARNING: + case RequirementSeverity::Warning->value: $requirements['update_script_test'] = [ 'title' => 'Update script test', 'value' => 'Warning', 'description' => 'This is a requirements warning provided by the update_script_test module.', - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; break; - case REQUIREMENT_ERROR: + case RequirementSeverity::Error->value: $requirements['update_script_test'] = [ 'title' => 'Update script test', 'value' => 'Error', 'description' => 'This is a (buggy description fixed in update_script_test_requirements_alter()) requirements error provided by the update_script_test module.', - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; break; } @@ -51,7 +52,7 @@ class UpdateScriptTestRequirements { */ #[Hook('update_requirements_alter')] public function updateAlter(array &$requirements): void { - if (isset($requirements['update_script_test']) && $requirements['update_script_test']['severity'] === REQUIREMENT_ERROR) { + if (isset($requirements['update_script_test']) && $requirements['update_script_test']['severity'] === RequirementSeverity::Error) { $requirements['update_script_test']['description'] = 'This is a requirements error provided by the update_script_test module.'; } } diff --git a/core/modules/system/tests/modules/update_test_schema/src/Hook/UpdateTestSchemaRequirements.php b/core/modules/system/tests/modules/update_test_schema/src/Hook/UpdateTestSchemaRequirements.php index de96ce3e36a..3199527bd05 100644 --- a/core/modules/system/tests/modules/update_test_schema/src/Hook/UpdateTestSchemaRequirements.php +++ b/core/modules/system/tests/modules/update_test_schema/src/Hook/UpdateTestSchemaRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\update_test_schema\Hook; use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Url; @@ -22,7 +23,7 @@ class UpdateTestSchemaRequirements { $requirements['path_alias_test'] = [ 'title' => 'Path alias test', 'value' => 'Check a path alias for the admin page', - 'severity' => REQUIREMENT_INFO, + 'severity' => RequirementSeverity::Info, 'description' => new FormattableMarkup('Visit <a href=":link">the structure page</a> to do many useful things.', [ ':link' => Url::fromRoute('system.admin_structure')->toString(), ]), diff --git a/core/modules/system/tests/src/Functional/Form/FormTest.php b/core/modules/system/tests/src/Functional/Form/FormTest.php index 6812903dccc..f45e45e6159 100644 --- a/core/modules/system/tests/src/Functional/Form/FormTest.php +++ b/core/modules/system/tests/src/Functional/Form/FormTest.php @@ -6,7 +6,6 @@ namespace Drupal\Tests\system\Functional\Form; use Drupal\Component\Serialization\Json; use Drupal\Component\Utility\Html; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Form\FormState; use Drupal\Core\Render\Element; use Drupal\Core\Url; @@ -199,7 +198,7 @@ class FormTest extends BrowserTestBase { $expected_key = array_search($error->getText(), $expected); // If the error message is not one of the expected messages, fail. if ($expected_key === FALSE) { - $this->fail(new FormattableMarkup("Unexpected error message: @error", ['@error' => $error[0]])); + $this->fail("Unexpected error message: " . $error[0]); } // Remove the expected message from the list once it is found. else { @@ -209,7 +208,7 @@ class FormTest extends BrowserTestBase { // Fail if any expected messages were not found. foreach ($expected as $not_found) { - $this->fail(new FormattableMarkup("Found error message: @error", ['@error' => $not_found])); + $this->fail("Found error message: " . $not_found); } // Verify that input elements are still empty. @@ -610,14 +609,6 @@ class FormTest extends BrowserTestBase { public function testNumber(): void { $form = \Drupal::formBuilder()->getForm('\Drupal\form_test\Form\FormTestNumberForm'); - // Array with all the error messages to be checked. - $error_messages = [ - 'no_number' => '%name must be a number.', - 'too_low' => '%name must be higher than or equal to %min.', - 'too_high' => '%name must be lower than or equal to %max.', - 'step_mismatch' => '%name is not a valid number.', - ]; - // The expected errors. $expected = [ 'integer_no_number' => 'no_number', @@ -648,21 +639,26 @@ class FormTest extends BrowserTestBase { $this->submitForm([], 'Submit'); foreach ($expected as $element => $error) { - // Create placeholder array. - $placeholders = [ - '%name' => $form[$element]['#title'], - '%min' => $form[$element]['#min'] ?? '0', - '%max' => $form[$element]['#max'] ?? '0', + // Array with all the error messages to be checked. + $name = $form[$element]['#title']; + $min = $form[$element]['#min'] ?? '0'; + $max = $form[$element]['#max'] ?? '0'; + + $error_messages = [ + 'no_number' => "<em class=\"placeholder\">$name</em> must be a number.", + 'too_low' => "<em class=\"placeholder\">$name</em> must be higher than or equal to <em class=\"placeholder\">$min</em>.", + 'too_high' => "<em class=\"placeholder\">$name</em> must be lower than or equal to <em class=\"placeholder\">$max</em>.", + 'step_mismatch' => "<em class=\"placeholder\">$name</em> is not a valid number.", ]; foreach ($error_messages as $id => $message) { // Check if the error exists on the page, if the current message ID is // expected. Otherwise ensure that the error message is not present. if ($id === $error) { - $this->assertSession()->responseContains(new FormattableMarkup($message, $placeholders)); + $this->assertSession()->responseContains($message); } else { - $this->assertSession()->responseNotContains(new FormattableMarkup($message, $placeholders)); + $this->assertSession()->responseNotContains($message); } } } diff --git a/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php b/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php index 388e83f6fcc..b297647194a 100644 --- a/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php +++ b/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Functional\SecurityAdvisories; use Drupal\advisory_feed_test\AdvisoryTestClientMiddleware; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Url; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\Traits\Core\CronRunTrait; @@ -140,10 +141,10 @@ class SecurityAdvisoryTest extends BrowserTestBase { // If both PSA and non-PSA advisories are displayed they should be displayed // as errors. - $this->assertStatusReportLinks($mixed_advisory_links, REQUIREMENT_ERROR); + $this->assertStatusReportLinks($mixed_advisory_links, RequirementSeverity::Error); // The advisories will be displayed on admin pages if the response was // stored from the status report request. - $this->assertAdminPageLinks($mixed_advisory_links, REQUIREMENT_ERROR); + $this->assertAdminPageLinks($mixed_advisory_links, RequirementSeverity::Error); // Confirm that a user without the correct permission will not see the // advisories on admin pages. @@ -159,8 +160,8 @@ class SecurityAdvisoryTest extends BrowserTestBase { $this->drupalLogin($this->user); // Test cache. AdvisoryTestClientMiddleware::setTestEndpoint($this->nonWorkingEndpoint); - $this->assertAdminPageLinks($mixed_advisory_links, REQUIREMENT_ERROR); - $this->assertStatusReportLinks($mixed_advisory_links, REQUIREMENT_ERROR); + $this->assertAdminPageLinks($mixed_advisory_links, RequirementSeverity::Error); + $this->assertStatusReportLinks($mixed_advisory_links, RequirementSeverity::Error); // Tests transmit errors with a JSON endpoint. $this->tempStore->delete('advisories_response'); @@ -195,8 +196,8 @@ class SecurityAdvisoryTest extends BrowserTestBase { $this->assertAdvisoriesNotDisplayed($psa_advisory_links, ['system.admin']); // If only PSA advisories are displayed they should be displayed as // warnings. - $this->assertStatusReportLinks($psa_advisory_links, REQUIREMENT_WARNING); - $this->assertAdminPageLinks($psa_advisory_links, REQUIREMENT_WARNING); + $this->assertStatusReportLinks($psa_advisory_links, RequirementSeverity::Warning); + $this->assertAdminPageLinks($psa_advisory_links, RequirementSeverity::Warning); AdvisoryTestClientMiddleware::setTestEndpoint($this->workingEndpointNonPsaOnly, TRUE); $non_psa_advisory_links = [ @@ -205,8 +206,8 @@ class SecurityAdvisoryTest extends BrowserTestBase { ]; // If only non-PSA advisories are displayed they should be displayed as // errors. - $this->assertStatusReportLinks($non_psa_advisory_links, REQUIREMENT_ERROR); - $this->assertAdminPageLinks($non_psa_advisory_links, REQUIREMENT_ERROR); + $this->assertStatusReportLinks($non_psa_advisory_links, RequirementSeverity::Error); + $this->assertAdminPageLinks($non_psa_advisory_links, RequirementSeverity::Error); // Confirm that advisory fetching can be disabled after enabled. $this->config('system.advisories')->set('enabled', FALSE)->save(); @@ -220,16 +221,15 @@ class SecurityAdvisoryTest extends BrowserTestBase { * * @param string[] $expected_link_texts * The expected links' text. - * @param int $error_or_warning - * Whether the links are a warning or an error. Should be one of the - * REQUIREMENT_* constants. + * @param \Drupal\Core\Extension\Requirement\RequirementSeverity $error_or_warning + * Whether the links are a warning or an error. * * @internal */ - private function assertAdminPageLinks(array $expected_link_texts, int $error_or_warning): void { + private function assertAdminPageLinks(array $expected_link_texts, RequirementSeverity $error_or_warning): void { $assert = $this->assertSession(); $this->drupalGet(Url::fromRoute('system.admin')); - if ($error_or_warning === REQUIREMENT_ERROR) { + if ($error_or_warning === RequirementSeverity::Error) { $assert->pageTextContainsOnce('Error message'); $assert->pageTextNotContains('Warning message'); } @@ -247,16 +247,15 @@ class SecurityAdvisoryTest extends BrowserTestBase { * * @param string[] $expected_link_texts * The expected links' text. - * @param int $error_or_warning - * Whether the links are a warning or an error. Should be one of the - * REQUIREMENT_* constants. + * @param \Drupal\Core\Extension\Requirement\RequirementSeverity::Error|\Drupal\Core\Extension\Requirement\RequirementSeverity::Warning $error_or_warning + * Whether the links are a warning or an error. * * @internal */ - private function assertStatusReportLinks(array $expected_link_texts, int $error_or_warning): void { + private function assertStatusReportLinks(array $expected_link_texts, RequirementSeverity $error_or_warning): void { $this->drupalGet(Url::fromRoute('system.status')); $assert = $this->assertSession(); - $selector = 'h3#' . ($error_or_warning === REQUIREMENT_ERROR ? 'error' : 'warning') + $selector = 'h3#' . $error_or_warning->status() . ' ~ details.system-status-report__entry:contains("Critical security announcements")'; $assert->elementExists('css', $selector); foreach ($expected_link_texts as $expected_link_text) { diff --git a/core/modules/system/tests/src/Functional/Session/LegacySessionTest.php b/core/modules/system/tests/src/Functional/Session/LegacySessionTest.php new file mode 100644 index 00000000000..84ab1ed9d5b --- /dev/null +++ b/core/modules/system/tests/src/Functional/Session/LegacySessionTest.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Functional\Session; + +use Drupal\Tests\BrowserTestBase; + +/** + * Drupal legacy session handling tests. + * + * @group legacy + * @group Session + */ +class LegacySessionTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['session_test']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests data persistence via the session_test module callbacks. + */ + public function testLegacyDataPersistence(): void { + $this->expectDeprecation('Storing values directly in $_SESSION is deprecated in drupal:11.2.0 and will become unsupported in drupal:12.0.0. Use $request->getSession()->set() instead. Affected keys: legacy_test_value. See https://www.drupal.org/node/3518527'); + $value = $this->randomMachineName(); + + // Verify that the session value is stored. + $this->drupalGet('session-test/legacy-set/' . $value); + $this->assertSession()->pageTextContains($value); + + // Verify that the session correctly returned the stored data for an + // authenticated user. + $this->drupalGet('session-test/legacy-get'); + $this->assertSession()->pageTextContains($value); + } + +} diff --git a/core/modules/system/tests/src/Functional/System/SitesDirectoryHardeningTest.php b/core/modules/system/tests/src/Functional/System/SitesDirectoryHardeningTest.php index 41d60b8a42a..6e47278edad 100644 --- a/core/modules/system/tests/src/Functional/System/SitesDirectoryHardeningTest.php +++ b/core/modules/system/tests/src/Functional/System/SitesDirectoryHardeningTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Functional\System; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Site\Settings; use Drupal\Tests\BrowserTestBase; @@ -58,7 +59,7 @@ class SitesDirectoryHardeningTest extends BrowserTestBase { // Manually trigger the requirements check. $requirements = $this->checkSystemRequirements(); - $this->assertEquals(REQUIREMENT_WARNING, $requirements['configuration_files']['severity'], 'Warning severity is properly set.'); + $this->assertEquals(RequirementSeverity::Warning, $requirements['configuration_files']['severity'], 'Warning severity is properly set.'); $this->assertEquals('Protection disabled', (string) $requirements['configuration_files']['value']); $description = strip_tags((string) \Drupal::service('renderer')->renderInIsolation($requirements['configuration_files']['description'])); $this->assertStringContainsString('settings.php is not protected from modifications and poses a security risk.', $description); diff --git a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php index f0f78b23c99..5be7e48289f 100644 --- a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php +++ b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Functional\UpdateSystem; use Drupal\Component\Serialization\Yaml; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Url; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\BrowserTestBase; @@ -149,7 +150,7 @@ class UpdateScriptTest extends BrowserTestBase { // First, run this test with pending updates to make sure they can be run // successfully. $this->drupalLogin($this->updateUser); - $update_script_test_config->set('requirement_type', REQUIREMENT_WARNING)->save(); + $update_script_test_config->set('requirement_type', RequirementSeverity::Warning->value)->save(); /** @var \Drupal\Core\Update\UpdateHookRegistry $update_registry */ $update_registry = \Drupal::service('update.update_hook_registry'); $update_registry->setInstalledVersion('update_script_test', $update_registry->getInstalledVersion('update_script_test') - 1); @@ -177,7 +178,7 @@ class UpdateScriptTest extends BrowserTestBase { // If there is a requirements error, it should be displayed even after // clicking the link to proceed (since the problem that triggered the error // has not been fixed). - $update_script_test_config->set('requirement_type', REQUIREMENT_ERROR)->save(); + $update_script_test_config->set('requirement_type', RequirementSeverity::Error->value)->save(); $this->drupalGet($this->updateUrl, ['external' => TRUE]); $this->assertSession()->pageTextContains('This is a requirements error provided by the update_script_test module.'); $this->clickLink('try again'); @@ -185,7 +186,7 @@ class UpdateScriptTest extends BrowserTestBase { // Ensure that changes to a module's requirements that would cause errors // are displayed correctly. - $update_script_test_config->set('requirement_type', REQUIREMENT_OK)->save(); + $update_script_test_config->set('requirement_type', RequirementSeverity::OK->value)->save(); \Drupal::state()->set('update_script_test.system_info_alter', ['dependencies' => ['a_module_that_does_not_exist']]); $this->drupalGet($this->updateUrl, ['external' => TRUE]); $this->assertSession()->responseContains('a_module_that_does_not_exist (Missing)'); diff --git a/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php b/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php index 6c8c42da59e..82d866e985e 100644 --- a/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php +++ b/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php @@ -77,6 +77,8 @@ class DateFormatAccessControlHandlerTest extends KernelTestBase { * An array of test cases. */ public static function providerTestAccess(): array { + $originalContainer = \Drupal::hasContainer() ? \Drupal::getContainer() : NULL; + $c = new ContainerBuilder(); $cache_contexts_manager = (new Prophet())->prophesize(CacheContextsManager::class); $cache_contexts_manager->assertValidTokens()->willReturn(TRUE); @@ -84,7 +86,7 @@ class DateFormatAccessControlHandlerTest extends KernelTestBase { $c->set('cache_contexts_manager', $cache_contexts_manager); \Drupal::setContainer($c); - return [ + $data = [ 'No permission + unlocked' => [ [], 'unlocked', @@ -122,6 +124,13 @@ class DateFormatAccessControlHandlerTest extends KernelTestBase { AccessResult::allowed()->addCacheContexts(['user.permissions']), ], ]; + + // Restore the original container if needed. + if ($originalContainer) { + \Drupal::setContainer($originalContainer); + } + + return $data; } } diff --git a/core/modules/system/tests/src/Kernel/Element/StatusReportPageTest.php b/core/modules/system/tests/src/Kernel/Element/StatusReportPageTest.php new file mode 100644 index 00000000000..630a3a997dd --- /dev/null +++ b/core/modules/system/tests/src/Kernel/Element/StatusReportPageTest.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Kernel\Element; + +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\KernelTests\KernelTestBase; +use Drupal\system\Element\StatusReportPage; + +include_once \DRUPAL_ROOT . '/core/includes/install.inc'; + +/** + * Tests the status report page element. + * + * @group system + * @group legacy + */ +class StatusReportPageTest extends KernelTestBase { + + /** + * Tests the status report page element. + */ + public function testPeRenderCounters(): void { + $element = [ + '#requirements' => [ + 'foo' => [ + 'title' => 'Foo', + 'severity' => \REQUIREMENT_INFO, + ], + 'baz' => [ + 'title' => 'Baz', + 'severity' => RequirementSeverity::Warning, + ], + 'wiz' => [ + 'title' => 'Wiz', + 'severity' => RequirementSeverity::Error, + ], + ], + ]; + $this->expectDeprecation('Calling Drupal\system\Element\StatusReportPage::preRenderCounters() with an array of $requirements with \'severity\' with values not of type Drupal\Core\Extension\Requirement\RequirementSeverity enums is deprecated in drupal:11.2.0 and is required in drupal:12.0.0. See https://www.drupal.org/node/3410939'); + $element = StatusReportPage::preRenderCounters($element); + + $error = $element['#counters']['error']; + $this->assertEquals(1, $error['#amount']); + $this->assertEquals('error', $error['#severity']); + + $warning = $element['#counters']['warning']; + $this->assertEquals(1, $warning['#amount']); + $this->assertEquals('warning', $warning['#severity']); + + $checked = $element['#counters']['checked']; + $this->assertEquals(1, $checked['#amount']); + $this->assertEquals('checked', $checked['#severity']); + + } + +} diff --git a/core/modules/system/tests/src/Kernel/Module/RequirementsTest.php b/core/modules/system/tests/src/Kernel/Module/RequirementsTest.php index 2258b08bc49..c22529a72db 100644 --- a/core/modules/system/tests/src/Kernel/Module/RequirementsTest.php +++ b/core/modules/system/tests/src/Kernel/Module/RequirementsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Kernel\Module; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\KernelTests\KernelTestBase; /** @@ -28,7 +29,7 @@ class RequirementsTest extends KernelTestBase { $requirements = $this->container->get('system.manager')->listRequirements(); // @see requirements1_test_requirements_alter() $this->assertEquals('Requirements 1 Test - Changed', $requirements['requirements1_test_alterable']['title']); - $this->assertEquals(REQUIREMENT_WARNING, $requirements['requirements1_test_alterable']['severity']); + $this->assertEquals(RequirementSeverity::Warning, $requirements['requirements1_test_alterable']['severity']); $this->assertArrayNotHasKey('requirements1_test_deletable', $requirements); } diff --git a/core/modules/system/tests/src/Kernel/System/RunTimeRequirementsTest.php b/core/modules/system/tests/src/Kernel/System/RunTimeRequirementsTest.php index af027b48051..e39e509cb14 100644 --- a/core/modules/system/tests/src/Kernel/System/RunTimeRequirementsTest.php +++ b/core/modules/system/tests/src/Kernel/System/RunTimeRequirementsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Kernel\System; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\KernelTests\KernelTestBase; @@ -31,7 +32,7 @@ class RunTimeRequirementsTest extends KernelTestBase { 'title' => 'RuntimeError', 'value' => 'None', 'description' => 'Runtime Error.', - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, ]; $requirements = \Drupal::service('system.manager')->listRequirements()['test.runtime.error']; $this->assertEquals($testRequirements, $requirements); @@ -40,7 +41,7 @@ class RunTimeRequirementsTest extends KernelTestBase { 'title' => 'RuntimeWarning', 'value' => 'None', 'description' => 'Runtime Warning.', - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; $requirementsAlter = \Drupal::service('system.manager')->listRequirements()['test.runtime.error.alter']; $this->assertEquals($testRequirementsAlter, $requirementsAlter); diff --git a/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php b/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php index 7747682a42e..b751f6b52ba 100644 --- a/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php +++ b/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\taxonomy\Functional; -use Drupal\Component\Render\FormattableMarkup; +use Drupal\Component\Utility\Html; use Drupal\Core\Url; use Drupal\taxonomy\Entity\Vocabulary; @@ -101,9 +101,10 @@ class VocabularyUiTest extends TaxonomyTestBase { $link->click(); // Confirm deletion. - $this->assertSession()->responseContains(new FormattableMarkup('Are you sure you want to delete the vocabulary %name?', ['%name' => $edit['name']])); + $name = Html::escape($edit['name']); + $this->assertSession()->responseContains("Are you sure you want to delete the vocabulary <em class=\"placeholder\">$name</em>?"); $this->submitForm([], 'Delete'); - $this->assertSession()->responseContains(new FormattableMarkup('Deleted vocabulary %name.', ['%name' => $edit['name']])); + $this->assertSession()->responseContains("Deleted vocabulary <em class=\"placeholder\">$name</em>."); $this->container->get('entity_type.manager')->getStorage('taxonomy_vocabulary')->resetCache(); $this->assertNull(Vocabulary::load($edit['vid']), 'Vocabulary not found.'); } diff --git a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTest.php index 1d9654dd505..511778daf20 100644 --- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTest.php +++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTest.php @@ -10,6 +10,7 @@ use Drupal\node\Entity\Node; /** * Upgrade taxonomy term node associations. * + * @group #slow * @group migrate_drupal_6 */ class MigrateTermNodeTest extends MigrateDrupal6TestBase { diff --git a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php index 8d9465f61a3..7fcb764eac3 100644 --- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php +++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php @@ -11,6 +11,7 @@ use Drupal\node\Entity\Node; * Upgrade taxonomy term node associations. * * @group migrate_drupal_6 + * @group #slow */ class MigrateTermNodeTranslationTest extends MigrateDrupal6TestBase { diff --git a/core/modules/toolbar/js/escapeAdmin.js b/core/modules/toolbar/js/escapeAdmin.js index 2d76991e9dc..f7956befe23 100644 --- a/core/modules/toolbar/js/escapeAdmin.js +++ b/core/modules/toolbar/js/escapeAdmin.js @@ -14,7 +14,7 @@ // loaded within an existing "workflow". if ( !pathInfo.currentPathIsAdmin && - !/destination=/.test(windowLocation.search) + !windowLocation.search.includes('destination=') ) { sessionStorage.setItem('escapeAdminPath', windowLocation); } diff --git a/core/modules/toolbar/js/views/ToolbarVisualView.js b/core/modules/toolbar/js/views/ToolbarVisualView.js index 89f472f0eaf..00bd236973f 100644 --- a/core/modules/toolbar/js/views/ToolbarVisualView.js +++ b/core/modules/toolbar/js/views/ToolbarVisualView.js @@ -210,7 +210,7 @@ // Deactivate the previous tab. $(this.model.previous('activeTab')) .removeClass('is-active') - .prop('aria-pressed', false); + .attr('aria-pressed', false); // Deactivate the previous tray. $(this.model.previous('activeTray')).removeClass('is-active'); @@ -222,7 +222,7 @@ $tab .addClass('is-active') // Mark the tab as pressed. - .prop('aria-pressed', true); + .attr('aria-pressed', true); const name = $tab.attr('data-toolbar-tray'); // Store the active tab name or remove the setting. const id = $tab.get(0).id; diff --git a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php index c315f9f6ebb..dcf0ff6d79c 100644 --- a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php +++ b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\toolbar\FunctionalJavascript; +use Behat\Mink\Element\NodeElement; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; /** @@ -43,12 +44,22 @@ class ToolbarIntegrationTest extends WebDriverTestBase { $page = $this->getSession()->getPage(); // Test that it is possible to toggle the toolbar tray. - $content = $page->findLink('Content'); - $this->assertTrue($content->isVisible(), 'Toolbar tray is open by default.'); - $page->clickLink('Manage'); - $this->assertFalse($content->isVisible(), 'Toolbar tray is closed after clicking the "Manage" link.'); - $page->clickLink('Manage'); - $this->assertTrue($content->isVisible(), 'Toolbar tray is visible again after clicking the "Manage" button a second time.'); + $content_link = $page->findLink('Content'); + $manage_link = $page->find('css', '#toolbar-item-administration'); + + // Start with open tray. + $this->waitAndAssertAriaPressedState($manage_link, TRUE); + $this->assertTrue($content_link->isVisible(), 'Toolbar tray is open by default.'); + + // Click to close. + $manage_link->click(); + $this->waitAndAssertAriaPressedState($manage_link, FALSE); + $this->assertFalse($content_link->isVisible(), 'Toolbar tray is closed after clicking the "Manage" link.'); + + // Click to open. + $manage_link->click(); + $this->waitAndAssertAriaPressedState($manage_link, TRUE); + $this->assertTrue($content_link->isVisible(), 'Toolbar tray is visible again after clicking the "Manage" button a second time.'); // Test toggling the toolbar tray between horizontal and vertical. $tray = $page->findById('toolbar-item-administration-tray'); @@ -87,4 +98,33 @@ class ToolbarIntegrationTest extends WebDriverTestBase { $this->assertFalse($button->isVisible(), 'Orientation toggle from other tray is not visible'); } + /** + * Asserts that an element's `aria-pressed` attribute matches expected state. + * + * Uses `waitFor()` to pause until either the condition is met or the timeout + * of `1` second has passed. + * + * @param \Behat\Mink\Element\NodeElement $element + * The element to be tested. + * @param bool $expected + * The expected value of `aria-pressed`, as a boolean. + * + * @throws ExpectationFailedException + */ + private function waitAndAssertAriaPressedState(NodeElement $element, bool $expected): void { + $this->assertTrue( + $this + ->getSession() + ->getPage() + ->waitFor(1, function () use ($element, $expected): bool { + // Get boolean representation of `aria-pressed`. + // TRUE if `aria-pressed="true"`, FALSE otherwise. + $actual = $element->getAttribute('aria-pressed') == 'true'; + + // Exit `waitFor()` when $actual == $expected. + return $actual == $expected; + }) + ); + } + } diff --git a/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js b/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js index cbba417abe3..0bed815f330 100644 --- a/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js +++ b/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js @@ -13,27 +13,10 @@ const userOrientationBtn = `${itemUserTray} .toolbar-toggle-orientation button`; module.exports = { '@tags': ['core'], before(browser) { - browser - .drupalInstall() - .drupalInstallModule('toolbar', true) - .drupalCreateUser({ - name: 'user', - password: '123', - permissions: [ - 'access site reports', - 'access toolbar', - 'access administration pages', - 'administer menu', - 'administer modules', - 'administer site configuration', - 'administer account settings', - 'administer software updates', - 'access content', - 'administer permissions', - 'administer users', - ], - }) - .drupalLogin({ name: 'user', password: '123' }); + browser.drupalInstall({ + setupFile: + 'core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php', + }); }, beforeEach(browser) { // Set the resolution to the default desktop resolution. Ensure the default @@ -189,7 +172,7 @@ module.exports = { browser.drupalRelativeURL('/admin'); // Don't check the visibility as stark doesn't add the .path-admin class // to the <body> required to display the button. - browser.assert.attributeContains(escapeSelector, 'href', '/user/2'); + browser.assert.attributeContains(escapeSelector, 'href', '/user/login'); }, 'Aural view test: tray orientation': (browser) => { browser.waitForElementPresent( diff --git a/core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php b/core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php new file mode 100644 index 00000000000..47dd0e6e50a --- /dev/null +++ b/core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\toolbar\Nightwatch; + +use Drupal\Core\Extension\ModuleInstallerInterface; +use Drupal\TestSite\TestSetupInterface; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; + +/** + * Sets up the site for testing the toolbar module. + */ +class ToolbarTestSetup implements TestSetupInterface { + + /** + * {@inheritdoc} + */ + public function setup(): void { + $module_installer = \Drupal::service('module_installer'); + assert($module_installer instanceof ModuleInstallerInterface); + $module_installer->install(['toolbar']); + + $role = Role::load(RoleInterface::ANONYMOUS_ID); + foreach ([ + 'access toolbar', + 'access administration pages', + 'administer modules', + 'administer site configuration', + 'administer account settings', + ] as $permission) { + $role->grantPermission($permission); + } + $role->save(); + } + +} diff --git a/core/modules/update/src/Hook/UpdateHooks.php b/core/modules/update/src/Hook/UpdateHooks.php index 2502d4bb171..6c6c57b53e5 100644 --- a/core/modules/update/src/Hook/UpdateHooks.php +++ b/core/modules/update/src/Hook/UpdateHooks.php @@ -2,6 +2,7 @@ namespace Drupal\update\Hook; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\update\UpdateManagerInterface; use Drupal\Core\Url; @@ -96,10 +97,10 @@ class UpdateHooks { } if (!empty($verbose)) { if (isset($status[$type]['severity'])) { - if ($status[$type]['severity'] == REQUIREMENT_ERROR) { + if ($status[$type]['severity'] === RequirementSeverity::Error) { \Drupal::messenger()->addError($status[$type]['description']); } - elseif ($status[$type]['severity'] == REQUIREMENT_WARNING) { + elseif ($status[$type]['severity'] === RequirementSeverity::Warning) { \Drupal::messenger()->addWarning($status[$type]['description']); } } diff --git a/core/modules/update/src/Hook/UpdateRequirements.php b/core/modules/update/src/Hook/UpdateRequirements.php index 4aa5ccc1826..2f51f205b1a 100644 --- a/core/modules/update/src/Hook/UpdateRequirements.php +++ b/core/modules/update/src/Hook/UpdateRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\update\Hook; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Link; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -76,7 +77,7 @@ class UpdateRequirements { else { $requirements['update_core']['title'] = $this->t('Drupal core update status'); $requirements['update_core']['value'] = $this->t('No update data available'); - $requirements['update_core']['severity'] = REQUIREMENT_WARNING; + $requirements['update_core']['severity'] = RequirementSeverity::Warning; $requirements['update_core']['reason'] = UpdateFetcherInterface::UNKNOWN; $requirements['update_core']['description'] = _update_no_data(); } @@ -113,7 +114,7 @@ class UpdateRequirements { $status = $project['status']; if ($status != UpdateManagerInterface::CURRENT) { $requirement['reason'] = $status; - $requirement['severity'] = REQUIREMENT_ERROR; + $requirement['severity'] = RequirementSeverity::Error; // When updates are available, append the available updates link to the // message from _update_message_text(), and format the two translated // strings together in a single paragraph. @@ -137,7 +138,7 @@ class UpdateRequirements { case UpdateManagerInterface::NOT_CURRENT: $requirement_label = $this->t('Out of date'); - $requirement['severity'] = REQUIREMENT_WARNING; + $requirement['severity'] = RequirementSeverity::Warning; break; case UpdateFetcherInterface::UNKNOWN: @@ -145,7 +146,7 @@ class UpdateRequirements { case UpdateFetcherInterface::NOT_FETCHED: case UpdateFetcherInterface::FETCH_PENDING: $requirement_label = $project['reason'] ?? $this->t('Can not determine status'); - $requirement['severity'] = REQUIREMENT_WARNING; + $requirement['severity'] = RequirementSeverity::Warning; break; default: diff --git a/core/modules/update/src/ProjectSecurityRequirement.php b/core/modules/update/src/ProjectSecurityRequirement.php index cc6fed789fe..331c65537c8 100644 --- a/core/modules/update/src/ProjectSecurityRequirement.php +++ b/core/modules/update/src/ProjectSecurityRequirement.php @@ -2,6 +2,7 @@ namespace Drupal\update; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Url; @@ -141,11 +142,11 @@ final class ProjectSecurityRequirement { 'Covered until @end_version', ['@end_version' => $this->securityCoverageInfo['security_coverage_end_version']] ); - $requirement['severity'] = $this->securityCoverageInfo['additional_minors_coverage'] > 1 ? REQUIREMENT_INFO : REQUIREMENT_WARNING; + $requirement['severity'] = $this->securityCoverageInfo['additional_minors_coverage'] > 1 ? RequirementSeverity::Info : RequirementSeverity::Warning; } else { $requirement['value'] = $this->t('Coverage has ended'); - $requirement['severity'] = REQUIREMENT_ERROR; + $requirement['severity'] = RequirementSeverity::Error; } } return $requirement; @@ -224,7 +225,7 @@ final class ProjectSecurityRequirement { if ($this->securityCoverageInfo['security_coverage_end_date'] <= $comparable_request_date) { // Security coverage is over. $requirement['value'] = $this->t('Coverage has ended'); - $requirement['severity'] = REQUIREMENT_ERROR; + $requirement['severity'] = RequirementSeverity::Error; $requirement['description']['coverage_message'] = [ '#markup' => $this->getVersionNoSecurityCoverageMessage(), '#suffix' => ' ', @@ -237,7 +238,7 @@ final class ProjectSecurityRequirement { ->format($security_coverage_end_timestamp, 'custom', $output_date_format); $translation_arguments = ['@date' => $formatted_end_date]; $requirement['value'] = $this->t('Covered until @date', $translation_arguments); - $requirement['severity'] = REQUIREMENT_INFO; + $requirement['severity'] = RequirementSeverity::Info; // 'security_coverage_ending_warn_date' will always be in the format // 'Y-m-d'. $request_date = $date_formatter->format($time->getRequestTime(), 'custom', 'Y-m-d'); @@ -246,7 +247,7 @@ final class ProjectSecurityRequirement { '#markup' => $this->t('Update to a supported version soon to continue receiving security updates.'), '#suffix' => ' ', ]; - $requirement['severity'] = REQUIREMENT_WARNING; + $requirement['severity'] = RequirementSeverity::Warning; } } $requirement['description']['release_cycle_link'] = ['#markup' => $this->getReleaseCycleLink()]; diff --git a/core/modules/update/update.fetch.inc b/core/modules/update/update.fetch.inc index a0d2a22e562..c8e4990d385 100644 --- a/core/modules/update/update.fetch.inc +++ b/core/modules/update/update.fetch.inc @@ -5,6 +5,7 @@ */ use Drupal\Core\Hook\Attribute\ProceduralHookScanStop; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\update\UpdateManagerInterface; /** @@ -33,7 +34,7 @@ function _update_cron_notify(): void { foreach (['core', 'contrib'] as $report_type) { $type = 'update_' . $report_type; if (isset($status[$type]['severity']) - && ($status[$type]['severity'] == REQUIREMENT_ERROR || ($notify_all && $status[$type]['reason'] == UpdateManagerInterface::NOT_CURRENT))) { + && ($status[$type]['severity'] == RequirementSeverity::Error || ($notify_all && $status[$type]['reason'] == UpdateManagerInterface::NOT_CURRENT))) { $params[$report_type] = $status[$type]['reason']; } } diff --git a/core/modules/user/src/Hook/UserRequirements.php b/core/modules/user/src/Hook/UserRequirements.php index 186ce12285f..f317ced58bc 100644 --- a/core/modules/user/src/Hook/UserRequirements.php +++ b/core/modules/user/src/Hook/UserRequirements.php @@ -6,6 +6,7 @@ namespace Drupal\user\Hook; use Drupal\Core\Database\Connection; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -40,7 +41,7 @@ class UserRequirements { 'description' => $this->t('The anonymous user does not exist. See the <a href=":url">restore the anonymous (user ID 0) user record</a> for more information', [ ':url' => 'https://www.drupal.org/node/1029506', ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } @@ -57,7 +58,7 @@ class UserRequirements { 'description' => $this->t('Some user accounts have email addresses that differ only by case. For example, one account might have alice@example.com and another might have Alice@Example.com. See <a href=":url">Conflicting User Emails</a> for more information.', [ ':url' => 'https://www.drupal.org/node/3486109', ]), - 'severity' => REQUIREMENT_WARNING, + 'severity' => RequirementSeverity::Warning, ]; } diff --git a/core/modules/views/js/ajax_view.js b/core/modules/views/js/ajax_view.js index dd3da9b8350..8e646697d83 100644 --- a/core/modules/views/js/ajax_view.js +++ b/core/modules/views/js/ajax_view.js @@ -83,7 +83,7 @@ if (queryString !== '') { // If there is a '?' in ajaxPath, clean URL are on and & should be // used to add parameters. - queryString = (/\?/.test(ajaxPath) ? '&' : '?') + queryString; + queryString = (ajaxPath.includes('?') ? '&' : '?') + queryString; } } diff --git a/core/modules/views/src/Entity/View.php b/core/modules/views/src/Entity/View.php index cd1b2a0a42e..f6bb32cec87 100644 --- a/core/modules/views/src/Entity/View.php +++ b/core/modules/views/src/Entity/View.php @@ -481,7 +481,7 @@ class View extends ConfigEntityBase implements ViewEntityInterface { * {@inheritdoc} */ public function onDependencyRemoval(array $dependencies) { - $changed = FALSE; + $changed = parent::onDependencyRemoval($dependencies); // Don't intervene if the views module is removed. if (isset($dependencies['module']) && in_array('views', $dependencies['module'])) { diff --git a/core/modules/views/src/Form/ViewsExposedForm.php b/core/modules/views/src/Form/ViewsExposedForm.php index d68b1dd5363..9f90160ff55 100644 --- a/core/modules/views/src/Form/ViewsExposedForm.php +++ b/core/modules/views/src/Form/ViewsExposedForm.php @@ -196,7 +196,6 @@ class ViewsExposedForm extends FormBase implements WorkspaceSafeFormInterface { $view->exposed_data = $values; $view->exposed_raw_input = []; - $exclude = ['submit', 'form_build_id', 'form_id', 'form_token', 'exposed_form_plugin', 'reset']; /** @var \Drupal\views\Plugin\views\exposed_form\ExposedFormPluginBase $exposed_form_plugin */ $exposed_form_plugin = $view->display_handler->getPlugin('exposed_form'); $exposed_form_plugin->exposedFormSubmit($form, $form_state, $exclude); diff --git a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php index fc4a983f929..d3adc61de5a 100644 --- a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php +++ b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php @@ -2117,13 +2117,18 @@ abstract class DisplayPluginBase extends PluginBase implements DisplayPluginInte $hasMoreRecords = !empty($this->view->pager) && $this->view->pager->hasMoreRecords(); if ($this->isMoreEnabled() && ($this->useMoreAlways() || $hasMoreRecords)) { $url = $this->getMoreUrl(); + $access = $url->access(return_as_object: TRUE); - return [ + $more_link = [ '#type' => 'more_link', '#url' => $url, '#title' => $this->useMoreText(), '#view' => $this->view, + '#access' => $access->isAllowed(), ]; + $accessCacheability = CacheableMetadata::createFromObject($access); + $accessCacheability->applyTo($more_link); + return $more_link; } } diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml new file mode 100644 index 00000000000..eb59548f17f --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml @@ -0,0 +1,37 @@ +langcode: en +status: true +dependencies: + module: + - node + - views_third_party_settings_test +third_party_settings: + views_third_party_settings_test: + example_setting: true +id: test_third_party_uninstall +label: test_third_party_uninstall +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +display: + default: + display_options: + access: + type: none + cache: + type: tag + exposed_form: + type: basic + pager: + type: full + query: + type: views_query + style: + type: default + row: + type: fields + display_plugin: default + display_title: Defaults + id: default + position: 0 diff --git a/core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml b/core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml new file mode 100644 index 00000000000..0bdeeed705a --- /dev/null +++ b/core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml @@ -0,0 +1,7 @@ +views.view.*.third_party.views_third_party_settings_test: + type: config_entity + label: "Example settings" + mapping: + example_setting: + type: boolean + label: "Example setting" diff --git a/core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml b/core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml new file mode 100644 index 00000000000..be975279565 --- /dev/null +++ b/core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml @@ -0,0 +1,8 @@ +name: 'Third Party Settings Test' +type: module +description: 'A dummy module that third party settings tests can depend on' +package: Testing +version: VERSION +dependencies: + - drupal:node + - drupal:views diff --git a/core/modules/views/tests/src/Functional/GlossaryTest.php b/core/modules/views/tests/src/Functional/GlossaryTest.php index 292f9176771..25c08d5f159 100644 --- a/core/modules/views/tests/src/Functional/GlossaryTest.php +++ b/core/modules/views/tests/src/Functional/GlossaryTest.php @@ -83,7 +83,6 @@ class GlossaryTest extends ViewTestBase { 'url', 'user.node_grants:view', 'user.permissions', - 'route', ], [ 'config:views.view.glossary', diff --git a/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php b/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php index 8af887d1ef1..5aecbea3e36 100644 --- a/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php +++ b/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\views\Functional\Plugin; -use Drupal\Component\Render\FormattableMarkup; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\views\Functional\ViewTestBase; use Drupal\views\Views; @@ -317,6 +316,14 @@ class DisplayTest extends ViewTestBase { $output = $view->preview(); $output = (string) $renderer->renderRoot($output); $this->assertStringContainsString('/node?date=22&foo=bar#22', $output, 'The read more link with href "/node?date=22&foo=bar#22" was found.'); + + // Test more link isn't rendered if user doesn't have permission to the + // more link URL. + $view->display_handler->setOption('link_url', 'admin/content'); + $this->executeView($view); + $output = $view->preview(); + $output = (string) $renderer->renderRoot($output); + $this->assertStringNotContainsString('/admin/content', $output, 'The read more link with href "/admin/content" was not found.'); } /** @@ -389,8 +396,8 @@ class DisplayTest extends ViewTestBase { $errors = $view->validate(); // Check that the error messages are shown. $this->assertCount(2, $errors['default'], 'Error messages found for required relationship'); - $this->assertEquals(new FormattableMarkup('The %relationship_name relationship used in %handler_type %handler is not present in the %display_name display.', ['%relationship_name' => 'uid', '%handler_type' => 'field', '%handler' => 'User: Last login', '%display_name' => 'Default']), $errors['default'][0]); - $this->assertEquals(new FormattableMarkup('The %relationship_name relationship used in %handler_type %handler is not present in the %display_name display.', ['%relationship_name' => 'uid', '%handler_type' => 'field', '%handler' => 'User: Created', '%display_name' => 'Default']), $errors['default'][1]); + $this->assertEquals("The uid relationship used in field User: Last login is not present in the Default display.", $errors['default'][0]); + $this->assertEquals("The uid relationship used in field User: Created is not present in the Default display.", $errors['default'][1]); } /** diff --git a/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php b/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php index 03488125064..e19f1414615 100644 --- a/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php +++ b/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php @@ -150,4 +150,38 @@ class ArgumentSummaryTest extends ViewsKernelTestBase { $this->assertStringContainsString($tags[1]->label() . ' (2)', $output); } + /** + * Tests that the active link is set correctly. + */ + public function testActiveLink(): void { + require_once $this->root . '/core/modules/views/views.theme.inc'; + + // We need at least one node. + Node::create([ + 'type' => $this->nodeType->id(), + 'title' => $this->randomMachineName(), + ])->save(); + + $view = Views::getView('test_argument_summary'); + $view->execute(); + $view->build(); + $variables = [ + 'view' => $view, + 'rows' => $view->result, + ]; + + template_preprocess_views_view_summary_unformatted($variables); + $this->assertFalse($variables['rows'][0]->active); + + template_preprocess_views_view_summary($variables); + $this->assertFalse($variables['rows'][0]->active); + + // Checks that the row with the current path is active. + \Drupal::service('path.current')->setPath('/test-argument-summary'); + template_preprocess_views_view_summary_unformatted($variables); + $this->assertTrue($variables['rows'][0]->active); + template_preprocess_views_view_summary($variables); + $this->assertTrue($variables['rows'][0]->active); + } + } diff --git a/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php b/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php index 97d670634b3..14f90fd0c33 100644 --- a/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php +++ b/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php @@ -137,12 +137,13 @@ class ExposedFormRenderTest extends ViewsKernelTestBase { $view->save(); $this->executeView($view); + // The "type" filter should be excluded from the raw input because its + // value is "All". $expected = [ - 'type' => 'All', 'type_with_default_value' => 'article', 'multiple_types_with_default_value' => ['article' => 'article'], ]; - $this->assertSame($view->exposed_raw_input, $expected); + $this->assertSame($expected, $view->exposed_raw_input); } } diff --git a/core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php b/core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php new file mode 100644 index 00000000000..0f3d3eb5291 --- /dev/null +++ b/core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\views\Kernel; + +use Drupal\views\Entity\View; + +/** + * Tests proper removal of third-party settings from views. + * + * @group views + */ +class ThirdPartyUninstallTest extends ViewsKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['node', 'views_third_party_settings_test']; + + /** + * Views used by this test. + * + * @var array + */ + public static $testViews = ['test_third_party_uninstall']; + + /** + * {@inheritdoc} + */ + protected function setUp($import_test_views = TRUE): void { + parent::setUp($import_test_views); + + $this->installEntitySchema('user'); + $this->installSchema('user', ['users_data']); + } + + /** + * Tests removing third-party settings when a provider module is uninstalled. + */ + public function testThirdPartyUninstall(): void { + $view = View::load('test_third_party_uninstall'); + $this->assertNotEmpty($view); + $this->assertContains('views_third_party_settings_test', $view->getDependencies()['module']); + $this->assertTrue($view->getThirdPartySetting('views_third_party_settings_test', 'example_setting')); + + \Drupal::service('module_installer')->uninstall(['views_third_party_settings_test']); + + $view = View::load('test_third_party_uninstall'); + $this->assertNotEmpty($view); + $this->assertNotContains('views_third_party_settings_test', $view->getDependencies()['module']); + $this->assertNull($view->getThirdPartySetting('views_third_party_settings_test', 'example_setting')); + } + +} diff --git a/core/modules/views/views.theme.inc b/core/modules/views/views.theme.inc index 10c29c5dbf3..04c5de5a535 100644 --- a/core/modules/views/views.theme.inc +++ b/core/modules/views/views.theme.inc @@ -253,15 +253,12 @@ function template_preprocess_views_view_summary(&$variables): void { $url_options['query'] = $view->exposed_raw_input; } + $currentPath = \Drupal::service('path.current')->getPath(); $active_urls = [ // Force system path. - Url::fromRoute('<current>', [], ['alias' => TRUE])->toString(), - // Force system path. - Url::fromRouteMatch(\Drupal::routeMatch())->setOption('alias', TRUE)->toString(), - // Could be an alias. - Url::fromRoute('<current>')->toString(), + Url::fromUserInput($currentPath, ['alias' => TRUE])->toString(), // Could be an alias. - Url::fromRouteMatch(\Drupal::routeMatch())->toString(), + Url::fromUserInput($currentPath)->toString(), ]; $active_urls = array_combine($active_urls, $active_urls); @@ -342,11 +339,12 @@ function template_preprocess_views_view_summary_unformatted(&$variables): void { } $count = 0; + $currentPath = \Drupal::service('path.current')->getPath(); $active_urls = [ // Force system path. - Url::fromRoute('<current>', [], ['alias' => TRUE])->toString(), + Url::fromUserInput($currentPath, ['alias' => TRUE])->toString(), // Could be an alias. - Url::fromRoute('<current>')->toString(), + Url::fromUserInput($currentPath)->toString(), ]; $active_urls = array_combine($active_urls, $active_urls); diff --git a/core/modules/workflows/tests/src/Kernel/WorkflowAccessControlHandlerTest.php b/core/modules/workflows/tests/src/Kernel/WorkflowAccessControlHandlerTest.php index e46fbcf417b..180edc868f6 100644 --- a/core/modules/workflows/tests/src/Kernel/WorkflowAccessControlHandlerTest.php +++ b/core/modules/workflows/tests/src/Kernel/WorkflowAccessControlHandlerTest.php @@ -124,6 +124,8 @@ class WorkflowAccessControlHandlerTest extends KernelTestBase { * An array of test data. */ public static function checkAccessProvider() { + $originalContainer = \Drupal::hasContainer() ? \Drupal::getContainer() : NULL; + $container = new ContainerBuilder(); $cache_contexts_manager = (new Prophet())->prophesize(CacheContextsManager::class); $cache_contexts_manager->assertValidTokens()->willReturn(TRUE); @@ -131,7 +133,7 @@ class WorkflowAccessControlHandlerTest extends KernelTestBase { $container->set('cache_contexts_manager', $cache_contexts_manager); \Drupal::setContainer($container); - return [ + $data = [ 'Admin view' => [ 'adminUser', 'view', @@ -275,6 +277,13 @@ class WorkflowAccessControlHandlerTest extends KernelTestBase { AccessResult::allowed()->addCacheContexts(['user.permissions']), ], ]; + + // Restore the original container if needed. + if ($originalContainer) { + \Drupal::setContainer($originalContainer); + } + + return $data; } } diff --git a/core/modules/workspaces/src/Install/Requirements/WorkspacesRequirements.php b/core/modules/workspaces/src/Install/Requirements/WorkspacesRequirements.php index a54148215af..d865ea82c17 100644 --- a/core/modules/workspaces/src/Install/Requirements/WorkspacesRequirements.php +++ b/core/modules/workspaces/src/Install/Requirements/WorkspacesRequirements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\workspaces\Install\Requirements; use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; /** * Install time requirements for the workspaces module. @@ -18,7 +19,7 @@ class WorkspacesRequirements implements InstallRequirementsInterface { $requirements = []; if (\Drupal::moduleHandler()->moduleExists('workspace')) { $requirements['workspace_incompatibility'] = [ - 'severity' => REQUIREMENT_ERROR, + 'severity' => RequirementSeverity::Error, 'description' => t('Workspaces can not be installed when the contributed Workspace module is also installed. See the <a href=":link">upgrade path</a> page for more information on how to upgrade.', [ ':link' => 'https://www.drupal.org/node/2987783', ]), |