diff options
Diffstat (limited to 'core/modules')
780 files changed, 13409 insertions, 6744 deletions
diff --git a/core/modules/announcements_feed/tests/src/FunctionalJavascript/AccessAnnouncementTest.php b/core/modules/announcements_feed/tests/src/FunctionalJavascript/AccessAnnouncementTest.php index 02718fe8e40..4876bf779d2 100644 --- a/core/modules/announcements_feed/tests/src/FunctionalJavascript/AccessAnnouncementTest.php +++ b/core/modules/announcements_feed/tests/src/FunctionalJavascript/AccessAnnouncementTest.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Drupal\Tests\announcements_feed\FunctionalJavascript; -use Drupal\Tests\system\FunctionalJavascript\OffCanvasTestBase; use Drupal\announce_feed_test\AnnounceTestHttpClientMiddleware; +use Drupal\Tests\system\FunctionalJavascript\OffCanvasTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Test the access announcement permissions to get access announcement icon. - * - * @group announcements_feed */ +#[Group('announcements_feed')] class AccessAnnouncementTest extends OffCanvasTestBase { /** diff --git a/core/modules/announcements_feed/tests/src/FunctionalJavascript/AlertsJsonFeedTest.php b/core/modules/announcements_feed/tests/src/FunctionalJavascript/AlertsJsonFeedTest.php index 39edf53e90c..77a8df8b32b 100644 --- a/core/modules/announcements_feed/tests/src/FunctionalJavascript/AlertsJsonFeedTest.php +++ b/core/modules/announcements_feed/tests/src/FunctionalJavascript/AlertsJsonFeedTest.php @@ -4,15 +4,15 @@ declare(strict_types=1); namespace Drupal\Tests\announcements_feed\FunctionalJavascript; -use Drupal\Tests\system\FunctionalJavascript\OffCanvasTestBase; use Drupal\announce_feed_test\AnnounceTestHttpClientMiddleware; +use Drupal\Tests\system\FunctionalJavascript\OffCanvasTestBase; use Drupal\user\UserInterface; +use PHPUnit\Framework\Attributes\Group; /** * Test the access announcement according to json feed changes. - * - * @group announcements_feed */ +#[Group('announcements_feed')] class AlertsJsonFeedTest extends OffCanvasTestBase { /** diff --git a/core/modules/announcements_feed/tests/src/FunctionalJavascript/AnnounceBlockTest.php b/core/modules/announcements_feed/tests/src/FunctionalJavascript/AnnounceBlockTest.php index a796ccf0b8d..dd7f4f74791 100644 --- a/core/modules/announcements_feed/tests/src/FunctionalJavascript/AnnounceBlockTest.php +++ b/core/modules/announcements_feed/tests/src/FunctionalJavascript/AnnounceBlockTest.php @@ -10,12 +10,12 @@ use Drupal\Core\Access\AccessResultAllowed; use Drupal\Core\Access\AccessResultNeutral; use Drupal\Core\Session\AnonymousUserSession; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Test the announcement block test visibility. - * - * @group announcements_feed */ +#[Group('announcements_feed')] class AnnounceBlockTest extends WebDriverTestBase { /** diff --git a/core/modules/announcements_feed/tests/src/Kernel/AnnounceTestBase.php b/core/modules/announcements_feed/tests/src/Kernel/AnnounceTestBase.php index b70ad5d6c27..e017014afe5 100644 --- a/core/modules/announcements_feed/tests/src/Kernel/AnnounceTestBase.php +++ b/core/modules/announcements_feed/tests/src/Kernel/AnnounceTestBase.php @@ -14,7 +14,7 @@ use GuzzleHttp\Psr7\Response; /** * Base class for Announce Kernel tests. */ -class AnnounceTestBase extends KernelTestBase { +abstract class AnnounceTestBase extends KernelTestBase { /** * {@inheritdoc} diff --git a/core/modules/automated_cron/automated_cron.services.yml b/core/modules/automated_cron/automated_cron.services.yml index 0cc66d22384..69c364b1f25 100644 --- a/core/modules/automated_cron/automated_cron.services.yml +++ b/core/modules/automated_cron/automated_cron.services.yml @@ -4,6 +4,6 @@ parameters: services: _defaults: autoconfigure: true + autowire: true automated_cron.subscriber: class: Drupal\automated_cron\EventSubscriber\AutomatedCron - arguments: ['@cron', '@config.factory', '@state'] diff --git a/core/modules/automated_cron/src/EventSubscriber/AutomatedCron.php b/core/modules/automated_cron/src/EventSubscriber/AutomatedCron.php index c237f07ea34..80258bad508 100644 --- a/core/modules/automated_cron/src/EventSubscriber/AutomatedCron.php +++ b/core/modules/automated_cron/src/EventSubscriber/AutomatedCron.php @@ -1,10 +1,12 @@ <?php +declare(strict_types=1); + namespace Drupal\automated_cron\EventSubscriber; use Drupal\Core\Config\ConfigFactoryInterface; -use Drupal\Core\CronInterface; use Drupal\Core\State\StateInterface; +use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\TerminateEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -14,42 +16,12 @@ use Symfony\Component\HttpKernel\KernelEvents; */ class AutomatedCron implements EventSubscriberInterface { - /** - * The cron service. - * - * @var \Drupal\Core\CronInterface - */ - protected $cron; - - /** - * The cron configuration. - * - * @var \Drupal\Core\Config\Config - */ - protected $config; - - /** - * The state key value store. - * - * @var \Drupal\Core\State\StateInterface - */ - protected $state; - - /** - * Constructs a new automated cron runner. - * - * @param \Drupal\Core\CronInterface $cron - * The cron service. - * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory - * The config factory. - * @param \Drupal\Core\State\StateInterface $state - * The state key-value store service. - */ - public function __construct(CronInterface $cron, ConfigFactoryInterface $config_factory, StateInterface $state) { - $this->cron = $cron; - $this->config = $config_factory->get('automated_cron.settings'); - $this->state = $state; - } + public function __construct( + #[AutowireServiceClosure('cron')] + protected readonly \Closure $cron, + protected readonly ConfigFactoryInterface $configFactory, + protected StateInterface $state, + ) {} /** * Run the automated cron if enabled. @@ -57,12 +29,12 @@ class AutomatedCron implements EventSubscriberInterface { * @param \Symfony\Component\HttpKernel\Event\TerminateEvent $event * The Event to process. */ - public function onTerminate(TerminateEvent $event) { - $interval = $this->config->get('interval'); + public function onTerminate(TerminateEvent $event): void { + $interval = $this->configFactory->get('automated_cron.settings')->get('interval'); if ($interval > 0) { $cron_next = $this->state->get('system.cron_last', 0) + $interval; if ((int) $event->getRequest()->server->get('REQUEST_TIME') > $cron_next) { - $this->cron->run(); + ($this->cron)()->run(); } } } diff --git a/core/modules/automated_cron/tests/src/Kernel/AutomatedCronTest.php b/core/modules/automated_cron/tests/src/Kernel/AutomatedCronTest.php new file mode 100644 index 00000000000..40f71d091c2 --- /dev/null +++ b/core/modules/automated_cron/tests/src/Kernel/AutomatedCronTest.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\automated_cron\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Tests for automated_cron. + * + * @group automated_cron + */ +class AutomatedCronTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['automated_cron']; + + /** + * Tests that automated cron runs cron on an HTTP request. + * + * @covers \Drupal\automated_cron\EventSubscriber\AutomatedCron::onTerminate + */ + public function testRunsCronOnHttpRequest(): void { + // Set automated_cron interval and times. + // Any interval > 0 should work. + $this->config('automated_cron.settings')->set('interval', 10800)->save(); + $request = new Request(); + + // Cron uses `$_SERVER['REQUEST_TIME']` to set `system.cron_last` + // because there is no request stack, so we set the request time + // to the same. + $expected = $_SERVER['REQUEST_TIME']; + $request->server->set('REQUEST_TIME', $expected); + + // Invoke `AutomatedCron::onTerminate` and check result. + $this->assertNull($this->container->get('state')->get('system.cron_last')); + $this->container->get('kernel')->terminate($request, new Response()); + $this->assertEquals($expected, $this->container->get('state')->get('system.cron_last')); + } + +} diff --git a/core/modules/big_pipe/js/big_pipe.js b/core/modules/big_pipe/js/big_pipe.js index 3c3e106e703..cc8c7a24496 100644 --- a/core/modules/big_pipe/js/big_pipe.js +++ b/core/modules/big_pipe/js/big_pipe.js @@ -91,8 +91,7 @@ return Boolean( node.nodeType === Node.ELEMENT_NODE && node.nodeName === 'SCRIPT' && - node.dataset && - node.dataset.bigPipeReplacementForPlaceholderWithId && + node.dataset?.bigPipeReplacementForPlaceholderWithId && typeof drupalSettings.bigPipePlaceholderIds[ node.dataset.bigPipeReplacementForPlaceholderWithId ] !== 'undefined', diff --git a/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipePreviewTest.php b/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipePreviewTest.php index 3579ca7b2f4..ad064369206 100644 --- a/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipePreviewTest.php +++ b/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipePreviewTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\big_pipe\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests placeholder preview functionality. - * - * @group big_pipe */ +#[Group('big_pipe')] class BigPipePreviewTest extends WebDriverTestBase { /** diff --git a/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipeRegressionTest.php b/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipeRegressionTest.php index 8e4abff5734..e94279d9404 100644 --- a/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipeRegressionTest.php +++ b/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipeRegressionTest.php @@ -8,13 +8,13 @@ use Drupal\big_pipe\Render\BigPipe; use Drupal\big_pipe_regression_test\BigPipeRegressionTestController; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * BigPipe regression tests. - * - * @group big_pipe - * @group #slow */ +#[Group('big_pipe')] +#[Group('#slow')] class BigPipeRegressionTest extends WebDriverTestBase { /** 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/block.services.yml b/core/modules/block/block.services.yml index b73f9642231..97623ff523a 100644 --- a/core/modules/block/block.services.yml +++ b/core/modules/block/block.services.yml @@ -11,3 +11,4 @@ services: class: Drupal\block\BlockRepository arguments: ['@entity_type.manager', '@theme.manager', '@context.handler'] Drupal\block\BlockRepositoryInterface: '@block.repository' + Drupal\block\BlockConfigUpdater: ~ 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/BlockConfigUpdater.php b/core/modules/block/src/BlockConfigUpdater.php new file mode 100644 index 00000000000..317d19ddc64 --- /dev/null +++ b/core/modules/block/src/BlockConfigUpdater.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\block; + +/** + * Provides a BC layer for modules providing old configurations. + * + * @internal + */ +class BlockConfigUpdater { + + /** + * Flag determining whether deprecations should be triggered. + * + * @var bool + */ + protected bool $deprecationsEnabled = TRUE; + + /** + * Stores which deprecations were triggered. + * + * @var array + */ + protected array $triggeredDeprecations = []; + + /** + * Sets the deprecations enabling status. + * + * @param bool $enabled + * Whether deprecations should be enabled. + */ + public function setDeprecationsEnabled(bool $enabled): void { + $this->deprecationsEnabled = $enabled; + } + + /** + * Performs the required update. + * + * @param \Drupal\block\BlockInterface $block + * The block to update. + * + * @return bool + * Whether the block was updated. + */ + public function updateBlock(BlockInterface $block): bool { + $changed = FALSE; + if ($this->needsInfoStatusSettingsRemoved($block)) { + $settings = $block->get('settings'); + unset($settings['info'], $settings['status']); + $block->set('settings', $settings); + $changed = TRUE; + } + return $changed; + } + + /** + * Checks if the block contains deprecated info and status settings. + * + * @param \Drupal\block\BlockInterface $block + * The block to update. + * + * @return bool + * TRUE if the block has deprecated settings. + */ + public function needsInfoStatusSettingsRemoved(BlockInterface $block): bool { + if (!str_starts_with($block->getPluginId(), 'block_content')) { + return FALSE; + } + $settings = $block->get('settings'); + if (!isset($settings['info']) && !isset($settings['status'])) { + return FALSE; + } + + $deprecations_triggered = &$this->triggeredDeprecations['3426302'][$block->id()]; + if ($this->deprecationsEnabled && !$deprecations_triggered) { + $deprecations_triggered = TRUE; + @trigger_error('Block content blocks with the "status" and "info" settings is deprecated in drupal:11.3.0 and will be removed in drupal:12.0.0. They were unused, so there is no replacement. Profile, module and theme provided configuration should be updated. See https://www.drupal.org/node/3499836', E_USER_DEPRECATED); + } + + return TRUE; + } + +} diff --git a/core/modules/block/src/BlockViewBuilder.php b/core/modules/block/src/BlockViewBuilder.php index f33092de869..b5a1e464346 100644 --- a/core/modules/block/src/BlockViewBuilder.php +++ b/core/modules/block/src/BlockViewBuilder.php @@ -67,7 +67,11 @@ class BlockViewBuilder extends EntityViewBuilder implements TrustedCallbackInter if ($plugin instanceof MainContentBlockPluginInterface || $plugin instanceof TitleBlockPluginInterface) { // Immediately build a #pre_render-able block, since this block cannot // be built lazily. - $build[$entity_id] += static::buildPreRenderableBlock($entity, $this->moduleHandler()); + $cacheableMetadata = CacheableMetadata::createFromRenderArray($build[$entity_id]); + $preRenderableBlock = static::buildPreRenderableBlock($entity, $this->moduleHandler()); + $cacheableMetadata->addCacheableDependency(CacheableMetadata::createFromRenderArray($preRenderableBlock)); + $build[$entity_id] += $preRenderableBlock; + $cacheableMetadata->applyTo($build[$entity_id]); } else { // Assign a #lazy_builder callback, which will generate a #pre_render- 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/modules/block_test/src/Hook/BlockTestHooks.php b/core/modules/block/tests/modules/block_test/src/Hook/BlockTestHooks.php index 19afd070f9d..7954c270df2 100644 --- a/core/modules/block/tests/modules/block_test/src/Hook/BlockTestHooks.php +++ b/core/modules/block/tests/modules/block_test/src/Hook/BlockTestHooks.php @@ -37,6 +37,16 @@ class BlockTestHooks { } /** + * Implements hook_block_view_BASE_BLOCK_ID_alter(). + * + * @see \Drupal\Tests\block\Kernel\BlockViewBuilderTest::testBlockViewBuilderCacheTitleBlock() + */ + #[Hook('block_view_page_title_block_alter')] + public function blockViewPageTitleBlockAlter(array &$build, BlockPluginInterface $block): void { + $build['#cache']['tags'][] = 'custom_cache_tag'; + } + + /** * Implements hook_block_build_BASE_BLOCK_ID_alter(). */ #[Hook('block_build_test_cache_alter')] diff --git a/core/modules/block/tests/src/Functional/BlockLanguageTest.php b/core/modules/block/tests/src/Functional/BlockLanguageTest.php index d7ee92575fe..4801c308cf5 100644 --- a/core/modules/block/tests/src/Functional/BlockLanguageTest.php +++ b/core/modules/block/tests/src/Functional/BlockLanguageTest.php @@ -72,7 +72,7 @@ class BlockLanguageTest extends BrowserTestBase { public function testLanguageBlockVisibility(): void { // Check if the visibility setting is available. $default_theme = $this->config('system.theme')->get('default'); - $this->drupalGet('admin/structure/block/add/system_powered_by_block' . '/' . $default_theme); + $this->drupalGet('admin/structure/block/add/system_powered_by_block/' . $default_theme); // Ensure that the language visibility field is visible without a type // setting. $this->assertSession()->fieldExists('visibility[language][langcodes][en]'); @@ -84,7 +84,7 @@ class BlockLanguageTest extends BrowserTestBase { 'id' => $this->randomMachineName(8), 'region' => 'sidebar_first', ]; - $this->drupalGet('admin/structure/block/add/system_powered_by_block' . '/' . $default_theme); + $this->drupalGet('admin/structure/block/add/system_powered_by_block/' . $default_theme); $this->submitForm($edit, 'Save block'); // Change the default language. @@ -162,7 +162,7 @@ class BlockLanguageTest extends BrowserTestBase { // Check if the visibility setting is available with a type setting. $default_theme = $this->config('system.theme')->get('default'); - $this->drupalGet('admin/structure/block/add/system_powered_by_block' . '/' . $default_theme); + $this->drupalGet('admin/structure/block/add/system_powered_by_block/' . $default_theme); $this->assertSession()->fieldExists('visibility[language][langcodes][en]'); $this->assertSession()->fieldExists('visibility[language][context_mapping][language]'); @@ -174,7 +174,7 @@ class BlockLanguageTest extends BrowserTestBase { 'id' => $block_id, 'region' => 'sidebar_first', ]; - $this->drupalGet('admin/structure/block/add/system_powered_by_block' . '/' . $default_theme); + $this->drupalGet('admin/structure/block/add/system_powered_by_block/' . $default_theme); $this->submitForm($edit, 'Save block'); // Interface negotiation depends on request arguments. diff --git a/core/modules/block/tests/src/FunctionalJavascript/BlockAddTest.php b/core/modules/block/tests/src/FunctionalJavascript/BlockAddTest.php index 9be17d58a17..84d1960c399 100644 --- a/core/modules/block/tests/src/FunctionalJavascript/BlockAddTest.php +++ b/core/modules/block/tests/src/FunctionalJavascript/BlockAddTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\block\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JS functionality in the block add form. - * - * @group block */ +#[Group('block')] class BlockAddTest extends WebDriverTestBase { /** diff --git a/core/modules/block/tests/src/FunctionalJavascript/BlockContextualLinksTest.php b/core/modules/block/tests/src/FunctionalJavascript/BlockContextualLinksTest.php index c425731e844..038542b4455 100644 --- a/core/modules/block/tests/src/FunctionalJavascript/BlockContextualLinksTest.php +++ b/core/modules/block/tests/src/FunctionalJavascript/BlockContextualLinksTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\block\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the contextual links added while rendering the block. - * - * @group block */ +#[Group('block')] class BlockContextualLinksTest extends WebDriverTestBase { /** diff --git a/core/modules/block/tests/src/FunctionalJavascript/BlockDragTest.php b/core/modules/block/tests/src/FunctionalJavascript/BlockDragTest.php index 4de5edebdd2..6fcf9d54f1e 100644 --- a/core/modules/block/tests/src/FunctionalJavascript/BlockDragTest.php +++ b/core/modules/block/tests/src/FunctionalJavascript/BlockDragTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\block\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests drag and drop blocks on block layout page. - * - * @group block */ +#[Group('block')] class BlockDragTest extends WebDriverTestBase { /** diff --git a/core/modules/block/tests/src/FunctionalJavascript/BlockFilterTest.php b/core/modules/block/tests/src/FunctionalJavascript/BlockFilterTest.php index 3502e65a195..a96930f375d 100644 --- a/core/modules/block/tests/src/FunctionalJavascript/BlockFilterTest.php +++ b/core/modules/block/tests/src/FunctionalJavascript/BlockFilterTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\block\FunctionalJavascript; use Behat\Mink\Element\NodeElement; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript functionality of the block add filter. - * - * @group block */ +#[Group('block')] class BlockFilterTest extends WebDriverTestBase { /** 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/BlockViewBuilderTest.php b/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php index d93e1f819ea..377cd31deac 100644 --- a/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php +++ b/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php @@ -164,6 +164,30 @@ class BlockViewBuilderTest extends KernelTestBase { } /** + * Tests title block render cache handling. + * + * @see \Drupal\block_test\Hook\BlockTestHooks::blockViewPageTitleBlockAlter() + */ + public function testBlockViewBuilderCacheTitleBlock(): void { + // Create title block. + $this->block = $this->controller->create([ + 'id' => 'test_block_title', + 'theme' => 'stark', + 'plugin' => 'page_title_block', + ]); + $this->block->save(); + + $entity = Block::load('test_block_title'); + $builder = \Drupal::entityTypeManager()->getViewBuilder('block'); + $output = $builder->view($entity, 'block'); + + $this->assertSame( + ['block_view', 'config:block.block.test_block_title', 'custom_cache_tag'], + $output['#cache']['tags'] + ); + } + + /** * Verifies render cache handling of the block being tested. * * @see ::testBlockViewBuilderCache() 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..cb94233ebaf 100644 --- a/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php +++ b/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Drupal\Tests\block\Kernel\Migrate\d6; use Drupal\block\Entity\Block; -use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase; use Drupal\block\Hook\BlockHooks; +use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase; /** * Tests migration of blocks to configuration entities. @@ -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 = [ @@ -265,8 +265,6 @@ class MigrateBlockTest extends MigrateDrupal6TestBase { 'label' => 'Static Block', 'provider' => 'block_content', 'label_display' => 'visible', - 'status' => TRUE, - 'info' => '', 'view_mode' => 'full', ]; $this->assertEntity('block', $visibility, 'content', 'olivero', 0, $settings); @@ -283,8 +281,6 @@ class MigrateBlockTest extends MigrateDrupal6TestBase { 'label' => 'Another Static Block', 'provider' => 'block_content', 'label_display' => 'visible', - 'status' => TRUE, - 'info' => '', 'view_mode' => 'full', ]; // We expect this block to be disabled because '' is not a valid region, @@ -296,8 +292,6 @@ class MigrateBlockTest extends MigrateDrupal6TestBase { 'label' => '', 'provider' => 'block_content', 'label_display' => '0', - 'status' => TRUE, - 'info' => '', 'view_mode' => 'full', ]; $this->assertEntity('block_2', [], 'right', 'test_theme', -7, $settings); diff --git a/core/modules/block_content/block_content.post_update.php b/core/modules/block_content/block_content.post_update.php index 592b3ee9714..53021a078f6 100644 --- a/core/modules/block_content/block_content.post_update.php +++ b/core/modules/block_content/block_content.post_update.php @@ -5,6 +5,10 @@ * Post update functions for Content Block. */ +use Drupal\block\BlockConfigUpdater; +use Drupal\block\BlockInterface; +use Drupal\Core\Config\Entity\ConfigEntityUpdater; + /** * Implements hook_removed_post_updates(). */ @@ -18,3 +22,16 @@ function block_content_removed_post_updates(): array { 'block_content_post_update_revision_type' => '11.0.0', ]; } + +/** + * Remove deprecated status and info keys from block_content blocks. + */ +function block_content_post_update_remove_block_content_status_info_keys(array &$sandbox = []): void { + /** @var \Drupal\block\BlockConfigUpdater $blockConfigUpdater */ + $blockConfigUpdater = \Drupal::service(BlockConfigUpdater::class); + $blockConfigUpdater->setDeprecationsEnabled(FALSE); + \Drupal::classResolver(ConfigEntityUpdater::class) + ->update($sandbox, 'block', function (BlockInterface $block) use ($blockConfigUpdater): bool { + return $blockConfigUpdater->needsInfoStatusSettingsRemoved($block); + }); +} diff --git a/core/modules/block_content/block_content.services.yml b/core/modules/block_content/block_content.services.yml index 2661eda513b..5d43a1d37f8 100644 --- a/core/modules/block_content/block_content.services.yml +++ b/core/modules/block_content/block_content.services.yml @@ -1,6 +1,28 @@ parameters: block_content.skip_procedural_hook_scan: false + block_content.moved_classes: + Drupal\block_content\Access\AccessGroupAnd: + class: Drupal\Core\Access\AccessGroupAnd + deprecation_version: drupal:11.2.0 + removed_version: drupal:12.0.0 + change_record: https://www.drupal.org/node/3527501 + Drupal\block_content\Access\DependentAccessInterface: + class: Drupal\Core\Access\DependentAccessInterface + deprecation_version: drupal:11.2.0 + removed_version: drupal:12.0.0 + change_record: https://www.drupal.org/node/3527501 + Drupal\block_content\Access\RefinableDependentAccessInterface: + class: Drupal\Core\Access\RefinableDependentAccessInterface + deprecation_version: drupal:11.2.0 + removed_version: drupal:12.0.0 + change_record: https://www.drupal.org/node/3527501 + Drupal\block_content\Access\RefinableDependentAccessTrait: + class: Drupal\Core\Access\RefinableDependentAccessTrait + deprecation_version: drupal:11.2.0 + removed_version: drupal:12.0.0 + change_record: https://www.drupal.org/node/3527501 + services: _defaults: autoconfigure: true diff --git a/core/modules/block_content/config/schema/block_content.schema.yml b/core/modules/block_content/config/schema/block_content.schema.yml index 8c4161df5b5..6d7b2aeadc8 100644 --- a/core/modules/block_content/config/schema/block_content.schema.yml +++ b/core/modules/block_content/config/schema/block_content.schema.yml @@ -32,12 +32,12 @@ block.settings.block_content:*: FullyValidatable: ~ mapping: # @see \Drupal\block_content\Plugin\Block\BlockContentBlock::defaultConfiguration() - # @todo Deprecate this in https://www.drupal.org/project/drupal/issues/3426302 status: + deprecated: "The 'status' setting for content blocks is deprecated in drupal:11.3.0 and is removed from drupal 12.0.0. It was unused, so there is no replacement. See https://www.drupal.org/node/3499836." type: boolean label: 'Status' - # @todo Deprecate this in https://www.drupal.org/project/drupal/issues/3426302 info: + deprecated: "The 'info' setting for content blocks is deprecated in drupal:11.3.0 and is removed from drupal 12.0.0. It was unused, so there is no replacement. See https://www.drupal.org/node/3499836." type: label label: 'Admin info' view_mode: diff --git a/core/modules/block_content/src/Access/AccessGroupAnd.php b/core/modules/block_content/src/Access/AccessGroupAnd.php deleted file mode 100644 index be86d9e9700..00000000000 --- a/core/modules/block_content/src/Access/AccessGroupAnd.php +++ /dev/null @@ -1,49 +0,0 @@ -<?php - -namespace Drupal\block_content\Access; - -use Drupal\Core\Access\AccessibleInterface; -use Drupal\Core\Access\AccessResult; -use Drupal\Core\Session\AccountInterface; - -/** - * An access group where all the dependencies must be allowed. - * - * @internal - */ -class AccessGroupAnd implements AccessibleInterface { - - /** - * The access dependencies. - * - * @var \Drupal\Core\Access\AccessibleInterface[] - */ - protected $dependencies = []; - - /** - * {@inheritdoc} - */ - public function addDependency(AccessibleInterface $dependency) { - $this->dependencies[] = $dependency; - return $this; - } - - /** - * {@inheritdoc} - */ - public function access($operation, ?AccountInterface $account = NULL, $return_as_object = FALSE) { - $access_result = AccessResult::neutral(); - foreach (array_slice($this->dependencies, 1) as $dependency) { - $access_result = $access_result->andIf($dependency->access($operation, $account, TRUE)); - } - return $return_as_object ? $access_result : $access_result->isAllowed(); - } - - /** - * {@inheritdoc} - */ - public function getDependencies() { - return $this->dependencies; - } - -} diff --git a/core/modules/block_content/src/Access/DependentAccessInterface.php b/core/modules/block_content/src/Access/DependentAccessInterface.php deleted file mode 100644 index bc6a6dcec69..00000000000 --- a/core/modules/block_content/src/Access/DependentAccessInterface.php +++ /dev/null @@ -1,35 +0,0 @@ -<?php - -namespace Drupal\block_content\Access; - -/** - * Interface for AccessibleInterface objects that have an access dependency. - * - * Objects should implement this interface when their access depends on access - * to another object that implements \Drupal\Core\Access\AccessibleInterface. - * This interface simply provides the getter method for the access - * dependency object. Objects that implement this interface are responsible for - * checking access of the access dependency because the dependency may not take - * effect in all cases. For instance an entity may only need the access - * dependency set when it is embedded within another entity and its access - * should be dependent on access to the entity in which it is embedded. - * - * To check the access to the dependency the object implementing this interface - * can use code like this: - * @code - * $accessible->getAccessDependency()->access($op, $account, TRUE); - * @endcode - * - * @internal - */ -interface DependentAccessInterface { - - /** - * Gets the access dependency. - * - * @return \Drupal\Core\Access\AccessibleInterface|null - * The access dependency or NULL if none has been set. - */ - public function getAccessDependency(); - -} diff --git a/core/modules/block_content/src/Access/RefinableDependentAccessInterface.php b/core/modules/block_content/src/Access/RefinableDependentAccessInterface.php deleted file mode 100644 index 5d9eaf430b4..00000000000 --- a/core/modules/block_content/src/Access/RefinableDependentAccessInterface.php +++ /dev/null @@ -1,48 +0,0 @@ -<?php - -namespace Drupal\block_content\Access; - -use Drupal\Core\Access\AccessibleInterface; - -/** - * An interface to allow adding an access dependency. - * - * @internal - */ -interface RefinableDependentAccessInterface extends DependentAccessInterface { - - /** - * Sets the access dependency. - * - * If an access dependency is already set this will replace the existing - * dependency. - * - * @param \Drupal\Core\Access\AccessibleInterface $access_dependency - * The object upon which access depends. - * - * @return $this - */ - public function setAccessDependency(AccessibleInterface $access_dependency); - - /** - * Adds an access dependency into the existing access dependency. - * - * If no existing dependency is currently set this will set the dependency - * will be set to the new value. - * - * If there is an existing dependency and it is not an instance of - * AccessGroupAnd the dependency will be set as a new AccessGroupAnd - * instance with the existing and new dependencies as the members of the - * group. - * - * If there is an existing dependency and it is an instance of AccessGroupAnd - * the dependency will be added to the existing access group. - * - * @param \Drupal\Core\Access\AccessibleInterface $access_dependency - * The access dependency to merge. - * - * @return $this - */ - public function addAccessDependency(AccessibleInterface $access_dependency); - -} diff --git a/core/modules/block_content/src/Access/RefinableDependentAccessTrait.php b/core/modules/block_content/src/Access/RefinableDependentAccessTrait.php deleted file mode 100644 index 98b2a547ccf..00000000000 --- a/core/modules/block_content/src/Access/RefinableDependentAccessTrait.php +++ /dev/null @@ -1,52 +0,0 @@ -<?php - -namespace Drupal\block_content\Access; - -use Drupal\Core\Access\AccessibleInterface; - -/** - * Trait for \Drupal\block_content\Access\RefinableDependentAccessInterface. - * - * @internal - */ -trait RefinableDependentAccessTrait { - - /** - * The access dependency. - * - * @var \Drupal\Core\Access\AccessibleInterface - */ - protected $accessDependency; - - /** - * {@inheritdoc} - */ - public function setAccessDependency(AccessibleInterface $access_dependency) { - $this->accessDependency = $access_dependency; - return $this; - } - - /** - * {@inheritdoc} - */ - public function getAccessDependency() { - return $this->accessDependency; - } - - /** - * {@inheritdoc} - */ - public function addAccessDependency(AccessibleInterface $access_dependency) { - if (empty($this->accessDependency)) { - $this->accessDependency = $access_dependency; - return $this; - } - if (!$this->accessDependency instanceof AccessGroupAnd) { - $accessGroup = new AccessGroupAnd(); - $this->accessDependency = $accessGroup->addDependency($this->accessDependency); - } - $this->accessDependency->addDependency($access_dependency); - return $this; - } - -} diff --git a/core/modules/block_content/src/BlockContentAccessControlHandler.php b/core/modules/block_content/src/BlockContentAccessControlHandler.php index 34d5dd6e5ed..0d2765e8018 100644 --- a/core/modules/block_content/src/BlockContentAccessControlHandler.php +++ b/core/modules/block_content/src/BlockContentAccessControlHandler.php @@ -2,10 +2,10 @@ namespace Drupal\block_content; -use Drupal\block_content\Access\DependentAccessInterface; use Drupal\block_content\Event\BlockContentGetDependencyEvent; use Drupal\Core\Access\AccessResult; use Drupal\Core\Access\AccessResultInterface; +use Drupal\Core\Access\DependentAccessInterface; use Drupal\Core\Entity\EntityAccessControlHandler; use Drupal\Core\Entity\EntityHandlerInterface; use Drupal\Core\Entity\EntityInterface; diff --git a/core/modules/block_content/src/BlockContentInterface.php b/core/modules/block_content/src/BlockContentInterface.php index f6763f451c5..e4f80d20254 100644 --- a/core/modules/block_content/src/BlockContentInterface.php +++ b/core/modules/block_content/src/BlockContentInterface.php @@ -2,7 +2,7 @@ namespace Drupal\block_content; -use Drupal\block_content\Access\RefinableDependentAccessInterface; +use Drupal\Core\Access\RefinableDependentAccessInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityChangedInterface; use Drupal\Core\Entity\EntityPublishedInterface; diff --git a/core/modules/block_content/src/BlockContentTypeInterface.php b/core/modules/block_content/src/BlockContentTypeInterface.php index aaf7957b895..9c12709b2a5 100644 --- a/core/modules/block_content/src/BlockContentTypeInterface.php +++ b/core/modules/block_content/src/BlockContentTypeInterface.php @@ -3,19 +3,12 @@ namespace Drupal\block_content; use Drupal\Core\Config\Entity\ConfigEntityInterface; +use Drupal\Core\Entity\EntityDescriptionInterface; use Drupal\Core\Entity\RevisionableEntityBundleInterface; /** * Provides an interface defining a block type entity. */ -interface BlockContentTypeInterface extends ConfigEntityInterface, RevisionableEntityBundleInterface { - - /** - * Returns the description of the block type. - * - * @return string - * The description of the type of this block. - */ - public function getDescription(); +interface BlockContentTypeInterface extends ConfigEntityInterface, RevisionableEntityBundleInterface, EntityDescriptionInterface { } 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/Entity/BlockContent.php b/core/modules/block_content/src/Entity/BlockContent.php index 0d92bde4930..ed69e44f6f6 100644 --- a/core/modules/block_content/src/Entity/BlockContent.php +++ b/core/modules/block_content/src/Entity/BlockContent.php @@ -10,13 +10,13 @@ use Drupal\block_content\BlockContentTranslationHandler; use Drupal\block_content\BlockContentViewBuilder; use Drupal\block_content\BlockContentViewsData; use Drupal\block_content\Form\BlockContentDeleteForm; +use Drupal\Core\Access\RefinableDependentAccessTrait; use Drupal\Core\Entity\Attribute\ContentEntityType; use Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider; use Drupal\Core\Entity\Form\RevisionRevertForm; use Drupal\Core\Entity\Form\RevisionDeleteForm; use Drupal\Core\Entity\Sql\SqlContentEntityStorage; use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\block_content\Access\RefinableDependentAccessTrait; use Drupal\Core\Entity\EditorialContentEntityBase; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; diff --git a/core/modules/block_content/src/Entity/BlockContentType.php b/core/modules/block_content/src/Entity/BlockContentType.php index ecbc6c3866d..fa6fe395503 100644 --- a/core/modules/block_content/src/Entity/BlockContentType.php +++ b/core/modules/block_content/src/Entity/BlockContentType.php @@ -100,6 +100,13 @@ class BlockContentType extends ConfigEntityBundleBase implements BlockContentTyp /** * {@inheritdoc} */ + public function setDescription($description): static { + return $this->set('description', $description); + } + + /** + * {@inheritdoc} + */ public function shouldCreateNewRevision() { return $this->revision; } diff --git a/core/modules/block_content/src/Hook/BlockContentHooks.php b/core/modules/block_content/src/Hook/BlockContentHooks.php index bd1cfd608f1..d960c0a22d6 100644 --- a/core/modules/block_content/src/Hook/BlockContentHooks.php +++ b/core/modules/block_content/src/Hook/BlockContentHooks.php @@ -2,6 +2,7 @@ namespace Drupal\block_content\Hook; +use Drupal\block\BlockConfigUpdater; use Drupal\block\BlockInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\block_content\BlockContentInterface; @@ -29,11 +30,11 @@ class BlockContentHooks { $field_ui = \Drupal::moduleHandler()->moduleExists('field_ui') ? Url::fromRoute('help.page', ['name' => 'field_ui'])->toString() : '#'; $output = ''; $output .= '<h2>' . $this->t('About') . '</h2>'; - $output .= '<p>' . $this->t('The Block Content module allows you to create and manage custom <em>block types</em> and <em>content-containing blocks</em>. For more information, see the <a href=":online-help">online documentation for the Block Content module</a>.', [':online-help' => 'https://www.drupal.org/documentation/modules/block_content']) . '</p>'; + $output .= '<p>' . $this->t('The Block Content module manages the creation, editing, and deletion of content blocks. Content blocks are field-able content entities managed by the <a href=":field">Field module</a>. For more information, see the <a href=":block-content">online documentation for the Block Content module</a>.', [':block-content' => 'https://www.drupal.org/documentation/modules/block_content', ':field' => Url::fromRoute('help.page', ['name' => 'field'])->toString()]) . '</p>'; $output .= '<h2>' . $this->t('Uses') . '</h2>'; $output .= '<dl>'; $output .= '<dt>' . $this->t('Creating and managing block types') . '</dt>'; - $output .= '<dd>' . $this->t('Users with the <em>Administer blocks</em> permission can create and edit block types with fields and display settings, from the <a href=":types">Block types</a> page under the Structure menu. For more information about managing fields and display settings, see the <a href=":field-ui">Field UI module help</a> and <a href=":field">Field module help</a>.', [ + $output .= '<dd>' . $this->t('Users with the <em>Administer block types</em> permission can create and edit block types with fields and display settings, from the <a href=":types">Block types</a> page under the Structure menu. For more information about managing fields and display settings, see the <a href=":field-ui">Field UI module help</a> and <a href=":field">Field module help</a>.', [ ':types' => Url::fromRoute('entity.block_content_type.collection')->toString(), ':field-ui' => $field_ui, ':field' => Url::fromRoute('help.page', [ @@ -41,9 +42,9 @@ class BlockContentHooks { ])->toString(), ]) . '</dd>'; $output .= '<dt>' . $this->t('Creating content blocks') . '</dt>'; - $output .= '<dd>' . $this->t('Users with the <em>Administer blocks</em> permission can create, edit, and delete content blocks of each defined block type, from the <a href=":block-library">Content blocks page</a>. After creating a block, place it in a region from the <a href=":blocks">Block layout page</a>, just like blocks provided by other modules.', [ - ':blocks' => Url::fromRoute('block.admin_display')->toString(), - ':block-library' => Url::fromRoute('entity.block_content.collection')->toString(), + $output .= '<dd>' . $this->t('Users with the <em>Administer block content</em> or <em>Create new content block</em> permissions for an individual block type are able to add content blocks. These can be created on the <a href=":add-content-block">Add content block page</a> or on the <em>Place block</em> modal on the <a href=":block-layout">Block Layout page</a> and are reusable across the entire site. Content blocks created in Layout Builder for a content type or individual node layouts are not reusable and also called inline blocks.', [ + ':add-content-block' => Url::fromRoute('block_content.add_page')->toString(), + ':block-layout' => Url::fromRoute('block.admin_display')->toString(), ]) . '</dd>'; $output .= '</dl>'; return $output; @@ -160,4 +161,15 @@ class BlockContentHooks { return $operations; } + /** + * Implements hook_ENTITY_TYPE_presave(). + */ + #[Hook('block_presave')] + public function blockPreSave(BlockInterface $block): void { + // Use an inline service since DI would require enabling the block module + // in any Kernel test that installs block_content. This is BC code so will + // be removed in Drupal 12 anyway. + \Drupal::service(BlockConfigUpdater::class)->updateBlock($block); + } + } diff --git a/core/modules/block_content/src/Plugin/Block/BlockContentBlock.php b/core/modules/block_content/src/Plugin/Block/BlockContentBlock.php index c42fb2f5de5..131a56b516f 100644 --- a/core/modules/block_content/src/Plugin/Block/BlockContentBlock.php +++ b/core/modules/block_content/src/Plugin/Block/BlockContentBlock.php @@ -132,8 +132,6 @@ class BlockContentBlock extends BlockBase implements ContainerFactoryPluginInter */ public function defaultConfiguration() { return [ - 'status' => TRUE, - 'info' => '', 'view_mode' => 'full', ]; } 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/modules/block_content_test/config/install/block.block.foobar_gorilla.yml b/core/modules/block_content/tests/modules/block_content_test/config/install/block.block.foobar_gorilla.yml index 998c43340eb..48147809560 100644 --- a/core/modules/block_content/tests/modules/block_content_test/config/install/block.block.foobar_gorilla.yml +++ b/core/modules/block_content/tests/modules/block_content_test/config/install/block.block.foobar_gorilla.yml @@ -16,8 +16,6 @@ settings: label: 'Foobar Gorilla' label_display: visible provider: block_content - status: true - info: '' view_mode: full visibility: request_path: 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/BlockContentTestBase.php b/core/modules/block_content/tests/src/Functional/BlockContentTestBase.php index d0a3794b978..ffee97e4679 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentTestBase.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentTestBase.php @@ -4,8 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\block_content\Functional; -use Drupal\block_content\Entity\BlockContent; -use Drupal\block_content\Entity\BlockContentType; +use Drupal\Tests\block_content\Traits\BlockContentCreationTrait; use Drupal\Tests\BrowserTestBase; /** @@ -13,6 +12,8 @@ use Drupal\Tests\BrowserTestBase; */ abstract class BlockContentTestBase extends BrowserTestBase { + use BlockContentCreationTrait; + /** * Profile to use. * @@ -64,80 +65,4 @@ abstract class BlockContentTestBase extends BrowserTestBase { $this->drupalPlaceBlock('local_actions_block'); } - /** - * Creates a content block. - * - * @param bool|string $title - * (optional) Title of block. When no value is given uses a random name. - * Defaults to FALSE. - * @param string $bundle - * (optional) Bundle name. Defaults to 'basic'. - * @param bool $save - * (optional) Whether to save the block. Defaults to TRUE. - * - * @return \Drupal\block_content\Entity\BlockContent - * Created content block. - */ - protected function createBlockContent($title = FALSE, $bundle = 'basic', $save = TRUE) { - $title = $title ?: $this->randomMachineName(); - $block_content = BlockContent::create([ - 'info' => $title, - 'type' => $bundle, - 'langcode' => 'en', - ]); - if ($block_content && $save === TRUE) { - $block_content->save(); - } - return $block_content; - } - - /** - * Creates a block type (bundle). - * - * @param array|string $values - * (deprecated) The variable $values as string is deprecated. Provide as an - * array as parameter. The value to create the block content type. If - * $values is an array it should be like: ['id' => 'foo', 'label' => 'Foo']. - * If $values is a string, it will be considered that it represents the - * label. - * @param bool $create_body - * Whether or not to create the body field. - * - * @return \Drupal\block_content\Entity\BlockContentType - * Created block type. - */ - protected function createBlockContentType($values, $create_body = FALSE) { - if (is_string($values)) { - @trigger_error('Using the variable $values as string is deprecated in drupal:11.1.0 and is removed from drupal:12.0.0. Provide an array as parameter. See https://www.drupal.org/node/3473739', E_USER_DEPRECATED); - } - if (is_array($values)) { - if (!isset($values['id'])) { - do { - $id = $this->randomMachineName(8); - } while (BlockContentType::load($id)); - } - else { - $id = $values['id']; - } - $values += [ - 'id' => $id, - 'label' => $id, - 'revision' => FALSE, - ]; - $bundle = BlockContentType::create($values); - } - else { - $bundle = BlockContentType::create([ - 'id' => $values, - 'label' => $values, - 'revision' => FALSE, - ]); - } - $bundle->save(); - if ($create_body) { - block_content_add_body_field($bundle->id()); - } - return $bundle; - } - } diff --git a/core/modules/block_content/tests/src/Functional/BlockContentTypeTest.php b/core/modules/block_content/tests/src/Functional/BlockContentTypeTest.php index fba8362cd2c..2ec82c2195b 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentTypeTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentTypeTest.php @@ -58,14 +58,26 @@ class BlockContentTypeTest extends BlockContentTestBase { } /** - * Tests the order of the block content types on the add page. + * Tests the block types on the block/add page. */ - public function testBlockContentAddPageOrder(): void { - $this->createBlockContentType(['id' => 'bundle_1', 'label' => 'Bundle 1']); - $this->createBlockContentType(['id' => 'bundle_2', 'label' => 'Aaa Bundle 2']); + public function testBlockContentAddPage(): void { + $this->createBlockContentType([ + 'id' => 'bundle_1', + 'label' => 'Bundle 1', + 'description' => 'Bundle 1 description', + ]); + $this->createBlockContentType([ + 'id' => 'bundle_2', + 'label' => 'Aaa Bundle 2', + 'description' => 'Bundle 2 description', + ]); $this->drupalLogin($this->adminUser); $this->drupalGet('block/add'); + // Ensure bundles are ordered by their label, not id. $this->assertSession()->pageTextMatches('/Aaa Bundle 2(.*)Bundle 1/'); + // Block type descriptions should display. + $this->assertSession()->pageTextContains('Bundle 1 description'); + $this->assertSession()->pageTextContains('Bundle 2 description'); } /** 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/block_content/tests/src/Functional/Update/BlockContentStatusInfoUpdatePathTest.php b/core/modules/block_content/tests/src/Functional/Update/BlockContentStatusInfoUpdatePathTest.php new file mode 100644 index 00000000000..fb4bff9e63b --- /dev/null +++ b/core/modules/block_content/tests/src/Functional/Update/BlockContentStatusInfoUpdatePathTest.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\block_content\Functional\Update; + +use Drupal\block\Entity\Block; +use Drupal\FunctionalTests\Update\UpdatePathTestBase; + +// cspell:ignore anotherblock + +/** + * Tests block_content_post_update_remove_block_content_status_info_keys. + * + * @group block_content + */ +class BlockContentStatusInfoUpdatePathTest extends UpdatePathTestBase { + + /** + * {@inheritdoc} + */ + protected function setDatabaseDumpFiles(): void { + $this->databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz', + ]; + } + + /** + * Tests block_content_post_update_remove_block_content_status_info_keys. + */ + public function testRunUpdates(): void { + $this->assertArrayHasKey('info', Block::load('anotherblock')->get('settings')); + $this->assertArrayHasKey('status', Block::load('anotherblock')->get('settings')); + + $this->runUpdates(); + + $this->assertArrayNotHasKey('info', Block::load('anotherblock')->get('settings')); + $this->assertArrayNotHasKey('status', Block::load('anotherblock')->get('settings')); + } + +} diff --git a/core/modules/block_content/tests/src/Functional/Views/BlockContentTestBase.php b/core/modules/block_content/tests/src/Functional/Views/BlockContentTestBase.php index 1b8af1195ee..04c54c637d1 100644 --- a/core/modules/block_content/tests/src/Functional/Views/BlockContentTestBase.php +++ b/core/modules/block_content/tests/src/Functional/Views/BlockContentTestBase.php @@ -4,8 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\block_content\Functional\Views; -use Drupal\block_content\Entity\BlockContent; -use Drupal\block_content\Entity\BlockContentType; +use Drupal\Tests\block_content\Traits\BlockContentCreationTrait; use Drupal\Tests\views\Functional\ViewTestBase; /** @@ -13,6 +12,11 @@ use Drupal\Tests\views\Functional\ViewTestBase; */ abstract class BlockContentTestBase extends ViewTestBase { + use BlockContentCreationTrait { + createBlockContent as baseCreateBlockContent; + createBlockContentType as baseCreateBlockContentType; + } + /** * Admin user. * @@ -67,7 +71,7 @@ abstract class BlockContentTestBase extends ViewTestBase { 'type' => 'basic', 'langcode' => 'en', ]; - if ($block_content = BlockContent::create($values)) { + if ($block_content = $this->baseCreateBlockContent(save: FALSE, values: $values)) { $status = $block_content->save(); } $this->assertEquals(SAVED_NEW, $status, "Created block content {$block_content->label()}."); @@ -84,26 +88,7 @@ abstract class BlockContentTestBase extends ViewTestBase { * Created block type. */ protected function createBlockContentType(array $values = []) { - // Find a non-existent random type name. - if (!isset($values['id'])) { - do { - $id = $this->randomMachineName(8); - } while (BlockContentType::load($id)); - } - else { - $id = $values['id']; - } - $values += [ - 'id' => $id, - 'label' => $id, - 'revision' => FALSE, - ]; - $bundle = BlockContentType::create($values); - $status = $bundle->save(); - block_content_add_body_field($bundle->id()); - - $this->assertEquals(SAVED_NEW, $status, sprintf('Created block content type %s.', $bundle->id())); - return $bundle; + return $this->baseCreateBlockContentType($values, TRUE); } } diff --git a/core/modules/block_content/tests/src/Kernel/BlockContentTest.php b/core/modules/block_content/tests/src/Kernel/BlockContentTest.php index bb0186431e9..fe07b3d5652 100644 --- a/core/modules/block_content/tests/src/Kernel/BlockContentTest.php +++ b/core/modules/block_content/tests/src/Kernel/BlockContentTest.php @@ -38,6 +38,19 @@ class BlockContentTest extends KernelTestBase { } /** + * Tests BlockContentType functionality. + */ + public function testBlockContentType(): void { + $type = BlockContentType::create([ + 'id' => 'foo', + 'label' => 'Foo', + ]); + $this->assertSame('', $type->getDescription()); + $type->setDescription('Test description'); + $this->assertSame('Test description', $type->getDescription()); + } + + /** * Tests the editing links for BlockContentBlock. */ public function testOperationLinks(): void { diff --git a/core/modules/block_content/tests/src/Traits/BlockContentCreationTrait.php b/core/modules/block_content/tests/src/Traits/BlockContentCreationTrait.php new file mode 100644 index 00000000000..0322a3ebf17 --- /dev/null +++ b/core/modules/block_content/tests/src/Traits/BlockContentCreationTrait.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\block_content\Traits; + +use Drupal\block_content\Entity\BlockContent; +use Drupal\block_content\Entity\BlockContentType; + +/** + * Provides methods for creating block_content entities and types. + */ +trait BlockContentCreationTrait { + + /** + * Creates a content block. + * + * @param bool|string $title + * (optional) Title of block. When no value is given uses a random name. + * Defaults to FALSE. + * @param string $bundle + * (optional) Bundle name. Defaults to 'basic'. + * @param bool $save + * (optional) Whether to save the block. Defaults to TRUE. + * @param array $values + * (optional) Additional values for the block_content entity. + * + * @return \Drupal\block_content\Entity\BlockContent + * Created content block. + */ + protected function createBlockContent(bool|string $title = FALSE, string $bundle = 'basic', bool $save = TRUE, array $values = []): BlockContent { + $title = $title ?: $this->randomMachineName(); + $values += [ + 'info' => $title, + 'type' => $bundle, + 'langcode' => 'en', + ]; + $block_content = BlockContent::create($values); + if ($block_content && $save === TRUE) { + $block_content->save(); + } + return $block_content; + } + + /** + * Creates a block type (bundle). + * + * @param array|string $values + * (deprecated) The variable $values as string is deprecated. Provide as an + * array as parameter. The value to create the block content type. If + * $values is an array it should be like: ['id' => 'foo', 'label' => 'Foo']. + * If $values is a string, it will be considered that it represents the + * label. + * @param bool $create_body + * Whether or not to create the body field. + * + * @return \Drupal\block_content\Entity\BlockContentType + * Created block type. + */ + protected function createBlockContentType(array|string $values, bool $create_body = FALSE): BlockContentType { + if (is_string($values)) { + @trigger_error('Using the variable $values as string is deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. Provide an array as parameter. See https://www.drupal.org/node/3473739', E_USER_DEPRECATED); + } + if (is_array($values)) { + if (!isset($values['id'])) { + do { + $id = $this->randomMachineName(8); + } while (BlockContentType::load($id)); + } + else { + $id = $values['id']; + } + $values += [ + 'id' => $id, + 'label' => $id, + 'revision' => FALSE, + ]; + $bundle = BlockContentType::create($values); + } + else { + $bundle = BlockContentType::create([ + 'id' => $values, + 'label' => $values, + 'revision' => FALSE, + ]); + } + $bundle->save(); + if ($create_body) { + block_content_add_body_field($bundle->id()); + } + return $bundle; + } + +} diff --git a/core/modules/block_content/tests/src/Unit/Access/AccessGroupAndTest.php b/core/modules/block_content/tests/src/Unit/Access/AccessGroupAndTest.php deleted file mode 100644 index b9fd73b49fa..00000000000 --- a/core/modules/block_content/tests/src/Unit/Access/AccessGroupAndTest.php +++ /dev/null @@ -1,57 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\block_content\Unit\Access; - -use Drupal\block_content\Access\AccessGroupAnd; -use Drupal\Core\Access\AccessResult; -use Drupal\Core\Session\AccountInterface; -use Drupal\Tests\UnitTestCase; - -/** - * Tests accessible groups. - * - * @group block_content - */ -class AccessGroupAndTest extends UnitTestCase { - - use AccessibleTestingTrait; - - /** - * {@inheritdoc} - */ - protected function setUp(): void { - parent::setUp(); - $this->account = $this->prophesize(AccountInterface::class)->reveal(); - } - - /** - * @covers \Drupal\block_content\Access\AccessGroupAnd - */ - public function testGroups(): void { - $allowedAccessible = $this->createAccessibleDouble(AccessResult::allowed()); - $forbiddenAccessible = $this->createAccessibleDouble(AccessResult::forbidden()); - $neutralAccessible = $this->createAccessibleDouble(AccessResult::neutral()); - - // Ensure that groups with no dependencies return a neutral access result. - $this->assertTrue((new AccessGroupAnd())->access('view', $this->account, TRUE)->isNeutral()); - - $andNeutral = new AccessGroupAnd(); - $andNeutral->addDependency($allowedAccessible)->addDependency($neutralAccessible); - $this->assertTrue($andNeutral->access('view', $this->account, TRUE)->isNeutral()); - - $andForbidden = $andNeutral; - $andForbidden->addDependency($forbiddenAccessible); - $this->assertTrue($andForbidden->access('view', $this->account, TRUE)->isForbidden()); - - // Ensure that groups added to other groups works. - $andGroupsForbidden = new AccessGroupAnd(); - $andGroupsForbidden->addDependency($andNeutral)->addDependency($andForbidden); - $this->assertTrue($andGroupsForbidden->access('view', $this->account, TRUE)->isForbidden()); - // Ensure you can add a non-group accessible object. - $andGroupsForbidden->addDependency($allowedAccessible); - $this->assertTrue($andGroupsForbidden->access('view', $this->account, TRUE)->isForbidden()); - } - -} diff --git a/core/modules/block_content/tests/src/Unit/Access/AccessibleTestingTrait.php b/core/modules/block_content/tests/src/Unit/Access/AccessibleTestingTrait.php deleted file mode 100644 index a407e238875..00000000000 --- a/core/modules/block_content/tests/src/Unit/Access/AccessibleTestingTrait.php +++ /dev/null @@ -1,38 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\block_content\Unit\Access; - -use Drupal\Core\Access\AccessibleInterface; -use Drupal\Core\Access\AccessResultInterface; - -/** - * Helper methods testing accessible interfaces. - */ -trait AccessibleTestingTrait { - - /** - * The test account. - * - * @var \Drupal\Core\Session\AccountInterface - */ - protected $account; - - /** - * Creates AccessibleInterface object from access result object for testing. - * - * @param \Drupal\Core\Access\AccessResultInterface $accessResult - * The accessible result to return. - * - * @return \Drupal\Core\Access\AccessibleInterface - * The AccessibleInterface object. - */ - private function createAccessibleDouble(AccessResultInterface $accessResult) { - $accessible = $this->prophesize(AccessibleInterface::class); - $accessible->access('view', $this->account, TRUE) - ->willReturn($accessResult); - return $accessible->reveal(); - } - -} diff --git a/core/modules/block_content/tests/src/Unit/Access/DependentAccessTest.php b/core/modules/block_content/tests/src/Unit/Access/DependentAccessTest.php deleted file mode 100644 index 55c9a89a10c..00000000000 --- a/core/modules/block_content/tests/src/Unit/Access/DependentAccessTest.php +++ /dev/null @@ -1,161 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\block_content\Unit\Access; - -use Drupal\block_content\Access\AccessGroupAnd; -use Drupal\Core\Access\AccessResult; -use Drupal\block_content\Access\RefinableDependentAccessInterface; -use Drupal\block_content\Access\RefinableDependentAccessTrait; -use Drupal\Core\Session\AccountInterface; -use Drupal\Tests\UnitTestCase; - -/** - * @coversDefaultClass \Drupal\block_content\Access\RefinableDependentAccessTrait - * - * @group block_content - */ -class DependentAccessTest extends UnitTestCase { - use AccessibleTestingTrait; - - /** - * An accessible object that results in forbidden access result. - * - * @var \Drupal\Core\Access\AccessibleInterface - */ - protected $forbidden; - - /** - * An accessible object that results in neutral access result. - * - * @var \Drupal\Core\Access\AccessibleInterface - */ - protected $neutral; - - /** - * {@inheritdoc} - */ - protected function setUp(): void { - parent::setUp(); - $this->account = $this->prophesize(AccountInterface::class)->reveal(); - $this->forbidden = $this->createAccessibleDouble(AccessResult::forbidden('Because I said so')); - $this->neutral = $this->createAccessibleDouble(AccessResult::neutral('I have no opinion')); - } - - /** - * Tests that the previous dependency is replaced when using set. - * - * @covers ::setAccessDependency - * - * @dataProvider providerTestSetFirst - */ - public function testSetAccessDependency($use_set_first): void { - $testRefinable = new RefinableDependentAccessTraitTestClass(); - - if ($use_set_first) { - $testRefinable->setAccessDependency($this->forbidden); - } - else { - $testRefinable->addAccessDependency($this->forbidden); - } - $accessResult = $testRefinable->getAccessDependency()->access('view', $this->account, TRUE); - $this->assertTrue($accessResult->isForbidden()); - $this->assertEquals('Because I said so', $accessResult->getReason()); - - // Calling setAccessDependency() replaces the existing dependency. - $testRefinable->setAccessDependency($this->neutral); - $dependency = $testRefinable->getAccessDependency(); - $this->assertNotInstanceOf(AccessGroupAnd::class, $dependency); - $accessResult = $dependency->access('view', $this->account, TRUE); - $this->assertTrue($accessResult->isNeutral()); - $this->assertEquals('I have no opinion', $accessResult->getReason()); - } - - /** - * Tests merging a new dependency with existing non-group access dependency. - * - * @dataProvider providerTestSetFirst - */ - public function testMergeNonGroup($use_set_first): void { - $testRefinable = new RefinableDependentAccessTraitTestClass(); - if ($use_set_first) { - $testRefinable->setAccessDependency($this->forbidden); - } - else { - $testRefinable->addAccessDependency($this->forbidden); - } - - $accessResult = $testRefinable->getAccessDependency()->access('view', $this->account, TRUE); - $this->assertTrue($accessResult->isForbidden()); - $this->assertEquals('Because I said so', $accessResult->getReason()); - - $testRefinable->addAccessDependency($this->neutral); - /** @var \Drupal\block_content\Access\AccessGroupAnd $dependency */ - $dependency = $testRefinable->getAccessDependency(); - // Ensure the new dependency create a new AND group when merged. - $this->assertInstanceOf(AccessGroupAnd::class, $dependency); - $dependencies = $dependency->getDependencies(); - $accessResultForbidden = $dependencies[0]->access('view', $this->account, TRUE); - $this->assertTrue($accessResultForbidden->isForbidden()); - $this->assertEquals('Because I said so', $accessResultForbidden->getReason()); - $accessResultNeutral = $dependencies[1]->access('view', $this->account, TRUE); - $this->assertTrue($accessResultNeutral->isNeutral()); - $this->assertEquals('I have no opinion', $accessResultNeutral->getReason()); - } - - /** - * Tests merging a new dependency with an existing access group dependency. - * - * @dataProvider providerTestSetFirst - */ - public function testMergeGroup($use_set_first): void { - $andGroup = new AccessGroupAnd(); - $andGroup->addDependency($this->forbidden); - $testRefinable = new RefinableDependentAccessTraitTestClass(); - if ($use_set_first) { - $testRefinable->setAccessDependency($andGroup); - } - else { - $testRefinable->addAccessDependency($andGroup); - } - - $testRefinable->addAccessDependency($this->neutral); - /** @var \Drupal\block_content\Access\AccessGroupAnd $dependency */ - $dependency = $testRefinable->getAccessDependency(); - - // Ensure the new dependency is merged with the existing group. - $this->assertInstanceOf(AccessGroupAnd::class, $dependency); - $dependencies = $dependency->getDependencies(); - $accessResultForbidden = $dependencies[0]->access('view', $this->account, TRUE); - $this->assertTrue($accessResultForbidden->isForbidden()); - $this->assertEquals('Because I said so', $accessResultForbidden->getReason()); - $accessResultNeutral = $dependencies[1]->access('view', $this->account, TRUE); - $this->assertTrue($accessResultNeutral->isNeutral()); - $this->assertEquals('I have no opinion', $accessResultNeutral->getReason()); - } - - /** - * Data provider for all test methods. - * - * Provides test cases for calling setAccessDependency() or - * mergeAccessDependency() first. A call to either should behave the same on a - * new RefinableDependentAccessInterface object. - */ - public static function providerTestSetFirst() { - return [ - [TRUE], - [FALSE], - ]; - } - -} - -/** - * Test class that implements RefinableDependentAccessInterface. - */ -class RefinableDependentAccessTraitTestClass implements RefinableDependentAccessInterface { - - use RefinableDependentAccessTrait; - -} diff --git a/core/modules/ckeditor5/ckeditor5.ckeditor5.yml b/core/modules/ckeditor5/ckeditor5.ckeditor5.yml index 2a2c3fd1658..7624fbfd708 100644 --- a/core/modules/ckeditor5/ckeditor5.ckeditor5.yml +++ b/core/modules/ckeditor5/ckeditor5.ckeditor5.yml @@ -771,25 +771,25 @@ media_mediaAlign: align: - name: 'right' title: 'Align right and wrap text' - icon: 'objectRight' + icon: 'IconObjectInlineRight' attributeName: 'data-align' attributeValue: 'right' modelElements: [ 'drupalMedia' ] - name: 'left' title: 'Align left and wrap text' - icon: 'objectLeft' + icon: 'IconObjectInlineLeft' attributeName: 'data-align' attributeValue: 'left' modelElements: [ 'drupalMedia' ] - name: 'center' title: 'Align center and break text' - icon: 'objectCenter' + icon: 'IconObjectCenter' attributeName: 'data-align' attributeValue: 'center' modelElements: ['drupalMedia'] - name: 'breakText' title: 'Break text' - icon: 'objectBlockLeft' + icon: 'IconObjectLeft' isDefault: true modelElements: [ 'drupalMedia' ] drupalMedia: diff --git a/core/modules/ckeditor5/css/drupalmedia.css b/core/modules/ckeditor5/css/drupalmedia.css index 07ce32c8fed..4dd9a4e3afa 100644 --- a/core/modules/ckeditor5/css/drupalmedia.css +++ b/core/modules/ckeditor5/css/drupalmedia.css @@ -28,7 +28,7 @@ display: table-caption; padding: 0.6em; caption-side: bottom; - word-break: break-word; + overflow-wrap: break-word; color: hsl(0, 0%, 20%); outline-offset: -1px; background-color: hsl(0, 0%, 97%); diff --git a/core/modules/ckeditor5/js/build/ckeditor5.types.jsdoc b/core/modules/ckeditor5/js/build/ckeditor5.types.jsdoc index aef00a7595e..f6d2bdb0da8 100644 --- a/core/modules/ckeditor5/js/build/ckeditor5.types.jsdoc +++ b/core/modules/ckeditor5/js/build/ckeditor5.types.jsdoc @@ -209,18 +209,6 @@ */ /** - * Declared in file @ckeditor/ckeditor5-bookmark/src/bookmarkui.js - * - * @typedef {module:bookmark/bookmarkui} module:bookmark/bookmarkui~BookmarkUI - */ - -/** - * Declared in file @ckeditor/ckeditor5-bookmark/src/ui/bookmarkactionsview.js - * - * @typedef {module:bookmark/ui/bookmarkactionsview} module:bookmark/ui/bookmarkactionsview~BookmarkActionsView - */ - -/** * Declared in file @ckeditor/ckeditor5-bookmark/src/ui/bookmarkformview.js * * @typedef {module:bookmark/ui/bookmarkformview} module:bookmark/ui/bookmarkformview~BookmarkFormView @@ -575,6 +563,84 @@ */ /** + * Declared in file @ckeditor/ckeditor5-emoji/src/emoji.js + * + * @typedef {module:emoji/emoji} module:emoji/emoji~Emoji + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/emojicommand.js + * + * @typedef {module:emoji/emojicommand} module:emoji/emojicommand~EmojiCommand + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/emojimention.js + * + * @typedef {module:emoji/emojimention} module:emoji/emojimention~EmojiMention + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/emojipicker.js + * + * @typedef {module:emoji/emojipicker} module:emoji/emojipicker~EmojiPicker + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/emojirepository.js + * + * @typedef {module:emoji/emojirepository} module:emoji/emojirepository~EmojiRepository + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/emojiutils.js + * + * @typedef {module:emoji/emojiutils} module:emoji/emojiutils~EmojiUtils + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/ui/emojicategoriesview.js + * + * @typedef {module:emoji/ui/emojicategoriesview} module:emoji/ui/emojicategoriesview~EmojiCategoriesView + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/ui/emojigridview.js + * + * @typedef {module:emoji/ui/emojigridview} module:emoji/ui/emojigridview~EmojiGridView + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/ui/emojipickerformview.js + * + * @typedef {module:emoji/ui/emojipickerformview} module:emoji/ui/emojipickerformview~EmojiPickerFormView + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/ui/emojipickerview.js + * + * @typedef {module:emoji/ui/emojipickerview} module:emoji/ui/emojipickerview~EmojiPickerView + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/ui/emojisearchview.js + * + * @typedef {module:emoji/ui/emojisearchview} module:emoji/ui/emojisearchview~EmojiSearchView + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/ui/emojitoneview.js + * + * @typedef {module:emoji/ui/emojitoneview} module:emoji/ui/emojitoneview~EmojiToneView + */ + +/** + * Declared in file @ckeditor/ckeditor5-emoji/src/utils/isemojisupported.js + * + * @typedef {module:emoji/utils/isemojisupported} module:emoji/utils/isemojisupported~isEmojiSupported + */ + +/** * Declared in file @ckeditor/ckeditor5-engine/src/controller/datacontroller.js * * @typedef {module:engine/controller/datacontroller} module:engine/controller/datacontroller~DataController @@ -851,12 +917,6 @@ */ /** - * Declared in file @ckeditor/ckeditor5-engine/src/model/treewalker.js - * - * @typedef {module:engine/model/treewalker} module:engine/model/treewalker~TreeWalker - */ - -/** * Declared in file @ckeditor/ckeditor5-engine/src/model/utils/deletecontent.js * * @typedef {module:engine/model/utils/deletecontent} module:engine/model/utils/deletecontent~deleteContent @@ -1043,6 +1103,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-engine/src/view/observer/touchobserver.js + * + * @typedef {module:engine/view/observer/touchobserver} module:engine/view/observer/touchobserver~TouchObserver + */ + +/** * Declared in file @ckeditor/ckeditor5-engine/src/view/position.js * * @typedef {module:engine/view/position} module:engine/view/position~Position @@ -1097,9 +1163,9 @@ */ /** - * Declared in file @ckeditor/ckeditor5-engine/src/view/treewalker.js + * Declared in file @ckeditor/ckeditor5-engine/src/view/tokenlist.js * - * @typedef {module:engine/view/treewalker} module:engine/view/treewalker~TreeWalker + * @typedef {module:engine/view/tokenlist} module:engine/view/tokenlist~TokenList */ /** @@ -1301,6 +1367,42 @@ */ /** + * Declared in file @ckeditor/ckeditor5-fullscreen/src/fullscreen.js + * + * @typedef {module:fullscreen/fullscreen} module:fullscreen/fullscreen~Fullscreen + */ + +/** + * Declared in file @ckeditor/ckeditor5-fullscreen/src/fullscreencommand.js + * + * @typedef {module:fullscreen/fullscreencommand} module:fullscreen/fullscreencommand~FullscreenCommand + */ + +/** + * Declared in file @ckeditor/ckeditor5-fullscreen/src/fullscreenediting.js + * + * @typedef {module:fullscreen/fullscreenediting} module:fullscreen/fullscreenediting~FullscreenEditing + */ + +/** + * Declared in file @ckeditor/ckeditor5-fullscreen/src/fullscreenui.js + * + * @typedef {module:fullscreen/fullscreenui} module:fullscreen/fullscreenui~FullscreenUI + */ + +/** + * Declared in file @ckeditor/ckeditor5-fullscreen/src/handlers/abstracteditorhandler.js + * + * @typedef {module:fullscreen/handlers/abstracteditorhandler} module:fullscreen/handlers/abstracteditorhandler~AbstractEditorHandler + */ + +/** + * Declared in file @ckeditor/ckeditor5-fullscreen/src/handlers/classiceditorhandler.js + * + * @typedef {module:fullscreen/handlers/classiceditorhandler} module:fullscreen/handlers/classiceditorhandler~ClassicEditorHandler + */ + +/** * Declared in file @ckeditor/ckeditor5-heading/src/heading.js * * @typedef {module:heading/heading} module:heading/heading~Heading @@ -1439,6 +1541,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-html-support/src/integrations/horizontalline.js + * + * @typedef {module:html-support/integrations/horizontalline} module:html-support/integrations/horizontalline~HorizontalLineElementSupport + */ + +/** * Declared in file @ckeditor/ckeditor5-html-support/src/integrations/image.js * * @typedef {module:html-support/integrations/image} module:html-support/integrations/image~ImageElementSupport @@ -1805,9 +1913,9 @@ */ /** - * Declared in file @ckeditor/ckeditor5-link/src/ui/linkactionsview.js + * Declared in file @ckeditor/ckeditor5-link/src/ui/linkbuttonview.js * - * @typedef {module:link/ui/linkactionsview} module:link/ui/linkactionsview~LinkActionsView + * @typedef {module:link/ui/linkbuttonview} module:link/ui/linkbuttonview~LinkButtonView */ /** @@ -1817,6 +1925,24 @@ */ /** + * Declared in file @ckeditor/ckeditor5-link/src/ui/linkpreviewbuttonview.js + * + * @typedef {module:link/ui/linkpreviewbuttonview} module:link/ui/linkpreviewbuttonview~LinkPreviewButtonView + */ + +/** + * Declared in file @ckeditor/ckeditor5-link/src/ui/linkpropertiesview.js + * + * @typedef {module:link/ui/linkpropertiesview} module:link/ui/linkpropertiesview~LinkPropertiesView + */ + +/** + * Declared in file @ckeditor/ckeditor5-link/src/ui/linkprovideritemsview.js + * + * @typedef {module:link/ui/linkprovideritemsview} module:link/ui/linkprovideritemsview~LinkProviderItemsView + */ + +/** * Declared in file @ckeditor/ckeditor5-link/src/unlinkcommand.js * * @typedef {module:link/unlinkcommand} module:link/unlinkcommand~UnlinkCommand @@ -2069,6 +2195,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-media-embed/src/mediaregistry.js + * + * @typedef {module:media-embed/mediaregistry} module:media-embed/mediaregistry~MediaRegistry + */ + +/** * Declared in file @ckeditor/ckeditor5-media-embed/src/ui/mediaformview.js * * @typedef {module:media-embed/ui/mediaformview} module:media-embed/ui/mediaformview~MediaFormView @@ -2477,6 +2609,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-table/src/commands/inserttablelayoutcommand.js + * + * @typedef {module:table/commands/inserttablelayoutcommand} module:table/commands/inserttablelayoutcommand~InsertTableLayoutCommand + */ + +/** * Declared in file @ckeditor/ckeditor5-table/src/commands/removecolumncommand.js * * @typedef {module:table/commands/removecolumncommand} module:table/commands/removecolumncommand~RemoveColumnCommand @@ -2615,6 +2753,30 @@ */ /** + * Declared in file @ckeditor/ckeditor5-table/src/tablelayout.js + * + * @typedef {module:table/tablelayout} module:table/tablelayout~TableLayout + */ + +/** + * Declared in file @ckeditor/ckeditor5-table/src/tablelayout/commands/tabletypecommand.js + * + * @typedef {module:table/tablelayout/commands/tabletypecommand} module:table/tablelayout/commands/tabletypecommand~TableTypeCommand + */ + +/** + * Declared in file @ckeditor/ckeditor5-table/src/tablelayout/tablelayoutediting.js + * + * @typedef {module:table/tablelayout/tablelayoutediting} module:table/tablelayout/tablelayoutediting~TableLayoutEditing + */ + +/** + * Declared in file @ckeditor/ckeditor5-table/src/tablelayout/tablelayoutui.js + * + * @typedef {module:table/tablelayout/tablelayoutui} module:table/tablelayout/tablelayoutui~TableLayoutUI + */ + +/** * Declared in file @ckeditor/ckeditor5-table/src/tablemouse.js * * @typedef {module:table/tablemouse} module:table/tablemouse~TableMouse @@ -2693,12 +2855,6 @@ */ /** - * Declared in file @ckeditor/ckeditor5-table/src/ui/formrowview.js - * - * @typedef {module:table/ui/formrowview} module:table/ui/formrowview~FormRowView - */ - -/** * Declared in file @ckeditor/ckeditor5-table/src/ui/inserttableview.js * * @typedef {module:table/ui/inserttableview} module:table/ui/inserttableview~InsertTableView @@ -2801,6 +2957,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-ui/src/collapsible/collapsibleview.js + * + * @typedef {module:ui/collapsible/collapsibleview} module:ui/collapsible/collapsibleview~CollapsibleView + */ + +/** * Declared in file @ckeditor/ckeditor5-ui/src/colorgrid/colorgridview.js * * @typedef {module:ui/colorgrid/colorgridview} module:ui/colorgrid/colorgridview~ColorGridView @@ -2951,6 +3113,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-ui/src/editorui/poweredby.js + * + * @typedef {module:ui/editorui/poweredby} module:ui/editorui/poweredby~PoweredBy + */ + +/** * Declared in file @ckeditor/ckeditor5-ui/src/focuscycler.js * * @typedef {module:ui/focuscycler} module:ui/focuscycler~FocusCycler @@ -2963,6 +3131,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-ui/src/formrow/formrowview.js + * + * @typedef {module:ui/formrow/formrowview} module:ui/formrow/formrowview~FormRowView + */ + +/** * Declared in file @ckeditor/ckeditor5-ui/src/highlightedtext/highlightedtextview.js * * @typedef {module:ui/highlightedtext/highlightedtextview} module:ui/highlightedtext/highlightedtextview~HighlightedTextView @@ -3275,6 +3449,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-utils/src/collectstylesheets.js + * + * @typedef {module:utils/collectstylesheets} module:utils/collectstylesheets~async + */ + +/** * Declared in file @ckeditor/ckeditor5-utils/src/comparearrays.js * * @typedef {module:utils/comparearrays} module:utils/comparearrays~compareArrays @@ -3371,6 +3551,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-utils/src/dom/getvisualviewportoffset.js + * + * @typedef {module:utils/dom/getvisualviewportoffset} module:utils/dom/getvisualviewportoffset~getVisualViewportOffset + */ + +/** * Declared in file @ckeditor/ckeditor5-utils/src/dom/indexof.js * * @typedef {module:utils/dom/indexof} module:utils/dom/indexof~indexOf @@ -3497,6 +3683,12 @@ */ /** + * Declared in file @ckeditor/ckeditor5-utils/src/formathtml.js + * + * @typedef {module:utils/formathtml} module:utils/formathtml~formatHtml + */ + +/** * Declared in file @ckeditor/ckeditor5-utils/src/isiterable.js * * @typedef {module:utils/isiterable} module:utils/isiterable~isIterable @@ -3593,6 +3785,18 @@ */ /** + * Declared in file @ckeditor/ckeditor5-watchdog/src/contextwatchdog.js + * + * @typedef {module:watchdog/contextwatchdog} module:watchdog/contextwatchdog~ContextWatchdog + */ + +/** + * Declared in file @ckeditor/ckeditor5-watchdog/src/editorwatchdog.js + * + * @typedef {module:watchdog/editorwatchdog} module:watchdog/editorwatchdog~EditorWatchdog + */ + +/** * Declared in file @ckeditor/ckeditor5-watchdog/src/utils/areconnectedthroughproperties.js * * @typedef {module:watchdog/utils/areconnectedthroughproperties} module:watchdog/utils/areconnectedthroughproperties~areConnectedThroughProperties diff --git a/core/modules/ckeditor5/js/build/drupalImage.js b/core/modules/ckeditor5/js/build/drupalImage.js index 9356c590f56..6ad80b095ea 100644 --- a/core/modules/ckeditor5/js/build/drupalImage.js +++ b/core/modules/ckeditor5/js/build/drupalImage.js @@ -1 +1 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.drupalImage=t())}(globalThis,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/engine.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/engine.js")},"ckeditor5/src/ui.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/ui.js")},"ckeditor5/src/upload.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/upload.js")},"ckeditor5/src/utils.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/utils.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function i(r){var s=t[r];if(void 0!==s)return s.exports;var n=t[r]={exports:{}};return e[r](n,n.exports,i),n.exports}i.d=(e,t)=>{for(var r in t)i.o(t,r)&&!i.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var r={};return(()=>{"use strict";i.d(r,{default:()=>B});var e=i("ckeditor5/src/core.js");function t(e,t,i){if(t.attributes)for(const[r,s]of Object.entries(t.attributes))e.setAttribute(r,s,i);t.styles&&e.setStyle(t.styles,i),t.classes&&e.addClass(t.classes,i)}var s=i("ckeditor5/src/engine.js");class n extends s.Observer{observe(e){this.listenTo(e,"load",((e,t)=>{const i=t.target;this.checkShouldIgnoreEventFromTarget(i)||"IMG"==i.tagName&&this._fireEvents(t)}),{useCapture:!0})}stopObserving(e){this.stopListening(e)}_fireEvents(e){this.isEnabled&&(this.document.fire("layoutChanged"),this.document.fire("imageLoaded",e))}}function o(e){return e.createEmptyElement("img")}function a(e){const t=parseFloat(e);return!Number.isNaN(t)&&e===String(t)}function l(e){return"string"==typeof e&&e.endsWith("%")?e:`${parseInt(e,10)}`}const u=[{modelValue:"alignCenter",dataValue:"center"},{modelValue:"alignRight",dataValue:"right"},{modelValue:"alignLeft",dataValue:"left"}];class d extends e.Plugin{static get requires(){return["ImageUtils"]}static get pluginName(){return"DrupalImageEditing"}init(){const{editor:e}=this,{conversion:i}=e,{schema:r}=e.model;if(r.isRegistered("imageInline")&&r.extend("imageInline",{allowAttributes:["dataEntityUuid","dataEntityType","isDecorative"]}),r.isRegistered("imageBlock")&&r.extend("imageBlock",{allowAttributes:["dataEntityUuid","dataEntityType","isDecorative"]}),i.for("upcast").add(function(e){function t(t,i,r){const{viewItem:s}=i,{writer:n,consumable:o,safeInsert:a,updateConversionResult:l,schema:d}=r,c=[];let m;if(!o.test(s,{name:!0,attributes:"src"}))return;const g=o.test(s,{name:!0,attributes:"data-caption"});if(m=d.checkChild(i.modelCursor,"imageInline")&&!g?n.createElement("imageInline",{src:s.getAttribute("src")}):n.createElement("imageBlock",{src:s.getAttribute("src")}),e.plugins.has("ImageStyleEditing")&&o.test(s,{name:!0,attributes:"data-align"})){const e=s.getAttribute("data-align"),t=u.find((t=>t.dataValue===e));t&&(n.setAttribute("imageStyle",t.modelValue,m),c.push("data-align"))}if(g){const t=n.createElement("caption"),i=e.data.processor.toView(s.getAttribute("data-caption"));r.consumable.constructor.createFrom(i,r.consumable),r.convertChildren(i,t),n.append(t,m),c.push("data-caption")}o.test(s,{name:!0,attributes:"data-entity-uuid"})&&(n.setAttribute("dataEntityUuid",s.getAttribute("data-entity-uuid"),m),c.push("data-entity-uuid")),o.test(s,{name:!0,attributes:"data-entity-type"})&&(n.setAttribute("dataEntityType",s.getAttribute("data-entity-type"),m),c.push("data-entity-type")),a(m,i.modelCursor)&&(o.consume(s,{name:!0,attributes:c}),l(m,i))}return e=>{e.on("element:img",t,{priority:"high"})}}(e)).attributeToAttribute({view:{name:"img",key:"width"},model:{key:"resizedWidth",value:e=>a(e.getAttribute("width"))?`${parseInt(e.getAttribute("width"),10)}px`:e.getAttribute("width").trim()}}).attributeToAttribute({view:{name:"img",key:"height"},model:{key:"resizedHeight",value:e=>a(e.getAttribute("height"))?`${parseInt(e.getAttribute("height"),10)}px`:e.getAttribute("height").trim()}}),e.plugins.has("DataFilter")){const t=e.plugins.get("DataFilter");i.for("upcast").add(function(e){function t(t,i,r){if(!i.modelRange)return;const s=i.viewItem.parent;if(!s.is("element","a"))return;if(!i.modelRange.getContainedElement().is("element","imageBlock"))return;const n=e.processViewAttributes(s,r);n&&r.writer.setAttribute("htmlLinkAttributes",n,i.modelRange)}return e=>{e.on("element:img",t,{priority:"high"})}}(t))}i.for("downcast").add(function(){function e(e,t,i){const{item:r}=t,{consumable:s,writer:n}=i;if(!s.consume(r,e.name))return;const o=i.mapper.toViewElement(r),a=Array.from(o.getChildren()).find((e=>"img"===e.name));n.setAttribute("data-entity-uuid",t.attributeNewValue,a||o)}return t=>{t.on("attribute:dataEntityUuid",e)}}()).add(function(){function e(e,t,i){const{item:r}=t,{consumable:s,writer:n}=i;if(!s.consume(r,e.name))return;const o=i.mapper.toViewElement(r),a=Array.from(o.getChildren()).find((e=>"img"===e.name));n.setAttribute("data-entity-type",t.attributeNewValue,a||o)}return t=>{t.on("attribute:dataEntityType",e)}}()),i.for("dataDowncast").add(function(e){return t=>{t.on("insert:caption",((t,i,r)=>{const{consumable:s,writer:n,mapper:o}=r;if(!e.plugins.get("ImageUtils").isImage(i.item.parent)||!s.consume(i.item,"insert"))return;const a=e.model.createRangeIn(i.item),l=n.createDocumentFragment();o.bindElements(i.item,l);for(const{item:t}of Array.from(a)){const i={item:t,range:e.model.createRangeOn(t)},s=`insert:${t.name||"$text"}`;e.data.downcastDispatcher.fire(s,i,r);for(const s of t.getAttributeKeys())Object.assign(i,{attributeKey:s,attributeOldValue:null,attributeNewValue:i.item.getAttribute(s)}),e.data.downcastDispatcher.fire(`attribute:${s}`,i,r)}for(const e of n.createRangeIn(l).getItems())o.unbindViewElement(e);o.unbindViewElement(l);const u=e.data.processor.toData(l);if(u){const e=o.toViewElement(i.item.parent);n.setAttribute("data-caption",u,e)}}),{priority:"high"})}}(e)).elementToElement({model:"imageBlock",view:(e,{writer:t})=>o(t),converterPriority:"high"}).elementToElement({model:"imageInline",view:(e,{writer:t})=>o(t),converterPriority:"high"}).add(function(){function e(e,t,i){const{item:r}=t,{consumable:s,writer:n}=i,o=u.find((e=>e.modelValue===t.attributeNewValue));if(!o||!s.consume(r,e.name))return;const a=i.mapper.toViewElement(r),l=Array.from(a.getChildren()).find((e=>"img"===e.name));n.setAttribute("data-align",o.dataValue,l||a)}return t=>{t.on("attribute:imageStyle",e,{priority:"high"})}}()).add(function(){function e(e,i,r){if(!r.consumable.consume(i.item,e.name))return;const s=r.mapper.toViewElement(i.item),n=r.writer,o=n.createContainerElement("a",{href:i.attributeNewValue});n.insert(n.createPositionBefore(s),o),n.move(n.createRangeOn(s),n.createPositionAt(o,0)),r.consumable.consume(i.item,"attribute:htmlLinkAttributes:imageBlock")&&t(r.writer,i.item.getAttribute("htmlLinkAttributes"),o)}return t=>{t.on("attribute:linkHref:imageBlock",e,{priority:"high"})}}()).attributeToAttribute({model:{name:"imageBlock",key:"resizedWidth"},view:e=>({key:"width",value:l(e)}),converterPriority:"high"}).attributeToAttribute({model:{name:"imageInline",key:"resizedWidth"},view:e=>({key:"width",value:l(e)}),converterPriority:"high"}).attributeToAttribute({model:{name:"imageBlock",key:"resizedHeight"},view:e=>({key:"height",value:l(e)}),converterPriority:"high"}).attributeToAttribute({model:{name:"imageInline",key:"resizedHeight"},view:e=>({key:"height",value:l(e)}),converterPriority:"high"}).attributeToAttribute({model:{name:"imageBlock",key:"width"},view:(e,{consumable:t},i)=>i.item.hasAttribute("resizedWidth")?(t.consume(i.item,"attribute:width"),null):{key:"width",value:e},converterPriority:"high"}).attributeToAttribute({model:{name:"imageInline",key:"width"},view:(e,{consumable:t},i)=>i.item.hasAttribute("resizedWidth")?(t.consume(i.item,"attribute:width"),null):{key:"width",value:e},converterPriority:"high"}).attributeToAttribute({model:{name:"imageBlock",key:"height"},view:(e,t,i)=>{if(i.item.hasAttribute("resizedWidth")){if(i.item.getAttribute("resizedWidth").endsWith("%"))return{key:"height",value:i.item.getAttribute("resizedWidth")};const t=parseInt(i.item.getAttribute("resizedWidth"),10),r=parseInt(i.item.getAttribute("width"),10)/parseInt(e,10);return{key:"height",value:`${Math.round(t/r)}`}}return{key:"height",value:e}},converterPriority:"high"}).attributeToAttribute({model:{name:"imageInline",key:"height"},view:(e,t,i)=>{if(i.item.hasAttribute("resizedWidth")){if(i.item.getAttribute("resizedWidth").endsWith("%"))return{key:"height",value:i.item.getAttribute("resizedWidth")};const t=parseInt(i.item.getAttribute("resizedWidth"),10),r=parseInt(i.item.getAttribute("width"),10)/parseInt(e,10);return{key:"height",value:`${Math.round(t/r)}`}}return{key:"height",value:e}},converterPriority:"high"}),e.editing.view.addObserver(n);const s=e.plugins.get("ImageUtils");e.editing.view.document.on("imageLoaded",((t,i)=>{const r=e.editing.view.domConverter.mapDomToView(i.target);if(!r)return;const n=s.getImageWidgetFromImageView(r);if(!n)return;const o=e.editing.mapper.toModelElement(n);o&&e.model.enqueueChange({isUndoable:!1},(()=>{s.setImageNaturalSizeAttributes(o)}))}))}}class c extends e.Command{refresh(){const e=this.editor.plugins.get("ImageUtils").getClosestSelectedImageElement(this.editor.model.document.selection);this.isEnabled=!!e,this.isEnabled&&e.hasAttribute("alt")?this.value=e.getAttribute("alt"):this.value=!1}execute(e){const t=this.editor,i=t.plugins.get("ImageUtils"),r=t.model,s=i.getClosestSelectedImageElement(r.document.selection);r.change((t=>{t.setAttribute("alt",e.newValue,s)}))}}class m extends e.Plugin{static get requires(){return["ImageUtils"]}static get pluginName(){return"DrupalImageAlternativeTextEditing"}constructor(e){super(e),this._missingAltTextViewReferences=new Set}init(){const e=this.editor;e.conversion.for("editingDowncast").add(this._imageEditingDowncastConverter("attribute:alt",e)).add(this._imageEditingDowncastConverter("attribute:src",e)),e.commands.add("imageTextAlternative",new c(this.editor)),e.editing.view.on("render",(()=>{for(const e of this._missingAltTextViewReferences)e.button.element.isConnected||(e.destroy(),this._missingAltTextViewReferences.delete(e))}))}_imageEditingDowncastConverter(e){const t=(e,t,i)=>{const r=this.editor;if(!r.plugins.get("ImageUtils").isImage(t.item))return;const s=i.mapper.toViewElement(t.item),n=Array.from(s.getChildren()).find((e=>e.getCustomProperty("drupalImageMissingAltWarning")));if(t.item.hasAttribute("alt"))return void(n&&i.writer.remove(n));if(n)return;const o=r.ui.componentFactory.create("drupalImageAlternativeTextMissing");o.listenTo(r.ui,"update",(()=>{const e=r.model.document.selection.getFirstRange(),i=r.model.createRangeOn(t.item);o.set({isSelected:e.containsRange(i)||e.isIntersecting(i)})})),o.render(),this._missingAltTextViewReferences.add(o);const a=i.writer.createUIElement("span",{class:"image-alternative-text-missing-wrapper"},(function(e){const t=this.toDomElement(e);return t.appendChild(o.element),t}));i.writer.setCustomProperty("drupalImageMissingAltWarning",!0,a),i.writer.insert(i.writer.createPositionAt(s,"end"),a)};return i=>{i.on(e,t,{priority:"low"})}}}var g=i("ckeditor5/src/ui.js");function h(e){const t=e.plugins.get("ContextualBalloon");if(e.plugins.get("ImageUtils").getClosestSelectedImageWidget(e.editing.view.document.selection)){const i=p(e);t.updatePosition(i)}}function p(e){const t=e.editing.view,i=g.BalloonPanelView.defaultPositions,r=e.plugins.get("ImageUtils");return{target:t.domConverter.mapViewToDom(r.getClosestSelectedImageWidget(t.document.selection)),positions:[i.northArrowSouth,i.northArrowSouthWest,i.northArrowSouthEast,i.southArrowNorth,i.southArrowNorthWest,i.southArrowNorthEast,i.viewportStickyNorth]}}var b=i("ckeditor5/src/utils.js");class f extends g.View{constructor(t){super(t),this.focusTracker=new b.FocusTracker,this.keystrokes=new b.KeystrokeHandler,this.decorativeToggle=this._decorativeToggleView(),this.labeledInput=this._createLabeledInputView(),this.saveButtonView=this._createButton(Drupal.t("Save"),e.icons.check,"ck-button-save"),this.saveButtonView.type="submit",this.saveButtonView.bind("isEnabled").to(this.decorativeToggle,"isOn",this.labeledInput,"isEmpty",((e,t)=>e||!t)),this.cancelButtonView=this._createButton(Drupal.t("Cancel"),e.icons.cancel,"ck-button-cancel","cancel"),this._focusables=new g.ViewCollection,this._focusCycler=new g.FocusCycler({focusables:this._focusables,focusTracker:this.focusTracker,keystrokeHandler:this.keystrokes,actions:{focusPrevious:"shift + tab",focusNext:"tab"}}),this.setTemplate({tag:"form",attributes:{class:["ck","ck-text-alternative-form","ck-text-alternative-form--with-decorative-toggle","ck-responsive-form"],tabindex:"-1"},children:[{tag:"div",attributes:{class:["ck","ck-text-alternative-form__decorative-toggle"]},children:[this.decorativeToggle]},this.labeledInput,this.saveButtonView,this.cancelButtonView]}),(0,g.injectCssTransitionDisabler)(this)}render(){super.render(),this.keystrokes.listenTo(this.element),(0,g.submitHandler)({view:this}),[this.decorativeToggle,this.labeledInput,this.saveButtonView,this.cancelButtonView].forEach((e=>{this._focusables.add(e),this.focusTracker.add(e.element)}))}destroy(){super.destroy(),this.focusTracker.destroy(),this.keystrokes.destroy()}_createButton(e,t,i,r){const s=new g.ButtonView(this.locale);return s.set({label:e,icon:t,tooltip:!0}),s.extendTemplate({attributes:{class:i}}),r&&s.delegate("execute").to(this,r),s}_createLabeledInputView(){const e=new g.LabeledFieldView(this.locale,g.createLabeledInputText);return e.bind("class").to(this.decorativeToggle,"isOn",(e=>e?"ck-hidden":"")),e.label=Drupal.t("Alternative text"),e}_decorativeToggleView(){const e=new g.SwitchButtonView(this.locale);return e.set({withText:!0,label:Drupal.t("Decorative image")}),e.on("execute",(()=>{e.set("isOn",!e.isOn)})),e}}class w extends g.View{constructor(e){super(e);const t=this.bindTemplate;this.set("isVisible"),this.set("isSelected");const i=Drupal.t("Add missing alternative text");this.button=new g.ButtonView(e),this.button.set({label:i,tooltip:!1,withText:!0}),this.setTemplate({tag:"span",attributes:{class:["image-alternative-text-missing",t.to("isVisible",(e=>e?"":"ck-hidden"))],title:i},children:[this.button]})}}class v extends e.Plugin{static get requires(){return[g.ContextualBalloon]}static get pluginName(){return"DrupalImageTextAlternativeUI"}init(){this._createButton(),this._createForm(),this._createMissingAltTextComponent();const e=()=>{this.editor.plugins.get("ImageUtils").getClosestSelectedImageWidget(this.editor.editing.view.document.selection)&&this._showForm()};if(this.editor.commands.get("insertImage")){this.editor.commands.get("insertImage").on("execute",e)}if(this.editor.plugins.has("ImageUploadEditing")){this.editor.plugins.get("ImageUploadEditing").on("uploadComplete",e)}}_createMissingAltTextComponent(){this.editor.ui.componentFactory.add("drupalImageAlternativeTextMissing",(e=>{const t=new w(e);return t.listenTo(t.button,"execute",(()=>{this._isInBalloon&&this._balloon.remove(this._form),this._showForm()})),t.listenTo(this.editor.ui,"update",(()=>{t.set({isVisible:!this._isVisible||!t.isSelected})})),t}))}destroy(){super.destroy(),this._form.destroy()}_createButton(){const t=this.editor;t.ui.componentFactory.add("drupalImageAlternativeText",(i=>{const r=t.commands.get("imageTextAlternative"),s=new g.ButtonView(i);return s.set({label:Drupal.t("Change image alternative text"),icon:e.icons.lowVision,tooltip:!0}),s.bind("isEnabled").to(r,"isEnabled"),this.listenTo(s,"execute",(()=>{this._showForm()})),s}))}_createForm(){const e=this.editor,t=e.editing.view.document,i=e.plugins.get("ImageUtils");this._balloon=this.editor.plugins.get("ContextualBalloon"),this._form=new f(e.locale),this._form.render(),this.listenTo(this._form,"submit",(()=>{e.execute("imageTextAlternative",{newValue:this._form.decorativeToggle.isOn?"":this._form.labeledInput.fieldView.element.value}),this._hideForm(!0)})),this.listenTo(this._form,"cancel",(()=>{this._hideForm(!0)})),this.listenTo(this._form.decorativeToggle,"execute",(()=>{h(e)})),this._form.keystrokes.set("Esc",((e,t)=>{this._hideForm(!0),t()})),this.listenTo(e.ui,"update",(()=>{i.getClosestSelectedImageWidget(t.selection)?this._isVisible&&h(e):this._hideForm(!0)})),(0,g.clickOutsideHandler)({emitter:this._form,activator:()=>this._isVisible,contextElements:[this._balloon.view.element],callback:()=>this._hideForm()})}_showForm(){if(this._isVisible)return;const e=this.editor,t=e.commands.get("imageTextAlternative"),i=this._form.decorativeToggle,r=this._form.labeledInput;this._form.disableCssTransitions(),this._isInBalloon||this._balloon.add({view:this._form,position:p(e)}),i.isOn=""===t.value,r.fieldView.element.value=t.value||"",r.fieldView.value=r.fieldView.element.value,i.isOn?i.focus():r.fieldView.select(),this._form.enableCssTransitions()}_hideForm(e){this._isInBalloon&&(this._form.focusTracker.isFocused&&this._form.saveButtonView.focus(),this._balloon.remove(this._form),e&&this.editor.editing.view.focus())}get _isVisible(){return this._balloon.visibleView===this._form}get _isInBalloon(){return this._balloon.hasView(this._form)}}class y extends e.Plugin{static get requires(){return[m,v]}static get pluginName(){return"DrupalImageAlternativeText"}}class A extends e.Plugin{static get requires(){return[d,y]}static get pluginName(){return"DrupalImage"}}const I=A;class k extends e.Plugin{init(){const{editor:e}=this;e.plugins.get("ImageUploadEditing").on("uploadComplete",((t,{data:i,imageElement:r})=>{e.model.change((e=>{e.setAttribute("dataEntityUuid",i.response.uuid,r),e.setAttribute("dataEntityType",i.response.entity_type,r)}))}))}static get pluginName(){return"DrupalImageUploadEditing"}}var x=i("ckeditor5/src/upload.js");class _{constructor(e,t){this.loader=e,this.options=t}upload(){return this.loader.file.then((e=>new Promise(((t,i)=>{this._initRequest(),this._initListeners(t,i,e),this._sendRequest(e)}))))}abort(){this.xhr&&this.xhr.abort()}_initRequest(){this.xhr=new XMLHttpRequest,this.xhr.open("POST",this.options.uploadUrl,!0),this.xhr.responseType="json"}_initListeners(e,t,i){const r=this.xhr,s=this.loader,n=`Couldn't upload file: ${i.name}.`;r.addEventListener("error",(()=>t(n))),r.addEventListener("abort",(()=>t())),r.addEventListener("load",(()=>{const i=r.response;if(!i||i.error)return t(i?.error?.message||n);e({response:i,urls:{default:i.url}})})),r.upload&&r.upload.addEventListener("progress",(e=>{e.lengthComputable&&(s.uploadTotal=e.total,s.uploaded=e.loaded)}))}_sendRequest(e){const t=this.options.headers||{},i=this.options.withCredentials||!1;Object.keys(t).forEach((e=>{this.xhr.setRequestHeader(e,t[e])})),this.xhr.withCredentials=i;const r=new FormData;r.append("upload",e),this.xhr.send(r)}}class T extends e.Plugin{static get requires(){return[x.FileRepository]}static get pluginName(){return"DrupalFileRepository"}init(){const e=this.editor.config.get("drupalImageUpload");e&&(e.uploadUrl?this.editor.plugins.get(x.FileRepository).createUploadAdapter=t=>new _(t,e):(0,b.logWarning)("simple-upload-adapter-missing-uploadurl"))}}class E extends e.Plugin{static get requires(){return[T,k]}static get pluginName(){return"DrupalImageUpload"}}const V=E;class C extends e.Plugin{init(){const{editor:e}=this;e.ui.componentFactory.add("drupalInsertImage",(()=>e.ui.componentFactory.create("insertImage")))}static get pluginName(){return"DrupalInsertImage"}}const B={DrupalImage:I,DrupalImageUpload:V,DrupalInsertImage:C}})(),r=r.default})()));
\ No newline at end of file +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.drupalImage=t())}(globalThis,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/engine.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/engine.js")},"ckeditor5/src/ui.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/ui.js")},"ckeditor5/src/upload.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/upload.js")},"ckeditor5/src/utils.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/utils.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function i(r){var s=t[r];if(void 0!==s)return s.exports;var n=t[r]={exports:{}};return e[r](n,n.exports,i),n.exports}i.d=(e,t)=>{for(var r in t)i.o(t,r)&&!i.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var r={};return(()=>{"use strict";i.d(r,{default:()=>B});var e=i("ckeditor5/src/core.js");function t(e,t,i){if(t.attributes)for(const[r,s]of Object.entries(t.attributes))e.setAttribute(r,s,i);t.styles&&e.setStyle(t.styles,i),t.classes&&e.addClass(t.classes,i)}var s=i("ckeditor5/src/engine.js");class n extends s.Observer{observe(e){this.listenTo(e,"load",((e,t)=>{const i=t.target;this.checkShouldIgnoreEventFromTarget(i)||"IMG"==i.tagName&&this._fireEvents(t)}),{useCapture:!0})}stopObserving(e){this.stopListening(e)}_fireEvents(e){this.isEnabled&&(this.document.fire("layoutChanged"),this.document.fire("imageLoaded",e))}}function a(e){return e.createEmptyElement("img")}function o(e){const t=parseFloat(e);return!Number.isNaN(t)&&e===String(t)}function l(e){return"string"==typeof e&&e.endsWith("%")?e:`${parseInt(e,10)}`}const u=[{modelValue:"alignCenter",dataValue:"center"},{modelValue:"alignRight",dataValue:"right"},{modelValue:"alignLeft",dataValue:"left"}];class d extends e.Plugin{static get requires(){return["ImageUtils"]}static get pluginName(){return"DrupalImageEditing"}init(){const{editor:e}=this,{conversion:i}=e,{schema:r}=e.model;if(r.isRegistered("imageInline")&&r.extend("imageInline",{allowAttributes:["dataEntityUuid","dataEntityType","isDecorative"]}),r.isRegistered("imageBlock")&&r.extend("imageBlock",{allowAttributes:["dataEntityUuid","dataEntityType","isDecorative"]}),i.for("upcast").add(function(e){function t(t,i,r){const{viewItem:s}=i,{writer:n,consumable:a,safeInsert:o,updateConversionResult:l,schema:d}=r,c=[];let m;if(!a.test(s,{name:!0,attributes:"src"}))return;const g=a.test(s,{name:!0,attributes:"data-caption"});if(m=d.checkChild(i.modelCursor,"imageInline")&&!g?n.createElement("imageInline",{src:s.getAttribute("src")}):n.createElement("imageBlock",{src:s.getAttribute("src")}),e.plugins.has("ImageStyleEditing")&&a.test(s,{name:!0,attributes:"data-align"})){const e=s.getAttribute("data-align"),t=u.find((t=>t.dataValue===e));t&&(n.setAttribute("imageStyle",t.modelValue,m),c.push("data-align"))}if(g){const t=n.createElement("caption"),i=e.data.processor.toView(s.getAttribute("data-caption"));r.consumable.constructor.createFrom(i,r.consumable),r.convertChildren(i,t),n.append(t,m),c.push("data-caption")}a.test(s,{name:!0,attributes:"data-entity-uuid"})&&(n.setAttribute("dataEntityUuid",s.getAttribute("data-entity-uuid"),m),c.push("data-entity-uuid")),a.test(s,{name:!0,attributes:"data-entity-type"})&&(n.setAttribute("dataEntityType",s.getAttribute("data-entity-type"),m),c.push("data-entity-type")),o(m,i.modelCursor)&&(a.consume(s,{name:!0,attributes:c}),l(m,i))}return e=>{e.on("element:img",t,{priority:"high"})}}(e)).attributeToAttribute({view:{name:"img",key:"width"},model:{key:"resizedWidth",value:e=>o(e.getAttribute("width"))?`${parseInt(e.getAttribute("width"),10)}px`:e.getAttribute("width").trim()}}).attributeToAttribute({view:{name:"img",key:"height"},model:{key:"resizedHeight",value:e=>o(e.getAttribute("height"))?`${parseInt(e.getAttribute("height"),10)}px`:e.getAttribute("height").trim()}}),e.plugins.has("DataFilter")){const t=e.plugins.get("DataFilter");i.for("upcast").add(function(e){function t(t,i,r){if(!i.modelRange)return;const s=i.viewItem.parent;if(!s.is("element","a"))return;if(!i.modelRange.getContainedElement().is("element","imageBlock"))return;const n=e.processViewAttributes(s,r);n&&r.writer.setAttribute("htmlLinkAttributes",n,i.modelRange)}return e=>{e.on("element:img",t,{priority:"high"})}}(t))}i.for("downcast").add(function(){function e(e,t,i){const{item:r}=t,{consumable:s,writer:n}=i;if(!s.consume(r,e.name))return;const a=i.mapper.toViewElement(r),o=Array.from(a.getChildren()).find((e=>"img"===e.name));n.setAttribute("data-entity-uuid",t.attributeNewValue,o||a)}return t=>{t.on("attribute:dataEntityUuid",e)}}()).add(function(){function e(e,t,i){const{item:r}=t,{consumable:s,writer:n}=i;if(!s.consume(r,e.name))return;const a=i.mapper.toViewElement(r),o=Array.from(a.getChildren()).find((e=>"img"===e.name));n.setAttribute("data-entity-type",t.attributeNewValue,o||a)}return t=>{t.on("attribute:dataEntityType",e)}}()),i.for("dataDowncast").add(function(e){return t=>{t.on("insert:caption",((t,i,r)=>{const{consumable:s,writer:n,mapper:a}=r;if(!e.plugins.get("ImageUtils").isImage(i.item.parent)||!s.consume(i.item,"insert"))return;const o=e.model.createRangeIn(i.item),l=n.createDocumentFragment();a.bindElements(i.item,l);for(const{item:t}of Array.from(o)){const i={item:t,range:e.model.createRangeOn(t)},s=`insert:${t.name||"$text"}`;e.data.downcastDispatcher.fire(s,i,r);for(const s of t.getAttributeKeys())Object.assign(i,{attributeKey:s,attributeOldValue:null,attributeNewValue:i.item.getAttribute(s)}),e.data.downcastDispatcher.fire(`attribute:${s}`,i,r)}for(const e of n.createRangeIn(l).getItems())a.unbindViewElement(e);a.unbindViewElement(l);const u=e.data.processor.toData(l);if(u){const e=a.toViewElement(i.item.parent);n.setAttribute("data-caption",u,e)}}),{priority:"high"})}}(e)).elementToElement({model:"imageBlock",view:(e,{writer:t})=>a(t),converterPriority:"high"}).elementToElement({model:"imageInline",view:(e,{writer:t})=>a(t),converterPriority:"high"}).add(function(){function e(e,t,i){const{item:r}=t,{consumable:s,writer:n}=i,a=u.find((e=>e.modelValue===t.attributeNewValue));if(!a||!s.consume(r,e.name))return;const o=i.mapper.toViewElement(r),l=Array.from(o.getChildren()).find((e=>"img"===e.name));n.setAttribute("data-align",a.dataValue,l||o)}return t=>{t.on("attribute:imageStyle",e,{priority:"high"})}}()).add(function(){function e(e,i,r){if(!r.consumable.consume(i.item,e.name))return;const s=r.mapper.toViewElement(i.item),n=r.writer,a=n.createContainerElement("a",{href:i.attributeNewValue});n.insert(n.createPositionBefore(s),a),n.move(n.createRangeOn(s),n.createPositionAt(a,0)),r.consumable.consume(i.item,"attribute:htmlLinkAttributes:imageBlock")&&t(r.writer,i.item.getAttribute("htmlLinkAttributes"),a)}return t=>{t.on("attribute:linkHref:imageBlock",e,{priority:"high"})}}()).attributeToAttribute({model:{name:"imageBlock",key:"resizedWidth"},view:e=>({key:"width",value:l(e)}),converterPriority:"high"}).attributeToAttribute({model:{name:"imageInline",key:"resizedWidth"},view:e=>({key:"width",value:l(e)}),converterPriority:"high"}).attributeToAttribute({model:{name:"imageBlock",key:"resizedHeight"},view:e=>({key:"height",value:l(e)}),converterPriority:"high"}).attributeToAttribute({model:{name:"imageInline",key:"resizedHeight"},view:e=>({key:"height",value:l(e)}),converterPriority:"high"}).attributeToAttribute({model:{name:"imageBlock",key:"width"},view:(e,{consumable:t},i)=>i.item.hasAttribute("resizedWidth")?(t.consume(i.item,"attribute:width"),null):{key:"width",value:e},converterPriority:"high"}).attributeToAttribute({model:{name:"imageInline",key:"width"},view:(e,{consumable:t},i)=>i.item.hasAttribute("resizedWidth")?(t.consume(i.item,"attribute:width"),null):{key:"width",value:e},converterPriority:"high"}).attributeToAttribute({model:{name:"imageBlock",key:"height"},view:(e,t,i)=>{if(i.item.hasAttribute("resizedWidth")){if(i.item.getAttribute("resizedWidth").endsWith("%"))return{key:"height",value:i.item.getAttribute("resizedWidth")};const t=parseInt(i.item.getAttribute("resizedWidth"),10),r=parseInt(i.item.getAttribute("width"),10)/parseInt(e,10);return{key:"height",value:`${Math.round(t/r)}`}}return{key:"height",value:e}},converterPriority:"high"}).attributeToAttribute({model:{name:"imageInline",key:"height"},view:(e,t,i)=>{if(i.item.hasAttribute("resizedWidth")){if(i.item.getAttribute("resizedWidth").endsWith("%"))return{key:"height",value:i.item.getAttribute("resizedWidth")};const t=parseInt(i.item.getAttribute("resizedWidth"),10),r=parseInt(i.item.getAttribute("width"),10)/parseInt(e,10);return{key:"height",value:`${Math.round(t/r)}`}}return{key:"height",value:e}},converterPriority:"high"}),e.editing.view.addObserver(n);const s=e.plugins.get("ImageUtils");e.editing.view.document.on("imageLoaded",((t,i)=>{const r=e.editing.view.domConverter.mapDomToView(i.target);if(!r)return;const n=s.getImageWidgetFromImageView(r);if(!n)return;const a=e.editing.mapper.toModelElement(n);a&&e.model.enqueueChange({isUndoable:!1},(()=>{s.setImageNaturalSizeAttributes(a)}))}))}}class c extends e.Command{refresh(){const e=this.editor.plugins.get("ImageUtils").getClosestSelectedImageElement(this.editor.model.document.selection);this.isEnabled=!!e,this.isEnabled&&e.hasAttribute("alt")?this.value=e.getAttribute("alt"):this.value=!1}execute(e){const t=this.editor,i=t.plugins.get("ImageUtils"),r=t.model,s=i.getClosestSelectedImageElement(r.document.selection);r.change((t=>{t.setAttribute("alt",e.newValue,s)}))}}class m extends e.Plugin{static get requires(){return["ImageUtils"]}static get pluginName(){return"DrupalImageAlternativeTextEditing"}constructor(e){super(e),this._missingAltTextViewReferences=new Set}init(){const e=this.editor;e.conversion.for("editingDowncast").add(this._imageEditingDowncastConverter("attribute:alt",e)).add(this._imageEditingDowncastConverter("attribute:src",e)),e.commands.add("imageTextAlternative",new c(this.editor)),e.editing.view.on("render",(()=>{for(const e of this._missingAltTextViewReferences)e.button.element.isConnected||(e.destroy(),this._missingAltTextViewReferences.delete(e))}))}_imageEditingDowncastConverter(e){const t=(e,t,i)=>{const r=this.editor;if(!r.plugins.get("ImageUtils").isImage(t.item))return;const s=i.mapper.toViewElement(t.item),n=Array.from(s.getChildren()).find((e=>e.getCustomProperty("drupalImageMissingAltWarning")));if(t.item.hasAttribute("alt"))return void(n&&i.writer.remove(n));if(n)return;const a=r.ui.componentFactory.create("drupalImageAlternativeTextMissing");a.listenTo(r.ui,"update",(()=>{const e=r.model.document.selection.getFirstRange(),i=r.model.createRangeOn(t.item);a.set({isSelected:e.containsRange(i)||e.isIntersecting(i)})})),a.render(),this._missingAltTextViewReferences.add(a);const o=i.writer.createUIElement("span",{class:"image-alternative-text-missing-wrapper"},(function(e){const t=this.toDomElement(e);return t.appendChild(a.element),t}));i.writer.setCustomProperty("drupalImageMissingAltWarning",!0,o),i.writer.insert(i.writer.createPositionAt(s,"end"),o)};return i=>{i.on(e,t,{priority:"low"})}}}var g=i("ckeditor5/src/ui.js");function h(e){const t=e.plugins.get("ContextualBalloon");if(e.plugins.get("ImageUtils").getClosestSelectedImageWidget(e.editing.view.document.selection)){const i=p(e);t.updatePosition(i)}}function p(e){const t=e.editing.view,i=g.BalloonPanelView.defaultPositions,r=e.plugins.get("ImageUtils");return{target:t.domConverter.mapViewToDom(r.getClosestSelectedImageWidget(t.document.selection)),positions:[i.northArrowSouth,i.northArrowSouthWest,i.northArrowSouthEast,i.southArrowNorth,i.southArrowNorthWest,i.southArrowNorthEast,i.viewportStickyNorth]}}var b=i("ckeditor5/src/utils.js");class w extends g.View{constructor(e){super(e),this.focusTracker=new b.FocusTracker,this.keystrokes=new b.KeystrokeHandler,this.decorativeToggle=this._decorativeToggleView(),this.labeledInput=this._createLabeledInputView(),this.saveButtonView=this._createButton(Drupal.t("Save"),'<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M6.972 16.615a.997.997 0 0 1-.744-.292l-4.596-4.596a1 1 0 1 1 1.414-1.414l3.926 3.926 9.937-9.937a1 1 0 0 1 1.414 1.415L7.717 16.323a.997.997 0 0 1-.745.292z"/></svg>',"ck-button-save"),this.saveButtonView.type="submit",this.saveButtonView.bind("isEnabled").to(this.decorativeToggle,"isOn",this.labeledInput,"isEmpty",((e,t)=>e||!t)),this.cancelButtonView=this._createButton(Drupal.t("Cancel"),'<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.591 10.177 4.243 4.242a1 1 0 0 1-1.415 1.415l-4.242-4.243-4.243 4.243a1 1 0 0 1-1.414-1.415l4.243-4.242L4.52 5.934A1 1 0 0 1 5.934 4.52l4.243 4.243 4.242-4.243a1 1 0 1 1 1.415 1.414l-4.243 4.243z"/></svg>',"ck-button-cancel","cancel"),this._focusables=new g.ViewCollection,this._focusCycler=new g.FocusCycler({focusables:this._focusables,focusTracker:this.focusTracker,keystrokeHandler:this.keystrokes,actions:{focusPrevious:"shift + tab",focusNext:"tab"}}),this.setTemplate({tag:"form",attributes:{class:["ck","ck-text-alternative-form","ck-text-alternative-form--with-decorative-toggle","ck-responsive-form"],tabindex:"-1"},children:[{tag:"div",attributes:{class:["ck","ck-text-alternative-form__decorative-toggle"]},children:[this.decorativeToggle]},this.labeledInput,this.saveButtonView,this.cancelButtonView]}),(0,g.injectCssTransitionDisabler)(this)}render(){super.render(),this.keystrokes.listenTo(this.element),(0,g.submitHandler)({view:this}),[this.decorativeToggle,this.labeledInput,this.saveButtonView,this.cancelButtonView].forEach((e=>{this._focusables.add(e),this.focusTracker.add(e.element)}))}destroy(){super.destroy(),this.focusTracker.destroy(),this.keystrokes.destroy()}_createButton(e,t,i,r){const s=new g.ButtonView(this.locale);return s.set({label:e,icon:t,tooltip:!0}),s.extendTemplate({attributes:{class:i}}),r&&s.delegate("execute").to(this,r),s}_createLabeledInputView(){const e=new g.LabeledFieldView(this.locale,g.createLabeledInputText);return e.bind("class").to(this.decorativeToggle,"isOn",(e=>e?"ck-hidden":"")),e.label=Drupal.t("Alternative text"),e}_decorativeToggleView(){const e=new g.SwitchButtonView(this.locale);return e.set({withText:!0,label:Drupal.t("Decorative image")}),e.on("execute",(()=>{e.set("isOn",!e.isOn)})),e}}class f extends g.View{constructor(e){super(e);const t=this.bindTemplate;this.set("isVisible"),this.set("isSelected");const i=Drupal.t("Add missing alternative text");this.button=new g.ButtonView(e),this.button.set({label:i,tooltip:!1,withText:!0}),this.setTemplate({tag:"span",attributes:{class:["image-alternative-text-missing",t.to("isVisible",(e=>e?"":"ck-hidden"))],title:i},children:[this.button]})}}class v extends e.Plugin{static get requires(){return[g.ContextualBalloon]}static get pluginName(){return"DrupalImageTextAlternativeUI"}init(){this._createButton(),this._createForm(),this._createMissingAltTextComponent();const e=()=>{this.editor.plugins.get("ImageUtils").getClosestSelectedImageWidget(this.editor.editing.view.document.selection)&&this._showForm()};if(this.editor.commands.get("insertImage")){this.editor.commands.get("insertImage").on("execute",e)}if(this.editor.plugins.has("ImageUploadEditing")){this.editor.plugins.get("ImageUploadEditing").on("uploadComplete",e)}}_createMissingAltTextComponent(){this.editor.ui.componentFactory.add("drupalImageAlternativeTextMissing",(e=>{const t=new f(e);return t.listenTo(t.button,"execute",(()=>{this._isInBalloon&&this._balloon.remove(this._form),this._showForm()})),t.listenTo(this.editor.ui,"update",(()=>{t.set({isVisible:!this._isVisible||!t.isSelected})})),t}))}destroy(){super.destroy(),this._form.destroy()}_createButton(){const e=this.editor;e.ui.componentFactory.add("drupalImageAlternativeText",(t=>{const i=e.commands.get("imageTextAlternative"),r=new g.ButtonView(t);return r.set({label:Drupal.t("Change image alternative text"),icon:'<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M5.085 6.22 2.943 4.078a.75.75 0 1 1 1.06-1.06l2.592 2.59A11.094 11.094 0 0 1 10 5.068c4.738 0 8.578 3.101 8.578 5.083 0 1.197-1.401 2.803-3.555 3.887l1.714 1.713a.75.75 0 0 1-.09 1.138.488.488 0 0 1-.15.084.75.75 0 0 1-.821-.16L6.17 7.304c-.258.11-.51.233-.757.365l6.239 6.24-.006.005.78.78c-.388.094-.78.166-1.174.215l-1.11-1.11h.011L4.55 8.197a7.2 7.2 0 0 0-.665.514l-.112.098 4.897 4.897-.005.006 1.276 1.276a10.164 10.164 0 0 1-1.477-.117l-.479-.479-.009.009-4.863-4.863-.022.031a2.563 2.563 0 0 0-.124.2c-.043.077-.08.158-.108.241a.534.534 0 0 0-.028.133.29.29 0 0 0 .008.072.927.927 0 0 0 .082.226c.067.133.145.26.234.379l3.242 3.365.025.01.59.623c-3.265-.918-5.59-3.155-5.59-4.668 0-1.194 1.448-2.838 3.663-3.93zm7.07.531a4.632 4.632 0 0 1 1.108 5.992l.345.344.046-.018a9.313 9.313 0 0 0 2-1.112c.256-.187.5-.392.727-.613.137-.134.27-.277.392-.431.072-.091.141-.185.203-.286.057-.093.107-.19.148-.292a.72.72 0 0 0 .036-.12.29.29 0 0 0 .008-.072.492.492 0 0 0-.028-.133.999.999 0 0 0-.036-.096 2.165 2.165 0 0 0-.071-.145 2.917 2.917 0 0 0-.125-.2 3.592 3.592 0 0 0-.263-.335 5.444 5.444 0 0 0-.53-.523 7.955 7.955 0 0 0-1.054-.768 9.766 9.766 0 0 0-1.879-.891c-.337-.118-.68-.219-1.027-.301zm-2.85.21-.069.002a.508.508 0 0 0-.254.097.496.496 0 0 0-.104.679.498.498 0 0 0 .326.199l.045.005c.091.003.181.003.272.012a2.45 2.45 0 0 1 2.017 1.513c.024.061.043.125.069.185a.494.494 0 0 0 .45.287h.008a.496.496 0 0 0 .35-.158.482.482 0 0 0 .13-.335.638.638 0 0 0-.048-.219 3.379 3.379 0 0 0-.36-.723 3.438 3.438 0 0 0-2.791-1.543l-.028-.001h-.013z"/></svg>',tooltip:!0}),r.bind("isEnabled").to(i,"isEnabled"),this.listenTo(r,"execute",(()=>{this._showForm()})),r}))}_createForm(){const e=this.editor,t=e.editing.view.document,i=e.plugins.get("ImageUtils");this._balloon=this.editor.plugins.get("ContextualBalloon"),this._form=new w(e.locale),this._form.render(),this.listenTo(this._form,"submit",(()=>{e.execute("imageTextAlternative",{newValue:this._form.decorativeToggle.isOn?"":this._form.labeledInput.fieldView.element.value}),this._hideForm(!0)})),this.listenTo(this._form,"cancel",(()=>{this._hideForm(!0)})),this.listenTo(this._form.decorativeToggle,"execute",(()=>{h(e)})),this._form.keystrokes.set("Esc",((e,t)=>{this._hideForm(!0),t()})),this.listenTo(e.ui,"update",(()=>{i.getClosestSelectedImageWidget(t.selection)?this._isVisible&&h(e):this._hideForm(!0)})),(0,g.clickOutsideHandler)({emitter:this._form,activator:()=>this._isVisible,contextElements:[this._balloon.view.element],callback:()=>this._hideForm()})}_showForm(){if(this._isVisible)return;const e=this.editor,t=e.commands.get("imageTextAlternative"),i=this._form.decorativeToggle,r=this._form.labeledInput;this._form.disableCssTransitions(),this._isInBalloon||this._balloon.add({view:this._form,position:p(e)}),i.isOn=""===t.value,r.fieldView.element.value=t.value||"",r.fieldView.value=r.fieldView.element.value,i.isOn?i.focus():r.fieldView.select(),this._form.enableCssTransitions()}_hideForm(e){this._isInBalloon&&(this._form.focusTracker.isFocused&&this._form.saveButtonView.focus(),this._balloon.remove(this._form),e&&this.editor.editing.view.focus())}get _isVisible(){return this._balloon.visibleView===this._form}get _isInBalloon(){return this._balloon.hasView(this._form)}}class y extends e.Plugin{static get requires(){return[m,v]}static get pluginName(){return"DrupalImageAlternativeText"}}class A extends e.Plugin{static get requires(){return[d,y]}static get pluginName(){return"DrupalImage"}}const I=A;class x extends e.Plugin{init(){const{editor:e}=this;e.plugins.get("ImageUploadEditing").on("uploadComplete",((t,{data:i,imageElement:r})=>{e.model.change((e=>{e.setAttribute("dataEntityUuid",i.response.uuid,r),e.setAttribute("dataEntityType",i.response.entity_type,r)}))}))}static get pluginName(){return"DrupalImageUploadEditing"}}var k=i("ckeditor5/src/upload.js");class _{constructor(e,t){this.loader=e,this.options=t}upload(){return this.loader.file.then((e=>new Promise(((t,i)=>{this._initRequest(),this._initListeners(t,i,e),this._sendRequest(e)}))))}abort(){this.xhr&&this.xhr.abort()}_initRequest(){this.xhr=new XMLHttpRequest,this.xhr.open("POST",this.options.uploadUrl,!0),this.xhr.responseType="json"}_initListeners(e,t,i){const r=this.xhr,s=this.loader,n=`Couldn't upload file: ${i.name}.`;r.addEventListener("error",(()=>t(n))),r.addEventListener("abort",(()=>t())),r.addEventListener("load",(()=>{const i=r.response;if(!i||i.error)return t(i?.error?.message||n);e({response:i,urls:{default:i.url}})})),r.upload&&r.upload.addEventListener("progress",(e=>{e.lengthComputable&&(s.uploadTotal=e.total,s.uploaded=e.loaded)}))}_sendRequest(e){const t=this.options.headers||{},i=this.options.withCredentials||!1;Object.keys(t).forEach((e=>{this.xhr.setRequestHeader(e,t[e])})),this.xhr.withCredentials=i;const r=new FormData;r.append("upload",e),this.xhr.send(r)}}class T extends e.Plugin{static get requires(){return[k.FileRepository]}static get pluginName(){return"DrupalFileRepository"}init(){const e=this.editor.config.get("drupalImageUpload");e&&(e.uploadUrl?this.editor.plugins.get(k.FileRepository).createUploadAdapter=t=>new _(t,e):(0,b.logWarning)("simple-upload-adapter-missing-uploadurl"))}}class E extends e.Plugin{static get requires(){return[T,x]}static get pluginName(){return"DrupalImageUpload"}}const V=E;class C extends e.Plugin{init(){const{editor:e}=this;e.ui.componentFactory.add("drupalInsertImage",(()=>e.ui.componentFactory.create("insertImage")))}static get pluginName(){return"DrupalInsertImage"}}const B={DrupalImage:I,DrupalImageUpload:V,DrupalInsertImage:C}})(),r=r.default})()));
\ No newline at end of file diff --git a/core/modules/ckeditor5/js/build/drupalMedia.js b/core/modules/ckeditor5/js/build/drupalMedia.js index 38c848e4394..06fbe635bf9 100644 --- a/core/modules/ckeditor5/js/build/drupalMedia.js +++ b/core/modules/ckeditor5/js/build/drupalMedia.js @@ -1 +1 @@ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.CKEditor5=t():(e.CKEditor5=e.CKEditor5||{},e.CKEditor5.drupalMedia=t())}(globalThis,(()=>(()=>{var e={"ckeditor5/src/core.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/engine.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/engine.js")},"ckeditor5/src/ui.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/ui.js")},"ckeditor5/src/utils.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/utils.js")},"ckeditor5/src/widget.js":(e,t,i)=>{e.exports=i("dll-reference CKEditor5.dll")("./src/widget.js")},"dll-reference CKEditor5.dll":e=>{"use strict";e.exports=CKEditor5.dll}},t={};function i(n){var a=t[n];if(void 0!==a)return a.exports;var r=t[n]={exports:{}};return e[n](r,r.exports,i),r.exports}i.d=(e,t)=>{for(var n in t)i.o(t,n)&&!i.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var n={};return(()=>{"use strict";i.d(n,{default:()=>ne});var e=i("ckeditor5/src/core.js"),t=i("ckeditor5/src/widget.js");function a(e){return!!e&&e.is("element","drupalMedia")}function r(e){return(0,t.isWidget)(e)&&!!e.getCustomProperty("drupalMedia")}function o(e){const t=e.getSelectedElement();return a(t)?t:e.getFirstPosition().findAncestor("drupalMedia")}function s(e){const t=e.getSelectedElement();if(t&&r(t))return t;if(null===e.getFirstPosition())return null;let i=e.getFirstPosition().parent;for(;i;){if(i.is("element")&&r(i))return i;i=i.parent}return null}function l(e){const t=typeof e;return null!=e&&("object"===t||"function"===t)}function d(e){for(const t of e){if(t.hasAttribute("data-drupal-media-preview"))return t;if(t.childCount){const e=d(t.getChildren());if(e)return e}}return null}function u(e){return`drupalElementStyle${e[0].toUpperCase()+e.substring(1)}`}class c extends e.Command{execute(e){const t=this.editor.plugins.get("DrupalMediaEditing"),i=Object.entries(t.attrs).reduce(((e,[t,i])=>(e[i]=t,e)),{}),n=Object.keys(e).reduce(((t,n)=>(i[n]&&(t[i[n]]=e[n]),t)),{});if(this.editor.plugins.has("DrupalElementStyleEditing")){const t=this.editor.plugins.get("DrupalElementStyleEditing"),{normalizedStyles:i}=t;for(const a of Object.keys(i))for(const i of t.normalizedStyles[a])if(e[i.attributeName]&&i.attributeValue===e[i.attributeName]){const e=u(a);n[e]=i.name}}this.editor.model.change((e=>{this.editor.model.insertObject(function(e,t){return e.createElement("drupalMedia",t)}(e,n))}))}refresh(){const e=this.editor.model,t=e.document.selection,i=e.schema.findAllowedParent(t.getFirstPosition(),"drupalMedia");this.isEnabled=null!==i}}const m="METADATA_ERROR";class p extends e.Plugin{static get requires(){return[t.Widget]}constructor(e){super(e),this.attrs={drupalMediaAlt:"alt",drupalMediaEntityType:"data-entity-type",drupalMediaEntityUuid:"data-entity-uuid"},this.converterAttributes=["drupalMediaEntityUuid","drupalElementStyleViewMode","drupalMediaEntityType","drupalMediaAlt"]}init(){const e=this.editor.config.get("drupalMedia");if(!e)return;const{previewURL:t,themeError:i}=e;this.previewUrl=t,this.labelError=Drupal.t("Preview failed"),this.themeError=i||`\n <p>${Drupal.t("An error occurred while trying to preview the media. Save your work and reload this page.")}<p>\n `,this._defineSchema(),this._defineConverters(),this._defineListeners(),this.editor.commands.add("insertDrupalMedia",new c(this.editor))}upcastDrupalMediaIsImage(e){const{model:t,plugins:i}=this.editor;i.get("DrupalMediaMetadataRepository").getMetadata(e).then((i=>{e&&t.enqueueChange({isUndoable:!1},(t=>{t.setAttribute("drupalMediaIsImage",!!i.imageSourceMetadata,e)}))})).catch((i=>{e&&(console.warn(i.toString()),t.enqueueChange({isUndoable:!1},(t=>{t.setAttribute("drupalMediaIsImage",m,e)})))}))}upcastDrupalMediaType(e){this.editor.plugins.get("DrupalMediaMetadataRepository").getMetadata(e).then((t=>{e&&this.editor.model.enqueueChange({isUndoable:!1},(i=>{i.setAttribute("drupalMediaType",t.type,e)}))})).catch((t=>{e&&(console.warn(t.toString()),this.editor.model.enqueueChange({isUndoable:!1},(t=>{t.setAttribute("drupalMediaType",m,e)})))}))}async _fetchPreview(e){const t={text:this._renderElement(e),uuid:e.getAttribute("drupalMediaEntityUuid")},i=await fetch(`${this.previewUrl}?${new URLSearchParams(t)}`,{headers:{"X-Drupal-MediaPreview-CSRF-Token":this.editor.config.get("drupalMedia").previewCsrfToken}});if(i.ok){return{label:i.headers.get("drupal-media-label"),preview:await i.text()}}return{label:this.labelError,preview:this.themeError}}_defineSchema(){this.editor.model.schema.register("drupalMedia",{inheritAllFrom:"$blockObject",allowAttributes:Object.keys(this.attrs)}),this.editor.editing.view.domConverter.blockElements.push("drupal-media")}_defineConverters(){const e=this.editor.conversion,i=this.editor.plugins.get("DrupalMediaMetadataRepository");e.for("upcast").elementToElement({view:{name:"drupal-media"},model:"drupalMedia"}).add((e=>{e.on("element:drupal-media",((e,t)=>{const[n]=t.modelRange.getItems();i.getMetadata(n).then((e=>{n&&(this.upcastDrupalMediaIsImage(n),this.editor.model.enqueueChange({isUndoable:!1},(t=>{t.setAttribute("drupalMediaType",e.type,n)})))})).catch((e=>{console.warn(e.toString())}))}),{priority:"lowest"})})),e.for("dataDowncast").elementToElement({model:"drupalMedia",view:{name:"drupal-media"}}),e.for("editingDowncast").elementToElement({model:"drupalMedia",view:(e,{writer:i})=>{const n=i.createContainerElement("figure",{class:"drupal-media"});if(!this.previewUrl){const e=i.createRawElement("div",{"data-drupal-media-preview":"unavailable"});i.insert(i.createPositionAt(n,0),e)}return i.setCustomProperty("drupalMedia",!0,n),(0,t.toWidget)(n,i,{label:Drupal.t("Media widget")})}}).add((e=>{const t=(e,t,i)=>{const n=i.writer,a=t.item,r=i.mapper.toViewElement(t.item);let o=d(r.getChildren());if(o){if("ready"!==o.getAttribute("data-drupal-media-preview"))return;n.setAttribute("data-drupal-media-preview","loading",o)}else o=n.createRawElement("div",{"data-drupal-media-preview":"loading"}),n.insert(n.createPositionAt(r,0),o);this._fetchPreview(a).then((({label:e,preview:t})=>{o&&this.editor.editing.view.change((i=>{const n=i.createRawElement("div",{"data-drupal-media-preview":"ready","aria-label":e},(e=>{e.innerHTML=t}));i.insert(i.createPositionBefore(o),n),i.remove(o)}))}))};return this.converterAttributes.forEach((i=>{e.on(`attribute:${i}:drupalMedia`,t)})),e})),e.for("editingDowncast").add((e=>{e.on("attribute:drupalElementStyleAlign:drupalMedia",((e,t,i)=>{const n={left:"drupal-media-style-align-left",right:"drupal-media-style-align-right",center:"drupal-media-style-align-center"},a=i.mapper.toViewElement(t.item),r=i.writer;n[t.attributeOldValue]&&r.removeClass(n[t.attributeOldValue],a),n[t.attributeNewValue]&&i.consumable.consume(t.item,e.name)&&r.addClass(n[t.attributeNewValue],a)}))})),Object.keys(this.attrs).forEach((t=>{const i={model:{key:t,name:"drupalMedia"},view:{name:"drupal-media",key:this.attrs[t]}};e.for("dataDowncast").attributeToAttribute(i),e.for("upcast").attributeToAttribute(i)}))}_defineListeners(){this.editor.model.on("insertContent",((e,[t])=>{a(t)&&(this.upcastDrupalMediaIsImage(t),this.upcastDrupalMediaType(t))}))}_renderElement(e){const t=this.editor.model.change((t=>{const i=t.createDocumentFragment(),n=t.cloneElement(e,!1);return["linkHref"].forEach((e=>{t.removeAttribute(e,n)})),t.append(n,i),i}));return this.editor.data.stringify(t)}static get pluginName(){return"DrupalMediaEditing"}}var g=i("ckeditor5/src/ui.js");class h extends e.Plugin{init(){const e=this.editor,t=this.editor.config.get("drupalMedia");if(!t)return;const{libraryURL:i,openDialog:n,dialogSettings:a={}}=t;i&&"function"==typeof n&&e.ui.componentFactory.add("drupalMedia",(t=>{const r=e.commands.get("insertDrupalMedia"),o=new g.ButtonView(t);return o.set({label:Drupal.t("Insert Media"),icon:'<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M19.1873 4.86414L10.2509 6.86414V7.02335H10.2499V15.5091C9.70972 15.1961 9.01793 15.1048 8.34069 15.3136C7.12086 15.6896 6.41013 16.8967 6.75322 18.0096C7.09631 19.1226 8.3633 19.72 9.58313 19.344C10.6666 19.01 11.3484 18.0203 11.2469 17.0234H11.2499V9.80173L18.1803 8.25067V14.3868C17.6401 14.0739 16.9483 13.9825 16.2711 14.1913C15.0513 14.5674 14.3406 15.7744 14.6836 16.8875C15.0267 18.0004 16.2937 18.5978 17.5136 18.2218C18.597 17.8877 19.2788 16.8982 19.1773 15.9011H19.1803V8.02687L19.1873 8.0253V4.86414Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M13.5039 0.743652H0.386932V12.1603H13.5039V0.743652ZM12.3379 1.75842H1.55289V11.1454H1.65715L4.00622 8.86353L6.06254 10.861L9.24985 5.91309L11.3812 9.22179L11.7761 8.6676L12.3379 9.45621V1.75842ZM6.22048 4.50869C6.22048 5.58193 5.35045 6.45196 4.27722 6.45196C3.20398 6.45196 2.33395 5.58193 2.33395 4.50869C2.33395 3.43546 3.20398 2.56543 4.27722 2.56543C5.35045 2.56543 6.22048 3.43546 6.22048 4.50869Z"/></svg>\n',tooltip:!0}),o.bind("isOn","isEnabled").to(r,"value","isEnabled"),this.listenTo(o,"execute",(()=>{n(i,(({attributes:t})=>{e.execute("insertDrupalMedia",t)}),a)})),o}))}}class f extends e.Plugin{static get requires(){return[t.WidgetToolbarRepository]}static get pluginName(){return"DrupalMediaToolbar"}afterInit(){const{editor:e}=this;var i;e.plugins.get(t.WidgetToolbarRepository).register("drupalMedia",{ariaLabel:Drupal.t("Drupal Media toolbar"),items:(i=e.config.get("drupalMedia.toolbar"),i.map((e=>l(e)?e.name:e))||[]),getRelatedElement:e=>s(e)})}}class b extends e.Command{refresh(){const e=o(this.editor.model.document.selection);this.isEnabled=!!e&&e.getAttribute("drupalMediaIsImage")&&e.getAttribute("drupalMediaIsImage")!==m,this.isEnabled?this.value=e.getAttribute("drupalMediaAlt"):this.value=!1}execute(e){const{model:t}=this.editor,i=o(t.document.selection);e.newValue=e.newValue.trim(),t.change((t=>{e.newValue.length>0?t.setAttribute("drupalMediaAlt",e.newValue,i):t.removeAttribute("drupalMediaAlt",i)}))}}class w extends e.Plugin{init(){this._data=new WeakMap}getMetadata(e){if(this._data.get(e))return new Promise((t=>{t(this._data.get(e))}));const t=this.editor.config.get("drupalMedia");if(!t)return new Promise(((e,t)=>{t(new Error("drupalMedia configuration is required for parsing metadata."))}));if(!e.hasAttribute("drupalMediaEntityUuid"))return new Promise(((e,t)=>{t(new Error("drupalMedia element must have drupalMediaEntityUuid attribute to retrieve metadata."))}));const{metadataUrl:i}=t;return(async e=>{const t=await fetch(e);if(t.ok)return JSON.parse(await t.text());throw new Error("Fetching media embed metadata from the server failed.")})(`${i}&${new URLSearchParams({uuid:e.getAttribute("drupalMediaEntityUuid")})}`).then((t=>(this._data.set(e,t),t)))}static get pluginName(){return"DrupalMediaMetadataRepository"}}class y extends e.Plugin{static get requires(){return[w]}static get pluginName(){return"MediaImageTextAlternativeEditing"}init(){const{editor:e,editor:{model:t,conversion:i}}=this;t.schema.extend("drupalMedia",{allowAttributes:["drupalMediaIsImage"]}),i.for("editingDowncast").add((e=>{e.on("attribute:drupalMediaIsImage",((e,t,i)=>{const{writer:n,mapper:a}=i,r=a.toViewElement(t.item);if(t.attributeNewValue!==m){const e=Array.from(r.getChildren()).find((e=>e.getCustomProperty("drupalMediaMetadataError")));return void(e&&(n.setCustomProperty("widgetLabel",e.getCustomProperty("drupalMediaOriginalWidgetLabel"),e),n.removeElement(e)))}const o=Drupal.t("Not all functionality may be available because some information could not be retrieved."),s=new g.Template({tag:"span",children:[{tag:"span",attributes:{class:"drupal-media__metadata-error-icon","data-cke-tooltip-text":o}}]}).render(),l=n.createRawElement("div",{class:"drupal-media__metadata-error"},((e,t)=>{t.setContentOf(e,s.outerHTML)}));n.setCustomProperty("drupalMediaMetadataError",!0,l);const d=r.getCustomProperty("widgetLabel");n.setCustomProperty("drupalMediaOriginalWidgetLabel",d,l),n.setCustomProperty("widgetLabel",`${d} (${o})`,r),n.insert(n.createPositionAt(r,0),l)}),{priority:"low"})})),e.commands.add("mediaImageTextAlternative",new b(this.editor))}}function v(e){const t=e.editing.view,i=g.BalloonPanelView.defaultPositions;return{target:t.domConverter.viewToDom(t.document.selection.getSelectedElement()),positions:[i.northArrowSouth,i.northArrowSouthWest,i.northArrowSouthEast,i.southArrowNorth,i.southArrowNorthWest,i.southArrowNorthEast]}}var E=i("ckeditor5/src/utils.js");class M extends g.View{constructor(t){super(t),this.focusTracker=new E.FocusTracker,this.keystrokes=new E.KeystrokeHandler,this.decorativeToggle=this._decorativeToggleView(),this.labeledInput=this._createLabeledInputView(),this.saveButtonView=this._createButton(Drupal.t("Save"),e.icons.check,"ck-button-save"),this.saveButtonView.type="submit",this.cancelButtonView=this._createButton(Drupal.t("Cancel"),e.icons.cancel,"ck-button-cancel","cancel"),this._focusables=new g.ViewCollection,this._focusCycler=new g.FocusCycler({focusables:this._focusables,focusTracker:this.focusTracker,keystrokeHandler:this.keystrokes,actions:{focusPrevious:"shift + tab",focusNext:"tab"}}),this.setTemplate({tag:"form",attributes:{class:["ck","ck-media-alternative-text-form","ck-vertical-form"],tabindex:"-1"},children:[{tag:"div",children:[this.decorativeToggle]},this.labeledInput,this.saveButtonView,this.cancelButtonView]}),(0,g.injectCssTransitionDisabler)(this)}render(){super.render(),this.keystrokes.listenTo(this.element),(0,g.submitHandler)({view:this}),[this.decorativeToggle,this.labeledInput,this.saveButtonView,this.cancelButtonView].forEach((e=>{this._focusables.add(e),this.focusTracker.add(e.element)}))}_createButton(e,t,i,n){const a=new g.ButtonView(this.locale);return a.set({label:e,icon:t,tooltip:!0}),a.extendTemplate({attributes:{class:i}}),n&&a.delegate("execute").to(this,n),a}_createLabeledInputView(){const e=new g.LabeledFieldView(this.locale,g.createLabeledInputText);return e.bind("class").to(this.decorativeToggle,"isOn",(e=>e?"ck-hidden":"")),e.label=Drupal.t("Alternative text override"),e}_decorativeToggleView(){const e=new g.SwitchButtonView(this.locale);return e.set({withText:!0,label:Drupal.t("Decorative image")}),e.on("execute",(()=>{e.isOn&&(this.labeledInput.fieldView.element.value=""),e.set("isOn",!e.isOn)})),e}}class k extends e.Plugin{static get requires(){return[g.ContextualBalloon]}static get pluginName(){return"MediaImageTextAlternativeUi"}init(){this._createButton(),this._createForm()}destroy(){super.destroy(),this._form.destroy()}_createButton(){const t=this.editor;t.ui.componentFactory.add("mediaImageTextAlternative",(i=>{const n=t.commands.get("mediaImageTextAlternative"),a=new g.ButtonView(i);return a.set({label:Drupal.t("Override media image alternative text"),icon:e.icons.lowVision,tooltip:!0}),a.bind("isVisible").to(n,"isEnabled"),this.listenTo(a,"execute",(()=>{this._showForm()})),a}))}_createForm(){const e=this.editor,t=e.editing.view.document;this._balloon=this.editor.plugins.get("ContextualBalloon"),this._form=new M(e.locale),this._form.render(),this.listenTo(this._form,"submit",(()=>{e.execute("mediaImageTextAlternative",{newValue:this._form.decorativeToggle.isOn?'""':this._form.labeledInput.fieldView.element.value}),this._hideForm(!0)})),this.listenTo(this._form,"cancel",(()=>{this._hideForm(!0)})),this._form.keystrokes.set("Esc",((e,t)=>{this._hideForm(!0),t()})),this.listenTo(e.ui,"update",(()=>{s(t.selection)?this._isVisible&&function(e){const t=e.plugins.get("ContextualBalloon");if(s(e.editing.view.document.selection)){const i=v(e);t.updatePosition(i)}}(e):this._hideForm(!0)})),(0,g.clickOutsideHandler)({emitter:this._form,activator:()=>this._isVisible,contextElements:[this._balloon.view.element],callback:()=>this._hideForm()})}_showForm(){if(this._isVisible)return;const e=this.editor,t=e.commands.get("mediaImageTextAlternative"),i=this._form.decorativeToggle,n=e.plugins.get("DrupalMediaMetadataRepository"),r=this._form.labeledInput;this._form.disableCssTransitions(),this._isInBalloon||this._balloon.add({view:this._form,position:v(e)}),i.isOn='""'===t.value,r.fieldView.element.value=t.value||"",r.fieldView.value=r.fieldView.element.value,this._form.defaultAltText="";const o=e.model.document.selection.getSelectedElement();a(o)&&n.getMetadata(o).then((e=>{this._form.defaultAltText=e.imageSourceMetadata?e.imageSourceMetadata.alt:"",r.infoText=Drupal.t(`Leave blank to use the default alternative text: "${this._form.defaultAltText}".`)})).catch((e=>{console.warn(e.toString())})),this._form.enableCssTransitions()}_hideForm(e){this._isInBalloon&&(this._form.focusTracker.isFocused&&this._form.saveButtonView.focus(),this._balloon.remove(this._form),e&&this.editor.editing.view.focus())}get _isVisible(){return this._balloon.visibleView===this._form}get _isInBalloon(){return this._balloon.hasView(this._form)}}class D extends e.Plugin{static get requires(){return[y,k]}static get pluginName(){return"MediaImageTextAlternative"}}function C(e,t,i){if(t.attributes)for(const[n,a]of Object.entries(t.attributes))e.setAttribute(n,a,i);t.styles&&e.setStyle(t.styles,i),t.classes&&e.addClass(t.classes,i)}function A(e,t,i){if(!i.consumable.consume(t.item,e.name))return;const n=i.mapper.toViewElement(t.item);C(i.writer,t.attributeNewValue,n)}class _ extends e.Plugin{constructor(e){if(super(e),!e.plugins.has("GeneralHtmlSupport"))return;e.plugins.has("DataFilter")&&e.plugins.has("DataSchema")||console.error("DataFilter and DataSchema plugins are required for Drupal Media to integrate with General HTML Support plugin.");const{schema:t}=e.model,{conversion:i}=e,n=this.editor.plugins.get("DataFilter");this.editor.plugins.get("DataSchema").registerBlockElement({model:"drupalMedia",view:"drupal-media"}),n.on("register:drupal-media",((e,a)=>{"drupalMedia"===a.model&&(t.extend("drupalMedia",{allowAttributes:["htmlLinkAttributes","htmlAttributes"]}),i.for("upcast").add(function(e){return t=>{t.on("element:drupal-media",((t,i,n)=>{function a(t,a){const r=e.processViewAttributes(t,n);r&&n.writer.setAttribute(a,r,i.modelRange)}const r=i.viewItem,o=r.parent;a(r,"htmlAttributes"),o.is("element","a")&&a(o,"htmlLinkAttributes")}),{priority:"low"})}}(n)),i.for("editingDowncast").add((e=>{e.on("attribute:linkHref:drupalMedia",((e,t,i)=>{if(!i.consumable.consume(t.item,"attribute:htmlLinkAttributes:drupalMedia"))return;const n=i.mapper.toViewElement(t.item),a=function(e,t,i){const n=e.createRangeOn(t);for(const{item:e}of n.getWalker())if(e.is("element",i))return e}(i.writer,n,"a");C(i.writer,t.item.getAttribute("htmlLinkAttributes"),a)}),{priority:"low"})})),i.for("dataDowncast").add((e=>{e.on("attribute:linkHref:drupalMedia",((e,t,i)=>{if(!i.consumable.consume(t.item,"attribute:htmlLinkAttributes:drupalMedia"))return;const n=i.mapper.toViewElement(t.item).parent;C(i.writer,t.item.getAttribute("htmlLinkAttributes"),n)}),{priority:"low"}),e.on("attribute:htmlAttributes:drupalMedia",A,{priority:"low"})})),e.stop())}))}static get pluginName(){return"DrupalMediaGeneralHtmlSupport"}}class x extends e.Plugin{static get requires(){return[p,_,h,f,D]}static get pluginName(){return"DrupalMedia"}}var V=i("ckeditor5/src/engine.js");function S(e){return Array.from(e.getChildren()).find((e=>"drupal-media"===e.name))}function T(e){return t=>{t.on(`attribute:${e.id}:drupalMedia`,((t,i,n)=>{const a=n.mapper.toViewElement(i.item);let r=Array.from(a.getChildren()).find((e=>"a"===e.name));if(r=!r&&a.is("element","a")?a:Array.from(a.getAncestors()).find((e=>"a"===e.name)),r){for(const[t,i]of(0,E.toMap)(e.attributes))n.writer.setAttribute(t,i,r);e.classes&&n.writer.addClass(e.classes,r);for(const t in e.styles)Object.prototype.hasOwnProperty.call(e.styles,t)&&n.writer.setStyle(t,e.styles[t],r)}}))}}function I(e,t){return e=>{e.on("element:a",((e,i,n)=>{const a=i.viewItem;if(!S(a))return;const r=new V.Matcher(t._createPattern()).match(a);if(!r)return;if(!n.consumable.consume(a,r.match))return;const o=i.modelCursor.nodeBefore;n.writer.setAttribute(t.id,!0,o)}),{priority:"high"})}}class L extends e.Plugin{static get requires(){return["LinkEditing","DrupalMediaEditing"]}static get pluginName(){return"DrupalLinkMediaEditing"}init(){const{editor:e}=this;e.model.schema.extend("drupalMedia",{allowAttributes:["linkHref"]}),e.conversion.for("upcast").add((e=>{e.on("element:a",((e,t,i)=>{const n=t.viewItem,a=S(n);if(!a)return;if(!i.consumable.consume(n,{attributes:["href"],name:!0}))return;const r=n.getAttribute("href");if(null===r)return;const o=i.convertItem(a,t.modelCursor);t.modelRange=o.modelRange,t.modelCursor=o.modelCursor;const s=t.modelCursor.nodeBefore;s&&s.is("element","drupalMedia")&&i.writer.setAttribute("linkHref",r,s)}),{priority:"high"})})),e.conversion.for("editingDowncast").add((e=>{e.on("attribute:linkHref:drupalMedia",((e,t,i)=>{const{writer:n}=i;if(!i.consumable.consume(t.item,e.name))return;const a=i.mapper.toViewElement(t.item),r=Array.from(a.getChildren()).find((e=>"a"===e.name));if(r)t.attributeNewValue?n.setAttribute("href",t.attributeNewValue,r):(n.move(n.createRangeIn(r),n.createPositionAt(a,0)),n.remove(r));else{const e=Array.from(a.getChildren()).find((e=>e.getAttribute("data-drupal-media-preview"))),i=n.createContainerElement("a",{href:t.attributeNewValue});n.insert(n.createPositionAt(a,0),i),n.move(n.createRangeOn(e),n.createPositionAt(i,0))}}),{priority:"high"})})),e.conversion.for("dataDowncast").add((e=>{e.on("attribute:linkHref:drupalMedia",((e,t,i)=>{const{writer:n}=i;if(!i.consumable.consume(t.item,e.name))return;const a=i.mapper.toViewElement(t.item),r=n.createContainerElement("a",{href:t.attributeNewValue});n.insert(n.createPositionBefore(a),r),n.move(n.createRangeOn(a),n.createPositionAt(r,0))}),{priority:"high"})})),this._enableManualDecorators();if(e.commands.get("link").automaticDecorators.length>0)throw new Error("The Drupal Media plugin is not compatible with automatic link decorators. To use Drupal Media, disable any plugins providing automatic link decorators.")}_enableManualDecorators(){const e=this.editor,t=e.commands.get("link");for(const i of t.manualDecorators)e.model.schema.extend("drupalMedia",{allowAttributes:i.id}),e.conversion.for("downcast").add(T(i)),e.conversion.for("upcast").add(I(0,i))}}class O extends e.Plugin{static get requires(){return["LinkEditing","LinkUI","DrupalMediaEditing"]}static get pluginName(){return"DrupalLinkMediaUi"}init(){const{editor:e}=this,t=e.editing.view.document;this.listenTo(t,"click",((t,i)=>{this._isSelectedLinkedMedia(e.model.document.selection)&&(i.preventDefault(),t.stop())}),{priority:"high"}),this._createToolbarLinkMediaButton()}_createToolbarLinkMediaButton(){const{editor:e}=this;e.ui.componentFactory.add("drupalLinkMedia",(t=>{const i=new g.ButtonView(t),n=e.plugins.get("LinkUI"),a=e.commands.get("link");return i.set({isEnabled:!0,label:Drupal.t("Link media"),icon:'<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.077 15 .991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955.741.741 0 0 1 .12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184z"/></svg>\n',keystroke:"Ctrl+K",tooltip:!0,isToggleable:!0}),i.bind("isEnabled").to(a,"isEnabled"),i.bind("isOn").to(a,"value",(e=>!!e)),this.listenTo(i,"execute",(()=>{this._isSelectedLinkedMedia(e.model.document.selection)?n._addActionsView():n._showUI(!0)})),i}))}_isSelectedLinkedMedia(e){const t=e.getSelectedElement();return!!t&&t.is("element","drupalMedia")&&t.hasAttribute("linkHref")}}class P extends e.Plugin{static get requires(){return[L,O]}static get pluginName(){return"DrupalLinkMedia"}}const B={get inline(){return{name:"inline",title:"In line",icon:e.icons.objectInline,modelElements:["imageInline"],isDefault:!0}},get alignLeft(){return{name:"alignLeft",title:"Left aligned image",icon:e.icons.objectLeft,modelElements:["imageBlock","imageInline"],className:"image-style-align-left"}},get alignBlockLeft(){return{name:"alignBlockLeft",title:"Left aligned image",icon:e.icons.objectBlockLeft,modelElements:["imageBlock"],className:"image-style-block-align-left"}},get alignCenter(){return{name:"alignCenter",title:"Centered image",icon:e.icons.objectCenter,modelElements:["imageBlock"],className:"image-style-align-center"}},get alignRight(){return{name:"alignRight",title:"Right aligned image",icon:e.icons.objectRight,modelElements:["imageBlock","imageInline"],className:"image-style-align-right"}},get alignBlockRight(){return{name:"alignBlockRight",title:"Right aligned image",icon:e.icons.objectBlockRight,modelElements:["imageBlock"],className:"image-style-block-align-right"}},get block(){return{name:"block",title:"Centered image",icon:e.icons.objectCenter,modelElements:["imageBlock"],isDefault:!0}},get side(){return{name:"side",title:"Side image",icon:e.icons.objectRight,modelElements:["imageBlock"],className:"image-style-side"}}},N=(()=>({full:e.icons.objectFullWidth,left:e.icons.objectBlockLeft,right:e.icons.objectBlockRight,center:e.icons.objectCenter,inlineLeft:e.icons.objectLeft,inlineRight:e.icons.objectRight,inline:e.icons.objectInline}))(),j=[{name:"imageStyle:wrapText",title:"Wrap text",defaultItem:"imageStyle:alignLeft",items:["imageStyle:alignLeft","imageStyle:alignRight"]},{name:"imageStyle:breakText",title:"Break text",defaultItem:"imageStyle:block",items:["imageStyle:alignBlockLeft","imageStyle:block","imageStyle:alignBlockRight"]}];function R(e){(0,E.logWarning)("image-style-configuration-definition-invalid",e)}const F={normalizeStyles:function(e){return(e.configuredStyles.options||[]).map((e=>function(e){e="string"==typeof e?B[e]?{...B[e]}:{name:e}:function(e,t){const i={...t};for(const n in e)Object.prototype.hasOwnProperty.call(t,n)||(i[n]=e[n]);return i}(B[e.name],e);"string"==typeof e.icon&&(e.icon=N[e.icon]||e.icon);return e}(e))).filter((t=>function(e,{isBlockPluginLoaded:t,isInlinePluginLoaded:i}){const{modelElements:n,name:a}=e;if(!(n&&n.length&&a))return R({style:e}),!1;{const a=[t?"imageBlock":null,i?"imageInline":null];if(!n.some((e=>a.includes(e))))return(0,E.logWarning)("image-style-missing-dependency",{style:e,missingPlugins:n.map((e=>"imageBlock"===e?"ImageBlockEditing":"ImageInlineEditing"))}),!1}return!0}(t,e)))},getDefaultStylesConfiguration:function(e,t){return e&&t?{options:["inline","alignLeft","alignRight","alignCenter","alignBlockLeft","alignBlockRight","block","side"]}:e?{options:["block","side"]}:t?{options:["inline","alignLeft","alignRight"]}:{}},getDefaultDropdownDefinitions:function(e){return e.has("ImageBlockEditing")&&e.has("ImageInlineEditing")?[...j]:[]},warnInvalidStyle:R,DEFAULT_OPTIONS:B,DEFAULT_ICONS:N,DEFAULT_DROPDOWN_DEFINITIONS:j};function U(e,t,i){for(const n of t)if(i.checkAttribute(e,n))return!0;return!1}function H(e,t,i){const n=e.getSelectedElement();if(n&&U(n,i,t))return n;let{parent:a}=e.getFirstPosition();for(;a;){if(a.is("element")&&U(a,i,t))return a;a=a.parent}return null}class $ extends e.Command{constructor(e,t){super(e),this.styles={},Object.keys(t).forEach((e=>{this.styles[e]=new Map(t[e].map((e=>[e.name,e])))})),this.modelAttributes=[];for(const e of Object.keys(t)){const t=u(e);this.modelAttributes.push(t)}}refresh(){const{editor:e}=this,t=H(e.model.document.selection,e.model.schema,this.modelAttributes);this.isEnabled=!!t,this.isEnabled?this.value=this.getValue(t):this.value=!1}getValue(e){const t={};return Object.keys(this.styles).forEach((i=>{const n=u(i);if(e.hasAttribute(n))t[i]=e.getAttribute(n);else for(const[,e]of this.styles[i])e.isDefault&&(t[i]=e.name)})),t}execute(e={}){const{editor:{model:t}}=this,{value:i,group:n}=e,a=u(n);t.change((e=>{const r=H(t.document.selection,t.schema,this.modelAttributes);!i||this.styles[n].get(i).isDefault?e.removeAttribute(a,r):e.setAttribute(a,i,r)}))}}function q(e,t){for(const i of t)if(i.name===e)return i}class W extends e.Plugin{init(){const{editor:t}=this,i=t.config.get("drupalElementStyles");this.normalizedStyles={},Object.keys(i).forEach((t=>{this.normalizedStyles[t]=i[t].map((t=>("string"==typeof t.icon&&e.icons[t.icon]&&(t.icon=e.icons[t.icon]),t.name&&(t.name=`${t.name}`),t))).filter((e=>e.isDefault||e.attributeName&&e.attributeValue?e.modelElements&&Array.isArray(e.modelElements)?!!e.name||(console.warn("drupalElementStyles options must include a name."),!1):(console.warn("drupalElementStyles options must include an array of supported modelElements."),!1):(console.warn(`${e.attributeValue} drupalElementStyles options must include attributeName and attributeValue.`),!1)))})),this._setupConversion(),t.commands.add("drupalElementStyle",new $(t,this.normalizedStyles))}_setupConversion(){const{editor:e}=this,{schema:t}=e.model;Object.keys(this.normalizedStyles).forEach((i=>{const n=u(i),a=(r=this.normalizedStyles[i],(e,t,i)=>{if(!i.consumable.consume(t.item,e.name))return;const n=q(t.attributeNewValue,r),a=q(t.attributeOldValue,r),o=i.mapper.toViewElement(t.item),s=i.writer;a&&("class"===a.attributeName?s.removeClass(a.attributeValue,o):s.removeAttribute(a.attributeName,o)),n&&("class"===n.attributeName?s.addClass(n.attributeValue,o):n.isDefault||s.setAttribute(n.attributeName,n.attributeValue,o))});var r;const o=function(e,t){const i=e.filter((e=>!e.isDefault));return(e,n,a)=>{if(!n.modelRange)return;const r=n.viewItem,o=(0,E.first)(n.modelRange.getItems());if(o&&a.schema.checkAttribute(o,t))for(const e of i)if("class"===e.attributeName)a.consumable.consume(r,{classes:e.attributeValue})&&a.writer.setAttribute(t,e.name,o);else if(a.consumable.consume(r,{attributes:[e.attributeName]}))for(const e of i)e.attributeValue===r.getAttribute(e.attributeName)&&a.writer.setAttribute(t,e.name,o)}}(this.normalizedStyles[i],n);e.editing.downcastDispatcher.on(`attribute:${n}`,a),e.data.downcastDispatcher.on(`attribute:${n}`,a);[...new Set(this.normalizedStyles[i].map((e=>e.modelElements)).flat())].forEach((e=>{t.extend(e,{allowAttributes:n})})),e.data.upcastDispatcher.on("element",o,{priority:"low"})}))}static get pluginName(){return"DrupalElementStyleEditing"}}const K=e=>e,z=(e,t)=>(e?`${e}: `:"")+t;function Z(e,t){return`drupalElementStyle:${t}:${e}`}class G extends e.Plugin{static get requires(){return[W]}init(){const{plugins:e}=this.editor,t=this.editor.config.get("drupalMedia.toolbar")||[],i=e.get("DrupalElementStyleEditing").normalizedStyles;Object.keys(i).forEach((e=>{i[e].forEach((t=>{this._createButton(t,e,i[e])}))}));t.filter(l).filter((e=>{const t=[];if(!e.display)return console.warn("dropdown configuration must include a display key specifying either listDropdown or splitButton."),!1;e.items.includes(e.defaultItem)||console.warn("defaultItem must be part of items in the dropdown configuration.");for(const i of e.items){const e=i.split(":")[1];t.push(e)}return!!t.every((e=>e===t[0]))||(console.warn("dropdown configuration should only contain buttons from one group."),!1)})).forEach((e=>{if(e.items.length>=2){const t=e.name.split(":")[1];switch(e.display){case"splitButton":this._createDropdown(e,i[t]);break;case"listDropdown":this._createListDropdown(e,i[t])}}}))}updateOptionVisibility(e,t,i,n){const{selection:a}=this.editor.model.document,r={};r[n]=e;const o=a?a.getSelectedElement():H(a,this.editor.model.schema,r),s=e.filter((function(e){for(const[t,i]of(0,E.toMap)(e.modelAttributes))if(o&&o.hasAttribute(t))return i.includes(o.getAttribute(t));return!0}));i.hasOwnProperty("model")?s.includes(t)?i.model.set({class:""}):i.model.set({class:"ck-hidden"}):s.includes(t)?i.set({class:""}):i.set({class:"ck-hidden"})}_createDropdown(e,t){const i=this.editor.ui.componentFactory;i.add(e.name,(n=>{let a;const{defaultItem:r,items:o,title:s}=e,l=o.filter((e=>{const i=e.split(":")[1];return t.find((({name:t})=>Z(t,i)===e))})).map((e=>{const t=i.create(e);return e===r&&(a=t),t}));o.length!==l.length&&F.warnInvalidStyle({dropdown:e});const d=(0,g.createDropdown)(n,g.SplitButtonView),u=d.buttonView;return(0,g.addToolbarToDropdown)(d,l),u.set({label:z(s,a.label),class:null,tooltip:!0}),u.bind("icon").toMany(l,"isOn",((...e)=>{const t=e.findIndex(K);return t<0?a.icon:l[t].icon})),u.bind("label").toMany(l,"isOn",((...e)=>{const t=e.findIndex(K);return z(s,t<0?a.label:l[t].label)})),u.bind("isOn").toMany(l,"isOn",((...e)=>e.some(K))),u.bind("class").toMany(l,"isOn",((...e)=>e.some(K)?"ck-splitbutton_flatten":null)),u.on("execute",(()=>{l.some((({isOn:e})=>e))?d.isOpen=!d.isOpen:a.fire("execute")})),d.bind("isEnabled").toMany(l,"isEnabled",((...e)=>e.some(K))),d}))}_createButton(e,t,i){const n=e.name;this.editor.ui.componentFactory.add(Z(n,t),(a=>{const r=this.editor.commands.get("drupalElementStyle"),o=new g.ButtonView(a);return o.set({label:e.title,icon:e.icon,tooltip:!0,isToggleable:!0}),o.bind("isEnabled").to(r,"isEnabled"),o.bind("isOn").to(r,"value",(e=>e&&e[t]===n)),o.on("execute",this._executeCommand.bind(this,n,t)),this.listenTo(this.editor.ui,"update",(()=>{this.updateOptionVisibility(i,e,o,t)})),o}))}getDropdownListItemDefinitions(e,t,i){const n=new E.Collection;return e.forEach((t=>{const a={type:"button",model:new g.ViewModel({group:i,commandValue:t.name,label:t.title,withText:!0,class:""})};n.add(a),this.listenTo(this.editor.ui,"update",(()=>{this.updateOptionVisibility(e,t,a,i)}))})),n}_createListDropdown(e,t){const i=this.editor.ui.componentFactory;i.add(e.name,(n=>{let a;const{defaultItem:r,items:o,title:s,defaultText:l}=e,d=e.name.split(":")[1],u=o.filter((e=>t.find((({name:t})=>Z(t,d)===e)))).map((e=>{const t=i.create(e);return e===r&&(a=t),t}));o.length!==u.length&&F.warnInvalidStyle({dropdown:e});const c=(0,g.createDropdown)(n,g.DropdownButtonView),m=c.buttonView;m.set({label:z(s,a.label),class:null,tooltip:l,withText:!0});const p=this.editor.commands.get("drupalElementStyle");return m.bind("label").to(p,"value",(e=>{if(e?.[d])for(const i of t)if(i.name===e[d])return i.title;return l})),c.bind("isOn").to(p),c.bind("isEnabled").to(this),(0,g.addListToDropdown)(c,this.getDropdownListItemDefinitions(t,p,d)),this.listenTo(c,"execute",(e=>{this._executeCommand(e.source.commandValue,e.source.group)})),c}))}_executeCommand(e,t){this.editor.execute("drupalElementStyle",{value:e,group:t}),this.editor.editing.view.focus()}static get pluginName(){return"DrupalElementStyleUi"}}class J extends e.Plugin{static get requires(){return[W,G]}static get pluginName(){return"DrupalElementStyle"}}function X(e){const t=e.getFirstPosition().findAncestor("caption");return t&&a(t.parent)?t:null}function Q(e){for(const t of e.getChildren())if(t&&t.is("element","caption"))return t;return null}class Y extends e.Command{refresh(){const e=this.editor.model.document.selection,t=e.getSelectedElement();if(!t)return this.isEnabled=!!o(e),void(this.value=!!X(e));this.isEnabled=a(t),this.isEnabled?this.value=!!Q(t):this.value=!1}execute(e={}){const{focusCaptionOnShow:t}=e;this.editor.model.change((e=>{this.value?this._hideDrupalMediaCaption(e):this._showDrupalMediaCaption(e,t)}))}_showDrupalMediaCaption(e,t){const i=this.editor.model.document.selection,n=this.editor.plugins.get("DrupalMediaCaptionEditing"),a=o(i),r=n._getSavedCaption(a)||e.createElement("caption");e.append(r,a),t&&e.setSelection(r,"in")}_hideDrupalMediaCaption(e){const t=this.editor,i=t.model.document.selection,n=t.plugins.get("DrupalMediaCaptionEditing");let a,r=i.getSelectedElement();r?a=Q(r):(a=X(i),r=o(i)),n._saveCaption(r,a),e.setSelection(r,"on"),e.remove(a)}}class ee extends e.Plugin{static get requires(){return[]}static get pluginName(){return"DrupalMediaCaptionEditing"}constructor(e){super(e),this._savedCaptionsMap=new WeakMap}init(){const e=this.editor,t=e.model.schema;t.isRegistered("caption")?t.extend("caption",{allowIn:"drupalMedia"}):t.register("caption",{allowIn:"drupalMedia",allowContentOf:"$block",isLimit:!0}),e.commands.add("toggleMediaCaption",new Y(e)),this._setupConversion()}_setupConversion(){const e=this.editor,i=e.editing.view;var n;e.conversion.for("upcast").add(function(e){const t=(t,i,n)=>{const{viewItem:a}=i,{writer:r,consumable:o}=n;if(!i.modelRange||!o.consume(a,{attributes:["data-caption"]}))return;const s=r.createElement("caption"),l=i.modelRange.start.nodeAfter,d=e.data.processor.toView(a.getAttribute("data-caption"));n.consumable.constructor.createFrom(d,n.consumable),n.convertChildren(d,s),r.append(s,l)};return e=>{e.on("element:drupal-media",t,{priority:"low"})}}(e)),e.conversion.for("editingDowncast").elementToElement({model:"caption",view:(e,{writer:n})=>{if(!a(e.parent))return null;const r=n.createEditableElement("figcaption");return r.placeholder=Drupal.t("Enter media caption"),(0,V.enablePlaceholder)({view:i,element:r,keepOnFocus:!0}),(0,t.toWidgetEditable)(r,n)}}),e.editing.mapper.on("modelToViewPosition",(n=i,(e,t)=>{const i=t.modelPosition,r=i.parent;if(!a(r))return;const o=t.mapper.toViewElement(r);t.viewPosition=n.createPositionAt(o,i.offset+1)})),e.conversion.for("dataDowncast").add(function(e){return t=>{t.on("insert:caption",((t,i,n)=>{const{consumable:r,writer:o,mapper:s}=n;if(!a(i.item.parent)||!r.consume(i.item,"insert"))return;const l=e.model.createRangeIn(i.item),d=o.createDocumentFragment();s.bindElements(i.item,d);for(const{item:t}of Array.from(l)){const i={item:t,range:e.model.createRangeOn(t)},a=`insert:${t.name||"$text"}`;e.data.downcastDispatcher.fire(a,i,n);for(const a of t.getAttributeKeys())Object.assign(i,{attributeKey:a,attributeOldValue:null,attributeNewValue:i.item.getAttribute(a)}),e.data.downcastDispatcher.fire(`attribute:${a}`,i,n)}for(const e of o.createRangeIn(d).getItems())s.unbindViewElement(e);s.unbindViewElement(d);const u=e.data.processor.toData(d);if(u){const e=s.toViewElement(i.item.parent);o.setAttribute("data-caption",u,e)}}))}}(e))}_getSavedCaption(e){const t=this._savedCaptionsMap.get(e);return t?V.Element.fromJSON(t):null}_saveCaption(e,t){this._savedCaptionsMap.set(e,t.toJSON())}}class te extends e.Plugin{static get requires(){return[]}static get pluginName(){return"DrupalMediaCaptionUI"}init(){const{editor:t}=this,i=t.editing.view;t.ui.componentFactory.add("toggleDrupalMediaCaption",(n=>{const a=new g.ButtonView(n),r=t.commands.get("toggleMediaCaption");return a.set({label:Drupal.t("Caption media"),icon:e.icons.caption,tooltip:!0,isToggleable:!0}),a.bind("isOn","isEnabled").to(r,"value","isEnabled"),a.bind("label").to(r,"value",(e=>e?Drupal.t("Toggle caption off"):Drupal.t("Toggle caption on"))),this.listenTo(a,"execute",(()=>{t.execute("toggleMediaCaption",{focusCaptionOnShow:!0});const e=X(t.model.document.selection);if(e){const n=t.editing.mapper.toViewElement(e);i.scrollToTheSelection(),i.change((e=>{e.addClass("drupal-media__caption_highlighted",n)}))}t.editing.view.focus()})),a}))}}class ie extends e.Plugin{static get requires(){return[ee,te]}static get pluginName(){return"DrupalMediaCaption"}}const ne={DrupalMedia:x,MediaImageTextAlternative:D,MediaImageTextAlternativeEditing:y,MediaImageTextAlternativeUi:k,DrupalLinkMedia:P,DrupalMediaCaption:ie,DrupalElementStyle:J}})(),n=n.default})()));
\ No newline at end of file +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.CKEditor5=e():(t.CKEditor5=t.CKEditor5||{},t.CKEditor5.drupalMedia=e())}(globalThis,(()=>(()=>{var t={"ckeditor5/src/core.js":(t,e,a)=>{t.exports=a("dll-reference CKEditor5.dll")("./src/core.js")},"ckeditor5/src/engine.js":(t,e,a)=>{t.exports=a("dll-reference CKEditor5.dll")("./src/engine.js")},"ckeditor5/src/icons.js":(t,e,a)=>{t.exports=a("dll-reference CKEditor5.dll")("./src/icons.js")},"ckeditor5/src/ui.js":(t,e,a)=>{t.exports=a("dll-reference CKEditor5.dll")("./src/ui.js")},"ckeditor5/src/utils.js":(t,e,a)=>{t.exports=a("dll-reference CKEditor5.dll")("./src/utils.js")},"ckeditor5/src/widget.js":(t,e,a)=>{t.exports=a("dll-reference CKEditor5.dll")("./src/widget.js")},"dll-reference CKEditor5.dll":t=>{"use strict";t.exports=CKEditor5.dll}},e={};function a(i){var l=e[i];if(void 0!==l)return l.exports;var n=e[i]={exports:{}};return t[i](n,n.exports,a),n.exports}a.d=(t,e)=>{for(var i in e)a.o(e,i)&&!a.o(t,i)&&Object.defineProperty(t,i,{enumerable:!0,get:e[i]})},a.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),a.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})};var i={};return(()=>{"use strict";a.d(i,{default:()=>hi});var t={};a.r(t),a.d(t,{IconAccessibility:()=>V,IconAddComment:()=>f,IconAlignBottom:()=>x,IconAlignCenter:()=>Z,IconAlignJustify:()=>b,IconAlignLeft:()=>L,IconAlignMiddle:()=>C,IconAlignRight:()=>I,IconAlignTop:()=>y,IconArrowDown:()=>B,IconArrowUp:()=>A,IconBold:()=>E,IconBookmark:()=>D,IconBookmarkInline:()=>k,IconBookmarkMedium:()=>S,IconBookmarkSmall:()=>_,IconBrowseFiles:()=>T,IconBulletedList:()=>O,IconCancel:()=>P,IconCaption:()=>j,IconCaseChange:()=>R,IconCheck:()=>F,IconChevronDown:()=>N,IconChevronUp:()=>U,IconCkboxImageEdit:()=>W,IconCode:()=>q,IconCodeBlock:()=>$,IconCog:()=>K,IconColorPalette:()=>G,IconColorTileCheck:()=>J,IconCommentsArchive:()=>Q,IconContentLock:()=>X,IconContentUnlock:()=>Y,IconCopy:()=>tt,IconDragHandle:()=>et,IconDragIndicator:()=>at,IconDropbox:()=>it,IconDropdownArrow:()=>lt,IconEditComment:()=>nt,IconEmoji:()=>ot,IconEraser:()=>st,IconError:()=>ht,IconExportPdf:()=>rt,IconExportWord:()=>vt,IconFacebook:()=>ct,IconFindReplace:()=>dt,IconFontBackground:()=>mt,IconFontColor:()=>gt,IconFontFamily:()=>pt,IconFontSize:()=>wt,IconFullscreenEnter:()=>ut,IconFullscreenLeave:()=>Mt,IconGoogleDrive:()=>Ht,IconGooglePhotos:()=>zt,IconHeading1:()=>Vt,IconHeading2:()=>ft,IconHeading3:()=>xt,IconHeading4:()=>Zt,IconHeading5:()=>bt,IconHeading6:()=>Lt,IconHistory:()=>Ct,IconHorizontalLine:()=>It,IconHtml:()=>yt,IconImage:()=>At,IconImageAssetManager:()=>Bt,IconImageUpload:()=>Et,IconImageUrl:()=>kt,IconImportExport:()=>Dt,IconImportWord:()=>St,IconIndent:()=>_t,IconInsertMergeField:()=>Tt,IconItalic:()=>Ot,IconLegalStyleList:()=>Pt,IconLink:()=>jt,IconListStyleCircle:()=>Rt,IconListStyleDecimal:()=>Nt,IconListStyleDecimalLeadingZero:()=>Ft,IconListStyleDisc:()=>Ut,IconListStyleLowerLatin:()=>Wt,IconListStyleLowerRoman:()=>$t,IconListStyleSquare:()=>qt,IconListStyleUpperLatin:()=>Kt,IconListStyleUpperRoman:()=>Gt,IconLocal:()=>Jt,IconLoupe:()=>Qt,IconLowVision:()=>Xt,IconMagicWand:()=>Yt,IconMarker:()=>te,IconMedia:()=>ae,IconMediaPlaceholder:()=>ee,IconMultiLevelList:()=>ie,IconNextArrow:()=>le,IconNotification:()=>ne,IconNumberedList:()=>oe,IconObjectCenter:()=>se,IconObjectFullWidth:()=>he,IconObjectInline:()=>ce,IconObjectInlineLeft:()=>re,IconObjectInlineRight:()=>ve,IconObjectLeft:()=>de,IconObjectRight:()=>me,IconObjectSizeCustom:()=>ge,IconObjectSizeFull:()=>pe,IconObjectSizeLarge:()=>we,IconObjectSizeMedium:()=>ue,IconObjectSizeSmall:()=>Me,IconOneDrive:()=>He,IconOutdent:()=>ze,IconPageBreak:()=>Ve,IconPaginationExample:()=>fe,IconPaintRoller:()=>be,IconPaintRollerCursorDefault:()=>xe,IconPaintRollerCursorText:()=>Ze,IconParagraph:()=>Le,IconPen:()=>Ie,IconPencil:()=>Ce,IconPilcrow:()=>ye,IconPlay:()=>Be,IconPlus:()=>Ae,IconPreviewMergeFields:()=>Ee,IconPreviousArrow:()=>ke,IconPrint:()=>De,IconProjectLogo:()=>Se,IconQuote:()=>_e,IconRedo:()=>Te,IconRemove:()=>je,IconRemoveComment:()=>Oe,IconRemoveFormat:()=>Pe,IconReturnArrow:()=>Re,IconRevisionHistory:()=>Fe,IconRobotPencil:()=>Ne,IconSelectAll:()=>Ue,IconSettings:()=>We,IconShowBlocks:()=>$e,IconSource:()=>qe,IconSpecialCharacters:()=>Ke,IconStrikethrough:()=>Ge,IconSubmit:()=>Je,IconSubscript:()=>Qe,IconSuperscript:()=>Xe,IconTable:()=>na,IconTableCellProperties:()=>Ye,IconTableColumn:()=>ta,IconTableLayout:()=>oa,IconTableMergeCell:()=>ea,IconTableOfContents:()=>aa,IconTableProperties:()=>ia,IconTableRow:()=>la,IconTemplate:()=>ha,IconTemplateGeneric:()=>sa,IconText:()=>va,IconTextAlternative:()=>ra,IconThreeVerticalDots:()=>ca,IconTodoList:()=>da,IconTrackChanges:()=>pa,IconTrackChangesAccept:()=>ma,IconTrackChangesDiscard:()=>ga,IconUnderline:()=>wa,IconUndo:()=>ua,IconUnlink:()=>Ma,IconUploadcareImageEdit:()=>Ha,IconUploadcareImageUpload:()=>za,IconUploadcareLink:()=>Va,IconUser:()=>fa,IconWarning:()=>xa});var e=a("ckeditor5/src/core.js"),l=a("ckeditor5/src/widget.js");function n(t){return!!t&&t.is("element","drupalMedia")}function o(t){return(0,l.isWidget)(t)&&!!t.getCustomProperty("drupalMedia")}function s(t){const e=t.getSelectedElement();return n(e)?e:t.getFirstPosition().findAncestor("drupalMedia")}function h(t){const e=t.getSelectedElement();if(e&&o(e))return e;if(null===t.getFirstPosition())return null;let a=t.getFirstPosition().parent;for(;a;){if(a.is("element")&&o(a))return a;a=a.parent}return null}function r(t){const e=typeof t;return null!=t&&("object"===e||"function"===e)}function v(t){for(const e of t){if(e.hasAttribute("data-drupal-media-preview"))return e;if(e.childCount){const t=v(e.getChildren());if(t)return t}}return null}function c(t){return`drupalElementStyle${t[0].toUpperCase()+t.substring(1)}`}class d extends e.Command{execute(t){const e=this.editor.plugins.get("DrupalMediaEditing"),a=Object.entries(e.attrs).reduce(((t,[e,a])=>(t[a]=e,t)),{}),i=Object.keys(t).reduce(((e,i)=>(a[i]&&(e[a[i]]=t[i]),e)),{});if(this.editor.plugins.has("DrupalElementStyleEditing")){const e=this.editor.plugins.get("DrupalElementStyleEditing"),{normalizedStyles:a}=e;for(const l of Object.keys(a))for(const a of e.normalizedStyles[l])if(t[a.attributeName]&&a.attributeValue===t[a.attributeName]){const t=c(l);i[t]=a.name}}this.editor.model.change((t=>{this.editor.model.insertObject(function(t,e){return t.createElement("drupalMedia",e)}(t,i))}))}refresh(){const t=this.editor.model,e=t.document.selection,a=t.schema.findAllowedParent(e.getFirstPosition(),"drupalMedia");this.isEnabled=null!==a}}const m="METADATA_ERROR";class g extends e.Plugin{static get requires(){return[l.Widget]}constructor(t){super(t),this.attrs={drupalMediaAlt:"alt",drupalMediaEntityType:"data-entity-type",drupalMediaEntityUuid:"data-entity-uuid"},this.converterAttributes=["drupalMediaEntityUuid","drupalElementStyleViewMode","drupalMediaEntityType","drupalMediaAlt"]}init(){const t=this.editor.config.get("drupalMedia");if(!t)return;const{previewURL:e,themeError:a}=t;this.previewUrl=e,this.labelError=Drupal.t("Preview failed"),this.themeError=a||`\n <p>${Drupal.t("An error occurred while trying to preview the media. Save your work and reload this page.")}<p>\n `,this._defineSchema(),this._defineConverters(),this._defineListeners(),this.editor.commands.add("insertDrupalMedia",new d(this.editor))}upcastDrupalMediaIsImage(t){const{model:e,plugins:a}=this.editor;a.get("DrupalMediaMetadataRepository").getMetadata(t).then((a=>{t&&e.enqueueChange({isUndoable:!1},(e=>{e.setAttribute("drupalMediaIsImage",!!a.imageSourceMetadata,t)}))})).catch((a=>{t&&(console.warn(a.toString()),e.enqueueChange({isUndoable:!1},(e=>{e.setAttribute("drupalMediaIsImage",m,t)})))}))}upcastDrupalMediaType(t){this.editor.plugins.get("DrupalMediaMetadataRepository").getMetadata(t).then((e=>{t&&this.editor.model.enqueueChange({isUndoable:!1},(a=>{a.setAttribute("drupalMediaType",e.type,t)}))})).catch((e=>{t&&(console.warn(e.toString()),this.editor.model.enqueueChange({isUndoable:!1},(e=>{e.setAttribute("drupalMediaType",m,t)})))}))}async _fetchPreview(t){const e={text:this._renderElement(t),uuid:t.getAttribute("drupalMediaEntityUuid")},a=await fetch(`${this.previewUrl}?${new URLSearchParams(e)}`,{headers:{"X-Drupal-MediaPreview-CSRF-Token":this.editor.config.get("drupalMedia").previewCsrfToken}});if(a.ok){return{label:a.headers.get("drupal-media-label"),preview:await a.text()}}return{label:this.labelError,preview:this.themeError}}_defineSchema(){this.editor.model.schema.register("drupalMedia",{inheritAllFrom:"$blockObject",allowAttributes:Object.keys(this.attrs)}),this.editor.editing.view.domConverter.blockElements.push("drupal-media")}_defineConverters(){const t=this.editor.conversion,e=this.editor.plugins.get("DrupalMediaMetadataRepository");t.for("upcast").elementToElement({view:{name:"drupal-media"},model:"drupalMedia"}).add((t=>{t.on("element:drupal-media",((t,a)=>{const[i]=a.modelRange.getItems();e.getMetadata(i).then((t=>{i&&(this.upcastDrupalMediaIsImage(i),this.editor.model.enqueueChange({isUndoable:!1},(e=>{e.setAttribute("drupalMediaType",t.type,i)})))})).catch((t=>{console.warn(t.toString())}))}),{priority:"lowest"})})),t.for("dataDowncast").elementToElement({model:"drupalMedia",view:{name:"drupal-media"}}),t.for("editingDowncast").elementToElement({model:"drupalMedia",view:(t,{writer:e})=>{const a=e.createContainerElement("figure",{class:"drupal-media"});if(!this.previewUrl){const t=e.createRawElement("div",{"data-drupal-media-preview":"unavailable"});e.insert(e.createPositionAt(a,0),t)}return e.setCustomProperty("drupalMedia",!0,a),(0,l.toWidget)(a,e,{label:Drupal.t("Media widget")})}}).add((t=>{const e=(t,e,a)=>{const i=a.writer,l=e.item,n=a.mapper.toViewElement(e.item);let o=v(n.getChildren());if(o){if("ready"!==o.getAttribute("data-drupal-media-preview"))return;i.setAttribute("data-drupal-media-preview","loading",o)}else o=i.createRawElement("div",{"data-drupal-media-preview":"loading"}),i.insert(i.createPositionAt(n,0),o);this._fetchPreview(l).then((({label:t,preview:e})=>{o&&this.editor.editing.view.change((a=>{const i=a.createRawElement("div",{"data-drupal-media-preview":"ready","aria-label":t},(t=>{t.innerHTML=e}));a.insert(a.createPositionBefore(o),i),a.remove(o)}))}))};return this.converterAttributes.forEach((a=>{t.on(`attribute:${a}:drupalMedia`,e)})),t})),t.for("editingDowncast").add((t=>{t.on("attribute:drupalElementStyleAlign:drupalMedia",((t,e,a)=>{const i={left:"drupal-media-style-align-left",right:"drupal-media-style-align-right",center:"drupal-media-style-align-center"},l=a.mapper.toViewElement(e.item),n=a.writer;i[e.attributeOldValue]&&n.removeClass(i[e.attributeOldValue],l),i[e.attributeNewValue]&&a.consumable.consume(e.item,t.name)&&n.addClass(i[e.attributeNewValue],l)}))})),Object.keys(this.attrs).forEach((e=>{const a={model:{key:e,name:"drupalMedia"},view:{name:"drupal-media",key:this.attrs[e]}};t.for("dataDowncast").attributeToAttribute(a),t.for("upcast").attributeToAttribute(a)}))}_defineListeners(){this.editor.model.on("insertContent",((t,[e])=>{n(e)&&(this.upcastDrupalMediaIsImage(e),this.upcastDrupalMediaType(e))}))}_renderElement(t){const e=this.editor.model.change((e=>{const a=e.createDocumentFragment(),i=e.cloneElement(t,!1);return["linkHref"].forEach((t=>{e.removeAttribute(t,i)})),e.append(i,a),a}));return this.editor.data.stringify(e)}static get pluginName(){return"DrupalMediaEditing"}}var p=a("ckeditor5/src/ui.js");class w extends e.Plugin{init(){const t=this.editor,e=this.editor.config.get("drupalMedia");if(!e)return;const{libraryURL:a,openDialog:i,dialogSettings:l={}}=e;a&&"function"==typeof i&&t.ui.componentFactory.add("drupalMedia",(e=>{const n=t.commands.get("insertDrupalMedia"),o=new p.ButtonView(e);return o.set({label:Drupal.t("Insert Media"),icon:'<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M19.1873 4.86414L10.2509 6.86414V7.02335H10.2499V15.5091C9.70972 15.1961 9.01793 15.1048 8.34069 15.3136C7.12086 15.6896 6.41013 16.8967 6.75322 18.0096C7.09631 19.1226 8.3633 19.72 9.58313 19.344C10.6666 19.01 11.3484 18.0203 11.2469 17.0234H11.2499V9.80173L18.1803 8.25067V14.3868C17.6401 14.0739 16.9483 13.9825 16.2711 14.1913C15.0513 14.5674 14.3406 15.7744 14.6836 16.8875C15.0267 18.0004 16.2937 18.5978 17.5136 18.2218C18.597 17.8877 19.2788 16.8982 19.1773 15.9011H19.1803V8.02687L19.1873 8.0253V4.86414Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M13.5039 0.743652H0.386932V12.1603H13.5039V0.743652ZM12.3379 1.75842H1.55289V11.1454H1.65715L4.00622 8.86353L6.06254 10.861L9.24985 5.91309L11.3812 9.22179L11.7761 8.6676L12.3379 9.45621V1.75842ZM6.22048 4.50869C6.22048 5.58193 5.35045 6.45196 4.27722 6.45196C3.20398 6.45196 2.33395 5.58193 2.33395 4.50869C2.33395 3.43546 3.20398 2.56543 4.27722 2.56543C5.35045 2.56543 6.22048 3.43546 6.22048 4.50869Z"/></svg>\n',tooltip:!0}),o.bind("isOn","isEnabled").to(n,"value","isEnabled"),this.listenTo(o,"execute",(()=>{i(a,(({attributes:e})=>{t.execute("insertDrupalMedia",e)}),l)})),o}))}}class u extends e.Plugin{static get requires(){return[l.WidgetToolbarRepository]}static get pluginName(){return"DrupalMediaToolbar"}afterInit(){const{editor:t}=this;var e;t.plugins.get(l.WidgetToolbarRepository).register("drupalMedia",{ariaLabel:Drupal.t("Drupal Media toolbar"),items:(e=t.config.get("drupalMedia.toolbar"),e.map((t=>r(t)?t.name:t))||[]),getRelatedElement:t=>h(t)})}}class M extends e.Command{refresh(){const t=s(this.editor.model.document.selection);this.isEnabled=t?.getAttribute("drupalMediaIsImage")&&t.getAttribute("drupalMediaIsImage")!==m,this.isEnabled?this.value=t.getAttribute("drupalMediaAlt"):this.value=!1}execute(t){const{model:e}=this.editor,a=s(e.document.selection);t.newValue=t.newValue.trim(),e.change((e=>{t.newValue.length>0?e.setAttribute("drupalMediaAlt",t.newValue,a):e.removeAttribute("drupalMediaAlt",a)}))}}class H extends e.Plugin{init(){this._data=new WeakMap}getMetadata(t){if(this._data.get(t))return new Promise((e=>{e(this._data.get(t))}));const e=this.editor.config.get("drupalMedia");if(!e)return new Promise(((t,e)=>{e(new Error("drupalMedia configuration is required for parsing metadata."))}));if(!t.hasAttribute("drupalMediaEntityUuid"))return new Promise(((t,e)=>{e(new Error("drupalMedia element must have drupalMediaEntityUuid attribute to retrieve metadata."))}));const{metadataUrl:a}=e;return(async t=>{const e=await fetch(t);if(e.ok)return JSON.parse(await e.text());throw new Error("Fetching media embed metadata from the server failed.")})(`${a}&${new URLSearchParams({uuid:t.getAttribute("drupalMediaEntityUuid")})}`).then((e=>(this._data.set(t,e),e)))}static get pluginName(){return"DrupalMediaMetadataRepository"}}class z extends e.Plugin{static get requires(){return[H]}static get pluginName(){return"MediaImageTextAlternativeEditing"}init(){const{editor:t,editor:{model:e,conversion:a}}=this;e.schema.extend("drupalMedia",{allowAttributes:["drupalMediaIsImage"]}),a.for("editingDowncast").add((t=>{t.on("attribute:drupalMediaIsImage",((t,e,a)=>{const{writer:i,mapper:l}=a,n=l.toViewElement(e.item);if(e.attributeNewValue!==m){const t=Array.from(n.getChildren()).find((t=>t.getCustomProperty("drupalMediaMetadataError")));return void(t&&(i.setCustomProperty("widgetLabel",t.getCustomProperty("drupalMediaOriginalWidgetLabel"),t),i.removeElement(t)))}const o=Drupal.t("Not all functionality may be available because some information could not be retrieved."),s=new p.Template({tag:"span",children:[{tag:"span",attributes:{class:"drupal-media__metadata-error-icon","data-cke-tooltip-text":o}}]}).render(),h=i.createRawElement("div",{class:"drupal-media__metadata-error"},((t,e)=>{e.setContentOf(t,s.outerHTML)}));i.setCustomProperty("drupalMediaMetadataError",!0,h);const r=n.getCustomProperty("widgetLabel");i.setCustomProperty("drupalMediaOriginalWidgetLabel",r,h),i.setCustomProperty("widgetLabel",`${r} (${o})`,n),i.insert(i.createPositionAt(n,0),h)}),{priority:"low"})})),t.commands.add("mediaImageTextAlternative",new M(this.editor))}}const V='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 6.628a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/><path d="M8.5 9.125a.3.3 0 0 0-.253-.296L5.11 8.327a.75.75 0 1 1 .388-1.449l4.04.716c.267.072.624.08.893.009l4.066-.724a.75.75 0 1 1 .388 1.45l-3.132.5a.3.3 0 0 0-.253.296v1.357a.3.3 0 0 0 .018.102l1.615 4.438a.75.75 0 0 1-1.41.513l-1.35-3.71a.3.3 0 0 0-.281-.197h-.209a.3.3 0 0 0-.282.198l-1.35 3.711a.75.75 0 0 1-1.41-.513l1.64-4.509a.3.3 0 0 0 .019-.103V9.125Z"/><path clip-rule="evenodd" d="M10 18.5a8.5 8.5 0 1 1 0-17 8.5 8.5 0 0 1 0 17Zm0 1.5c5.523 0 10-4.477 10-10S15.523 0 10 0 0 4.477 0 10s4.477 10 10 10Z"/></svg>',f='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M4 1.5h12A3.5 3.5 0 0 1 19.5 5v8l-.005.192a3.5 3.5 0 0 1-2.927 3.262l-.062.008v1.813a1.5 1.5 0 0 1-2.193 1.33l-.371-.193-.38-.212a13.452 13.452 0 0 1-3.271-2.63l-.062-.07H4A3.5 3.5 0 0 1 .5 13V5A3.5 3.5 0 0 1 4 1.5ZM4 3a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h6.924a11.917 11.917 0 0 0 3.71 3.081l.372.194v-3.268L14.962 15H16a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4Z"/><path d="M9.75 5a.75.75 0 0 0-.75.75v2.5H6.5a.75.75 0 0 0 0 1.5H9v2.5a.75.75 0 0 0 1.5 0v-2.5H13a.75.75 0 0 0 0-1.5h-2.5v-2.5A.75.75 0 0 0 9.75 5Z"/></svg>',x='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m9.239 13.938-2.88-1.663a.75.75 0 0 1 .75-1.3L9 12.067V4.75a.75.75 0 1 1 1.5 0v7.318l1.89-1.093a.75.75 0 0 1 .75 1.3l-2.879 1.663a.752.752 0 0 1-.511.187.752.752 0 0 1-.511-.187zM4.25 17a.75.75 0 1 1 0-1.5h10.5a.75.75 0 0 1 0 1.5H4.25z"/></svg>',Z='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 8c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm2.286 4c0 .414.336.75.75.75h9.928a.75.75 0 1 0 0-1.5H5.036a.75.75 0 0 0-.75.75zm0-8c0 .414.336.75.75.75h9.928a.75.75 0 1 0 0-1.5H5.036a.75.75 0 0 0-.75.75z"/></svg>',b='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 8c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 4c0 .414.336.75.75.75h9.929a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0-8c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75z"/></svg>',L='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 8c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0 4c0 .414.336.75.75.75h9.929a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm0-8c0 .414.336.75.75.75h9.929a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75z"/></svg>',C='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M9.75 11.875a.752.752 0 0 1 .508.184l2.883 1.666a.75.75 0 0 1-.659 1.344l-.091-.044-1.892-1.093.001 4.318a.75.75 0 1 1-1.5 0v-4.317l-1.89 1.092a.75.75 0 0 1-.75-1.3l2.879-1.663a.752.752 0 0 1 .51-.187zM15.25 9a.75.75 0 1 1 0 1.5H4.75a.75.75 0 1 1 0-1.5h10.5zM9.75.375a.75.75 0 0 1 .75.75v4.318l1.89-1.093.092-.045a.75.75 0 0 1 .659 1.344l-2.883 1.667a.752.752 0 0 1-.508.184.752.752 0 0 1-.511-.187L6.359 5.65a.75.75 0 0 1 .75-1.299L9 5.442V1.125a.75.75 0 0 1 .75-.75z"/></svg>',I='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M18 3.75a.75.75 0 0 1-.75.75H2.75a.75.75 0 1 1 0-1.5h14.5a.75.75 0 0 1 .75.75zm0 8a.75.75 0 0 1-.75.75H2.75a.75.75 0 1 1 0-1.5h14.5a.75.75 0 0 1 .75.75zm0 4a.75.75 0 0 1-.75.75H7.321a.75.75 0 1 1 0-1.5h9.929a.75.75 0 0 1 .75.75zm0-8a.75.75 0 0 1-.75.75H7.321a.75.75 0 1 1 0-1.5h9.929a.75.75 0 0 1 .75.75z"/></svg>',y='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m10.261 7.062 2.88 1.663a.75.75 0 0 1-.75 1.3L10.5 8.933v7.317a.75.75 0 1 1-1.5 0V8.932l-1.89 1.093a.75.75 0 0 1-.75-1.3l2.879-1.663a.752.752 0 0 1 .511-.187.752.752 0 0 1 .511.187zM15.25 4a.75.75 0 1 1 0 1.5H4.75a.75.75 0 0 1 0-1.5h10.5z"/></svg>',B='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 .75a9.25 9.25 0 1 1 0 18.5 9.25 9.25 0 0 1 0-18.5zm0 1.5a7.75 7.75 0 1 0 0 15.5 7.75 7.75 0 0 0 0-15.5zm0 2.022a.75.75 0 0 1 .743.649l.007.101v8.165l2.714-2.705a.75.75 0 0 1 .977-.07l.084.072a.75.75 0 0 1 .07.976l-.072.084-3.994 3.981a.75.75 0 0 1-.975.073l-.084-.073-3.99-3.98a.75.75 0 0 1 .975-1.135l.085.072 2.71 2.706V5.022a.75.75 0 0 1 .75-.75z"/></svg>',A='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 19.25a9.25 9.25 0 1 0 0-18.5 9.25 9.25 0 0 0 0 18.5zm0-1.5a7.75 7.75 0 1 1 0-15.5 7.75 7.75 0 0 1 0 15.5zm0-2.022a.75.75 0 0 0 .743-.649l.007-.101V6.813l2.714 2.705a.75.75 0 0 0 .977.07l.084-.072a.75.75 0 0 0 .07-.976l-.072-.084-3.994-3.981a.75.75 0 0 0-.975-.073l-.084.073-3.99 3.98a.75.75 0 0 0 .975 1.135l.085-.072 2.71-2.706v8.166c0 .414.336.75.75.75z"/></svg>',E='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.187 17H5.773c-.637 0-1.092-.138-1.364-.415-.273-.277-.409-.718-.409-1.323V4.738c0-.617.14-1.062.419-1.332.279-.27.73-.406 1.354-.406h4.68c.69 0 1.288.041 1.793.124.506.083.96.242 1.36.478.341.197.644.447.906.75a3.262 3.262 0 0 1 .808 2.162c0 1.401-.722 2.426-2.167 3.075C15.05 10.175 16 11.315 16 13.01a3.756 3.756 0 0 1-2.296 3.504 6.1 6.1 0 0 1-1.517.377c-.571.073-1.238.11-2 .11zm-.217-6.217H7v4.087h3.069c1.977 0 2.965-.69 2.965-2.072 0-.707-.256-1.22-.768-1.537-.512-.319-1.277-.478-2.296-.478zM7 5.13v3.619h2.606c.729 0 1.292-.067 1.69-.2a1.6 1.6 0 0 0 .91-.765c.165-.267.247-.566.247-.897 0-.707-.26-1.176-.778-1.409-.519-.232-1.31-.348-2.375-.348H7z"/></svg>',k='<svg viewBox="0 0 14 16" xmlns="http://www.w3.org/2000/svg"><path class="ck-icon__fill" d="M2 14.436V2a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v12.436a.5.5 0 0 1-.819.385l-3.862-3.2a.5.5 0 0 0-.638 0l-3.862 3.2A.5.5 0 0 1 2 14.436Z"/></svg>',D='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M5.68 3.417a.238.238 0 0 0-.24.236v12.66l3.793-3.102a1.215 1.215 0 0 1 1.534 0l3.793 3.103V3.654a.238.238 0 0 0-.24-.237H5.68ZM4 3.653C4 2.74 4.752 2 5.68 2h8.64c.928 0 1.68.74 1.68 1.653v13.164c0 1-1.185 1.547-1.967.908L10 14.426l-4.033 3.299c-.782.64-1.967.092-1.967-.908V3.653Z"/></svg>',S='<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M4.4 2.24c-.11 0-.2.092-.2.207v11.077l3.16-2.714a.975.975 0 0 1 1.28 0l3.16 2.714V2.447a.203.203 0 0 0-.2-.207H4.4ZM3 2.447C3 1.647 3.627 1 4.4 1h7.2c.773 0 1.4.648 1.4 1.447v11.518c0 .875-.988 1.354-1.64.794L8 11.873 4.64 14.76c-.652.56-1.64.081-1.64-.794V2.447Z"/></svg>',_='<svg viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M4.216 2.031a.503.503 0 0 0-.489.516v9.42l2.648-2.324a.938.938 0 0 1 1.25 0l2.648 2.324v-9.42a.503.503 0 0 0-.489-.516H4.216Zm-1.466.516C2.75 1.693 3.406 1 4.216 1h5.568c.81 0 1.466.693 1.466 1.547v9.42c0 .873-.965 1.351-1.602.793L7 10.436 4.352 12.76c-.637.558-1.602.08-1.602-.793v-9.42Z"/></svg>',T='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.627 16.5zm5.873-.196zm0-7.001V8h-13v8.5h4.341c.191.54.457 1.044.785 1.5H2a1.5 1.5 0 0 1-1.5-1.5v-13A1.5 1.5 0 0 1 2 2h4.5a1.5 1.5 0 0 1 1.06.44L9.122 4H16a1.5 1.5 0 0 1 1.5 1.5v1A1.5 1.5 0 0 1 19 8v2.531a6.027 6.027 0 0 0-1.5-1.228zM16 6.5v-1H8.5l-2-2H2v13h1V8a1.5 1.5 0 0 1 1.5-1.5H16z"/><path d="M14.5 19.5a5 5 0 1 1 0-10 5 5 0 0 1 0 10zM15 14v-2h-1v2h-2v1h2v2h1v-2h2v-1h-2z"/></svg>',O='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 5.75c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zm-6 0C1 4.784 1.777 4 2.75 4c.966 0 1.75.777 1.75 1.75 0 .966-.777 1.75-1.75 1.75C1.784 7.5 1 6.723 1 5.75zm6 9c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zm-6 0c0-.966.777-1.75 1.75-1.75.966 0 1.75.777 1.75 1.75 0 .966-.777 1.75-1.75 1.75-.966 0-1.75-.777-1.75-1.75z"/></svg>',P='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.591 10.177 4.243 4.242a1 1 0 0 1-1.415 1.415l-4.242-4.243-4.243 4.243a1 1 0 0 1-1.414-1.415l4.243-4.242L4.52 5.934A1 1 0 0 1 5.934 4.52l4.243 4.243 4.242-4.243a1 1 0 1 1 1.415 1.414l-4.243 4.243z"/></svg>',j='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 16h9a1 1 0 0 1 0 2H2a1 1 0 0 1 0-2z"/><path d="M17 1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h14zm0 1.5H3a.5.5 0 0 0-.492.41L2.5 3v9a.5.5 0 0 0 .41.492L3 12.5h14a.5.5 0 0 0 .492-.41L17.5 12V3a.5.5 0 0 0-.41-.492L17 2.5z" fill-opacity=".6"/></svg>',R='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M5.789 3 0 17h1.664l1.664-4.027h6.797l.227.548c.046-.172.112-.352.209-.545.217-.436.556-.609.898-.798L7.664 3H5.79Zm.938 1.758 2.777 6.715H3.949l2.778-6.715Z"/><path d="M15.956 7.5c-1.584-.02-3.226.824-3.843 2.882l1.495.106c.904-2.082 4.594-2.13 4.375.534-3.245 1.024-4.838.117-6.082 2.62-.208.418-.17 1.57.54 2.397.71.826 2.014 1.149 3.409.85 1.395-.299 2.24-1.386 2.24-1.386L18.205 17h1.493l-.059-1.72-.056-5.274C19.51 8.612 17.75 7.524 15.956 7.5Zm2.027 4.696s.641 2.453-1.528 3.27c-3.376 1.269-4.188-2.141-1.775-2.638 1.704-.352 1.873-.25 3.303-.632Z"/></svg>',F='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M6.972 16.615a.997.997 0 0 1-.744-.292l-4.596-4.596a1 1 0 1 1 1.414-1.414l3.926 3.926 9.937-9.937a1 1 0 0 1 1.414 1.415L7.717 16.323a.997.997 0 0 1-.745.292z"/></svg>',N='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M16.435 5.506a.75.75 0 0 1 1.197.899l-.067.089-6.992 8a.75.75 0 0 1-1.05.078l-.079-.078-7.008-8a.75.75 0 0 1 1.049-1.066l.08.078 6.442 7.354 6.428-7.354z"/></svg>',U='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M16.435 14.494a.75.75 0 0 0 1.197-.899l-.067-.089-6.992-8a.75.75 0 0 0-1.05-.078l-.079.078-7.008 8a.75.75 0 0 0 1.049 1.066l.08-.078 6.442-7.354 6.428 7.354z"/></svg>',W='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M1.201 1C.538 1 0 1.47 0 2.1v14.363c0 .64.534 1.037 1.186 1.037H5.06l5.058-5.078L6.617 9.15a.696.696 0 0 0-.957-.033L1.5 13.6V2.5h15v4.354a3.478 3.478 0 0 1 1.5.049V2.1c0-.63-.547-1.1-1.2-1.1H1.202Zm11.713 2.803a2.147 2.147 0 0 0-2.049 1.992 2.14 2.14 0 0 0 1.28 2.096 2.13 2.13 0 0 0 2.642-3.11 2.129 2.129 0 0 0-1.873-.978ZM8.089 17.635v2.388h2.389l7.046-7.046-2.39-2.39-7.045 7.048Zm11.282-6.507a.637.637 0 0 0 .139-.692.603.603 0 0 0-.139-.205l-1.49-1.488a.63.63 0 0 0-.899 0l-1.166 1.163 2.39 2.39 1.165-1.168Z"/></svg>',$='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M12.87 12.61a.75.75 0 0 1-.089.976l-.085.07-3.154 2.254 3.412 2.414a.75.75 0 0 1 .237.95l-.057.095a.75.75 0 0 1-.95.237l-.096-.058-4.272-3.022-.003-1.223 4.01-2.867a.75.75 0 0 1 1.047.174zm2.795-.231.095.057 4.011 2.867-.003 1.223-4.272 3.022-.095.058a.75.75 0 0 1-.88-.151l-.07-.086-.058-.095a.75.75 0 0 1 .15-.88l.087-.07 3.412-2.414-3.154-2.253-.085-.071a.75.75 0 0 1 .862-1.207zM16 0a2 2 0 0 1 2 2v9.354l-.663-.492-.837-.001V2a.5.5 0 0 0-.5-.5H2a.5.5 0 0 0-.5.5v15a.5.5 0 0 0 .5.5h3.118L7.156 19H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h14zM5.009 15l.003 1H3v-1h2.009zm2.188-2-1.471 1H5v-1h2.197zM10 11v.095L8.668 12H7v-1h3zm4-2v1H7V9h7zm0-2v1H7V7h7zm-4-2v1H5V5h5zM6 3v1H3V3h3z"/></svg>',q='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m12.5 5.7 5.2 3.9v1.3l-5.6 4c-.1.2-.3.2-.5.2-.3-.1-.6-.7-.6-1l.3-.4 4.7-3.5L11.5 7l-.2-.2c-.1-.3-.1-.6 0-.8.2-.2.5-.4.8-.4a.8.8 0 0 1 .4.1zm-5.2 0L2 9.6v1.3l5.6 4c.1.2.3.2.5.2.3-.1.7-.7.6-1 0-.1 0-.3-.2-.4l-5-3.5L8.2 7l.2-.2c.1-.3.1-.6 0-.8-.2-.2-.5-.4-.8-.4a.8.8 0 0 0-.3.1z"/></svg>',K='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.333 2 .19 2.263a5.899 5.899 0 0 1 1.458.604L14.714 3.4 16.6 5.286l-1.467 1.733c.263.452.468.942.605 1.46L18 8.666v2.666l-2.263.19a5.899 5.899 0 0 1-.604 1.458l1.467 1.733-1.886 1.886-1.733-1.467a5.899 5.899 0 0 1-1.46.605L11.334 18H8.667l-.19-2.263a5.899 5.899 0 0 1-1.458-.604L5.286 16.6 3.4 14.714l1.467-1.733a5.899 5.899 0 0 1-.604-1.458L2 11.333V8.667l2.262-.189a5.899 5.899 0 0 1 .605-1.459L3.4 5.286 5.286 3.4l1.733 1.467a5.899 5.899 0 0 1 1.46-.605L8.666 2h2.666zM10 6.267a3.733 3.733 0 1 0 0 7.466 3.733 3.733 0 0 0 0-7.466z"/></svg>',G='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.209 18.717A8.5 8.5 0 1 1 18.686 9.6h-.008l.002.12a3 3 0 0 1-2.866 2.997h-.268l-.046-.002v.002h-4.791a2 2 0 1 0 0 4 1 1 0 1 1-.128 1.992 8.665 8.665 0 0 1-.372.008Zm-3.918-7.01a1.25 1.25 0 1 0-2.415-.648 1.25 1.25 0 0 0 2.415.647ZM5.723 8.18a1.25 1.25 0 1 0 .647-2.414 1.25 1.25 0 0 0-.647 2.414ZM9.76 6.155a1.25 1.25 0 1 0 .647-2.415 1.25 1.25 0 0 0-.647 2.415Zm4.028 1.759a1.25 1.25 0 1 0 .647-2.415 1.25 1.25 0 0 0-.647 2.415Z"/></svg>',J='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path class="ck-icon__fill" d="M16.935 5.328a2 2 0 0 1 0 2.829l-7.778 7.778a2 2 0 0 1-2.829 0L3.5 13.107a1.999 1.999 0 1 1 2.828-2.829l.707.707a1 1 0 0 0 1.414 0l5.658-5.657a2 2 0 0 1 2.828 0z"/><path d="M14.814 6.035 8.448 12.4a1 1 0 0 1-1.414 0l-1.413-1.415A1 1 0 1 0 4.207 12.4l2.829 2.829a1 1 0 0 0 1.414 0l7.778-7.778a1 1 0 1 0-1.414-1.415z"/></svg>',Q='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M14 3.5A3.5 3.5 0 0 1 17.5 7v6l-.005.192a3.501 3.501 0 0 1-2.927 3.262l-.062.008v1.813a1.5 1.5 0 0 1-2.193 1.33l-.37-.193-.38-.212a13.452 13.452 0 0 1-3.272-2.63l-.062-.07-4.729-.046a3.5 3.5 0 0 1-3.5-3.5v-6a3.5 3.5 0 0 1 3.5-3.5L14 3.5ZM3.5 4.954a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2L8.924 15a11.917 11.917 0 0 0 3.71 3.081l.372.194v-3.268L12.962 15H14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2L3.5 4.954Z"/><path d="M16.5.5h-10a3.5 3.5 0 0 0-3.162 2h1.84A2 2 0 0 1 6.5 2h10a2 2 0 0 1 2 2v8.873a3.502 3.502 0 0 0 1.495-2.681L20 10V4A3.5 3.5 0 0 0 16.5.5Z"/><path clip-rule="evenodd" d="M12.013 7.453a.75.75 0 0 1 .034 1.06l-3.75 4a.75.75 0 0 1-1.045.048l-2.25-2a.75.75 0 0 1 .996-1.122l1.704 1.515 3.25-3.467a.75.75 0 0 1 1.061-.034Z"/></svg>',X='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M15.5 6.5a3.5 3.5 0 0 1 3.495 3.308L19 10v2a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1v-2l.005-.192A3.5 3.5 0 0 1 15.5 6.5zm0 7.5a.5.5 0 0 0-.492.41L15 14.5v2a.5.5 0 0 0 .992.09L16 16.5v-2a.5.5 0 0 0-.5-.5zm0-6a2 2 0 0 0-2 2v2h4v-2a2 2 0 0 0-2-2zm-9.25 8a.75.75 0 1 1 0 1.5H.75a.75.75 0 1 1 0-1.5h5.5zm0-5a.75.75 0 1 1 0 1.5H.75a.75.75 0 1 1 0-1.5h5.5zm3-5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1 0-1.5h8.5zm6-5a.75.75 0 1 1 0 1.5H.75a.75.75 0 0 1 0-1.5h14.5z"/></svg>',Y='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M6.25 16a.75.75 0 1 1 0 1.5H.75a.75.75 0 1 1 0-1.5h5.5zm0-5a.75.75 0 1 1 0 1.5H.75a.75.75 0 1 1 0-1.5h5.5zm3-5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1 0-1.5h8.5zm6-5a.75.75 0 1 1 0 1.5H.75a.75.75 0 0 1 0-1.5h14.5zm.25 5.5a3.5 3.5 0 0 1 3.143 1.959.75.75 0 0 1-1.36.636A2 2 0 0 0 13.5 10v2H19a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1v-2l.005-.192A3.5 3.5 0 0 1 15.5 6.5zm0 7.5a.5.5 0 0 0-.492.41L15 14.5v2a.5.5 0 0 0 .992.09L16 16.5v-2a.5.5 0 0 0-.5-.5z"/></svg>',tt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M5 2.801a.7.7 0 0 0-.7.7v11.5a.8.8 0 0 1-1.6 0v-11.5a2.3 2.3 0 0 1 2.3-2.3h6.5a.8.8 0 0 1 0 1.6H5Zm.7 3.7a2.3 2.3 0 0 1 2.3-2.3h7a2.3 2.3 0 0 1 2.3 2.3v10a2.3 2.3 0 0 1-2.3 2.3H8a2.3 2.3 0 0 1-2.3-2.3v-10Zm2.3-.7a.7.7 0 0 0-.7.7v10a.7.7 0 0 0 .7.7h7a.7.7 0 0 0 .7-.7v-10a.7.7 0 0 0-.7-.7H8Z"/></svg>',et='<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M4 0v1H1v3H0V.5A.5.5 0 0 1 .5 0H4zm8 0h3.5a.5.5 0 0 1 .5.5V4h-1V1h-3V0zM4 16H.5a.5.5 0 0 1-.5-.5V12h1v3h3v1zm8 0v-1h3v-3h1v3.5a.5.5 0 0 1-.5.5H12z"/><path fill-opacity=".256" d="M1 1h14v14H1z"/><g class="ck-icon__selected-indicator"><path d="M7 0h2v1H7V0zM0 7h1v2H0V7zm15 0h1v2h-1V7zm-8 8h2v1H7v-1z"/><path fill-opacity=".254" d="M1 1h14v14H1z"/></g></svg>',at='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M5 3.25a1.5 1.5 0 1 0 3 0 1.5 1.5 0 1 0-3 0"/><path d="M12 3.25a1.5 1.5 0 1 0 3 0 1.5 1.5 0 1 0-3 0"/><path d="M5 10a1.5 1.5 0 1 0 3 0 1.5 1.5 0 1 0-3 0"/><path d="M12 10a1.5 1.5 0 1 0 3 0 1.5 1.5 0 1 0-3 0"/><path d="M5 16.75a1.5 1.5 0 1 0 3 0 1.5 1.5 0 1 0-3 0"/><path d="M12 16.75a1.5 1.5 0 1 0 3 0 1.5 1.5 0 1 0-3 0"/></svg>',it='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M5.972 3.115A.746.746 0 0 1 6.374 3c.14 0 .28.037.402.115l3.229 2.059 3.228-2.057a.75.75 0 0 1 .805 0l3.629 2.31h.002a.757.757 0 0 1 0 1.264h-.002L15.034 8.37l2.633 1.678h.002a.756.756 0 0 1 0 1.262h-.002l-3.63 2.312a.746.746 0 0 1-.161.076c.234.08.409.275.482.5a.75.75 0 0 1-.322.854l-3.629 2.308a.75.75 0 0 1-.805 0l-3.63-2.31a.75.75 0 0 1-.229-1.031l.076-.122h.022a.746.746 0 0 1 .32-.189.75.75 0 0 1-.19-.086l-3.63-2.312a.756.756 0 0 1 0-1.264l2.632-1.678-2.632-1.676a.757.757 0 0 1 0-1.263l3.63-2.313Zm2.64 2.946L6.374 4.635 4.136 6.06l2.238 1.423L8.612 6.06Zm7.262 0-2.236-1.426-2.239 1.426 2.237 1.423 2.238-1.423Zm-3.637 2.306-2.232-1.422-2.233 1.422 2.235 1.422 2.23-1.422Zm-3.625 2.31L6.374 9.253l-2.238 1.426 2.238 1.424 2.238-1.424Zm7.262 0-2.236-1.425-2.239 1.426 2.237 1.424 2.238-1.424Zm-2.64 2.944-3.23-2.056-3.228 2.056a.75.75 0 0 1-.185.084.724.724 0 0 1 .185.08l3.229 2.057 3.226-2.055a.827.827 0 0 1 .18-.084.746.746 0 0 1-.178-.082Z"/></svg>',lt='<svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"><path d="M.941 4.523a.75.75 0 1 1 1.06-1.06l3.006 3.005 3.005-3.005a.75.75 0 1 1 1.06 1.06l-3.549 3.55a.75.75 0 0 1-1.168-.136L.941 4.523z"/></svg>',nt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 13.598v2.777h2.778l8.194-8.193-2.78-2.78L3 13.6v-.001zm13.12-7.566a.735.735 0 0 0 0-1.044l-1.734-1.73a.735.735 0 0 0-1.044 0L11.985 4.61l2.78 2.78 1.354-1.358z"/></svg>',ot='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M8.5 8.25a1.25 1.25 0 1 1-2.499.001A1.25 1.25 0 0 1 8.5 8.25Z"/><path clip-rule="evenodd" d="M14 8.25a1.25 1.25 0 1 1-2.499.001A1.25 1.25 0 0 1 14 8.25Z"/><path clip-rule="evenodd" d="M7.127 12.088a.75.75 0 1 0-1.254.824C6.88 14.444 8.423 15.25 10 15.25c1.578 0 3.12-.805 4.127-2.338a.75.75 0 0 0-1.254-.824C12.13 13.221 11.048 13.75 10 13.75c-1.047 0-2.13-.529-2.873-1.662Z"/><path d="M10 19a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-1.5a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z"/></svg>',st='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m8.636 9.531-2.758 3.94a.5.5 0 0 0 .122.696l3.224 2.284h1.314l2.636-3.736L8.636 9.53zm.288 8.451L5.14 15.396a2 2 0 0 1-.491-2.786l6.673-9.53a2 2 0 0 1 2.785-.49l3.742 2.62a2 2 0 0 1 .491 2.785l-7.269 10.053-2.147-.066z"/><path d="M4 18h5.523v-1H4zm-2 0h1v-1H2z"/></svg>',ht='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M9.9 4.385a1.2 1.2 0 0 0-.44.44h.002l-5.284 9.15a1.2 1.2 0 0 0 1.04 1.8h10.564a1.2 1.2 0 0 0 1.04-1.8l-5.282-9.15a1.2 1.2 0 0 0-1.64-.44Zm.05 8.363a.301.301 0 0 1 .115-.023h.9a.301.301 0 0 1 .3.3v.9a.3.3 0 0 1-.3.3h-.9a.3.3 0 0 1-.3-.3v-.9a.3.3 0 0 1 .185-.277Zm-.185-4.723a.3.3 0 0 1 .3-.3h.9a.3.3 0 0 1 .3.3v3.4a.3.3 0 0 1-.3.3h-.9a.301.301 0 0 1-.3-.3v-3.4Z"/></svg>',rt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M19 4.5 14 0H3v6.5h1.5v-5h8v5h5V11H19V4.5ZM14 2l3.3 3H14V2Z"/><path d="m12.452 18.5 1.25 1.5H3v-3h1.5v1.5h7.952Z"/><path d="M19.826 16.843a.75.75 0 1 0-1.152-.96L17.5 17.29V13H16v4.29l-1.174-1.408a.75.75 0 1 0-1.152.96l2.346 2.816a.95.95 0 0 0 1.46 0l2.346-2.815Z"/><path d="M1.63 14.24V12.3h.88c.66 0 1.14-.15 1.46-.45.32-.3.48-.71.48-1.24 0-.52-.15-.91-.44-1.2C3.7 9.15 3.28 9 2.69 9H0v5.24h1.63Zm.4-3h-.4v-1.17h.46c.3 0 .5.05.62.17.1.11.16.25.16.42 0 .16-.06.3-.19.41-.13.11-.34.16-.66.16l.01.01Zm5.7 3c.28 0 .6-.05.96-.14.26-.07.5-.21.73-.42.22-.2.4-.47.53-.77.12-.31.19-.75.19-1.3 0-.37-.04-.7-.13-1.02a2.3 2.3 0 0 0-.42-.84c-.19-.24-.43-.43-.72-.56C8.57 9.06 8.2 9 7.72 9h-2.4v5.24h2.41Zm-.4-1.19h-.4v-2.86h.4c.43 0 .73.1.91.3.18.2.27.59.27 1.14 0 .42-.04.73-.12.91a.76.76 0 0 1-.34.4c-.14.07-.38.11-.72.11Zm5.3 1.2V12.1h2.02v-1.06h-2.03v-.91H15V9h-4v5.24h1.62l.01.01Z"/></svg>',vt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.826 16.843a.75.75 0 0 0-1.152-.96L15.5 17.29V12H14v5.29l-1.174-1.408a.75.75 0 0 0-1.152.96l2.346 2.816a.95.95 0 0 0 1.46 0l2.346-2.815Z"/><path d="m14 0 5 4.5v9.741a2.737 2.737 0 0 0-1.5-.617V6.5h-5v-5h-8v3H3V0h11Zm0 2v3h3.3L14 2Z"/><path d="M3 17.5h6.746c.1.22.23.429.391.623l.731.877H3v-1.5Z"/><path d="M8.5 6a1.5 1.5 0 0 1 1.493 1.356L10 7.5v7a1.5 1.5 0 0 1-1.356 1.493L8.5 16h-7a1.5 1.5 0 0 1-1.493-1.356L0 14.5v-7a1.5 1.5 0 0 1 1.356-1.493L1.5 6h7ZM7.483 8.795l-.042.088-.986 2.534-.947-2.528-.043-.092a.601.601 0 0 0-1.042.008l-.042.093-.883 2.465-.937-2.475-.042-.089a.6.6 0 0 0-1.107.42l.027.093 1.514 4 .044.092a.6.6 0 0 0 1.041-.01l.041-.092.88-2.458.925 2.467.046.096a.6.6 0 0 0 1.032 0l.043-.09 1.554-4 .028-.093a.6.6 0 0 0-1.104-.43v.001Z"/></svg>',ct='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.01 2C5.59 2 2 5.59 2 10.01a8.011 8.011 0 0 0 6.775 7.914.754.754 0 0 0 .598-.17.75.75 0 0 0 .262-.565v-5.02a.745.745 0 0 0-.22-.524.745.745 0 0 0-.526-.221H7.77v-.627h1.12a.745.745 0 0 0 .525-.22c.14-.14.22-.329.22-.526V8.324c0-.466.122-1.083.524-1.48.33-.326 1.017-.6 2.332-.43v.408c-.152.012-.305.017-.457.04-.335.048-.662.136-.943.326-.313.21-.497.505-.59.804-.093.3-.107.602-.107.88v1.18a.746.746 0 0 0 .744.746h1.158l-.098.63h-1.06a.744.744 0 0 0-.744.744v5.017a.752.752 0 0 0 .26.567c.081.07.177.119.28.148a.75.75 0 0 0 .319.022A8.012 8.012 0 0 0 10.01 2Zm0 1.486a6.52 6.52 0 0 1 6.521 6.524c0 2.945-1.973 5.386-4.65 6.197v-3.291h.951a.749.749 0 0 0 .736-.63l.332-2.12a.742.742 0 0 0-.17-.6.745.745 0 0 0-.564-.26h-1.285V8.87c0-.252.024-.384.039-.433.003-.008.002-.012.002-.016.016-.011.104-.055.326-.088a7 7 0 0 1 .984-.05.746.746 0 0 0 .528-.218.743.743 0 0 0 .217-.527V5.76a.747.747 0 0 0-.586-.729c-2.04-.438-3.433-.083-4.278.75-.818.807-.968 1.884-.968 2.543v.983H7.027a.744.744 0 0 0-.525.22.743.743 0 0 0-.219.526v2.119c0 .197.08.386.219.525.14.14.328.221.525.221h1.118v3.291c-2.681-.809-4.659-3.25-4.659-6.197a6.523 6.523 0 0 1 6.524-6.526Z"/></svg>',dt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m12.87 13.786 1.532-1.286 3.857 4.596a1 1 0 1 1-1.532 1.286l-3.857-4.596z"/><path d="M16.004 8.5a6.5 6.5 0 0 1-9.216 5.905c-1.154-.53-.863-1.415-.663-1.615.194-.194.564-.592 1.635-.141a4.5 4.5 0 0 0 5.89-5.904l-.104-.227 1.332-1.331c.045-.046.196-.041.224.007a6.47 6.47 0 0 1 .902 3.306zm-3.4-5.715c.562.305.742 1.106.354 1.494-.388.388-.995.414-1.476.178a4.5 4.5 0 0 0-6.086 5.882l.114.236-1.348 1.349c-.038.037-.17.022-.198-.023a6.5 6.5 0 0 1 5.54-9.9 6.469 6.469 0 0 1 3.1.784z"/><path d="M4.001 11.93.948 8.877a.2.2 0 0 1 .141-.341h6.106a.2.2 0 0 1 .141.341L4.283 11.93a.2.2 0 0 1-.282 0zm11.083-6.789 3.053 3.053a.2.2 0 0 1-.14.342H11.89a.2.2 0 0 1-.14-.342l3.052-3.053a.2.2 0 0 1 .282 0z"/></svg>',mt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M4 2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8.38 9.262H7.62L10 5.506l2.38 5.756zm.532 1.285L14.34 16h1.426L10.804 4H9.196L4.234 16H5.66l1.428-3.453h5.824z"/></svg>',gt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M12.4 10.3 10 4.5l-2.4 5.8h4.8zm.5 1.2H7.1L5.7 15H4.2l5-12h1.6l5 12h-1.5L13 11.5zm3.1 7H4a1 1 0 0 1 0-2h12a1 1 0 0 1 0 2z"/></svg>',pt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.03 3h6.149a.75.75 0 1 1 0 1.5h-5.514L11.03 3zm1.27 3h4.879a.75.75 0 1 1 0 1.5h-4.244L12.3 6zm1.27 3h3.609a.75.75 0 1 1 0 1.5h-2.973L13.57 9zm-2.754 2.5L8.038 4.785 5.261 11.5h5.555zm.62 1.5H4.641l-1.666 4.028H1.312l5.789-14h1.875l5.789 14h-1.663L11.436 13z"/></svg>',wt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M9.816 11.5 7.038 4.785 4.261 11.5h5.555zm.62 1.5H3.641l-1.666 4.028H.312l5.789-14h1.875l5.789 14h-1.663L10.436 13zm7.55 2.279.779-.779.707.707-2.265 2.265-2.193-2.265.707-.707.765.765V4.825c0-.042 0-.083.002-.123l-.77.77-.707-.707L17.207 2.5l2.265 2.265-.707.707-.782-.782c.002.043.003.089.003.135v10.454z"/></svg>',ut='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.5 5.75a.75.75 0 0 1 0-1.5H15a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V6.81l-2.72 2.72a.75.75 0 0 1-1.06-1.06l2.72-2.72H11.5Z"/><path d="M9.53 10.47a.75.75 0 0 1 0 1.06l-2.72 2.72H8.5a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1-.75-.75v-3.5a.75.75 0 0 1 1.5 0v1.69l2.72-2.72a.75.75 0 0 1 1.06 0Z"/><path d="M2 0h16a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2Zm16 1.5H2a.5.5 0 0 0-.5.5v16a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5V2a.5.5 0 0 0-.5-.5Z"/></svg>',Mt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M15.53 5.53a.75.75 0 0 0-1.06-1.06l-2.72 2.72V5.5a.75.75 0 0 0-1.5 0V9a.75.75 0 0 0 .75.75h3.5a.75.75 0 0 0 0-1.5h-1.69l2.72-2.72Z"/><path d="M5.5 10.25a.75.75 0 0 0 0 1.5h1.69l-2.72 2.72a.75.75 0 1 0 1.06 1.06l2.72-2.72v1.69a.75.75 0 0 0 1.5 0V11a.75.75 0 0 0-.75-.75H5.5Z"/><path d="M0 2a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2Zm18.5 0a.5.5 0 0 0-.5-.5H2a.5.5 0 0 0-.5.5v16a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5V2Z"/></svg>',Ht='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7.666 3a.736.736 0 0 0-.371.1.748.748 0 0 0-.275.267l-4.918 8.397a.743.743 0 0 0-.006.744l2.46 4.318a.747.747 0 0 0 .65.377h9.606a.754.754 0 0 0 .65-.377l2.46-4.318a.74.74 0 0 0 .002-.735l-4.688-8.392a.754.754 0 0 0-.654-.38H7.666Zm1.285 1.492h3.195l3.854 6.9h-3.1l-3.949-6.9Zm-1.293.742L9.223 7.97l-4.016 6.988-1.6-2.813 4.051-6.91Zm2.424 4.237 1.098 1.922H8.977l1.105-1.922ZM8.12 12.885h7.87l-1.61 2.825H6.494l1.625-2.826Z"/></svg>',zt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M9.258 3.082c0-.594.486-1.082 1.08-1.082a4.38 4.38 0 0 1 4.239 5.489c-.18.688-.633 1.245-1.109 1.767h3.468c.595 0 1.082.488 1.082 1.082a4.382 4.382 0 0 1-5.49 4.24v-.001c-.689-.18-1.246-.633-1.768-1.109v3.468c0 .595-.487 1.082-1.082 1.082a4.384 4.384 0 0 1-4.111-2.866 4.382 4.382 0 0 1-.125-2.624c.18-.689.632-1.246 1.108-1.768H3.082A1.085 1.085 0 0 1 2 9.68a4.382 4.382 0 0 1 5.49-4.238c.69.18 1.246.632 1.768 1.108V3.082Zm3.164 1.32c-.435-.46-1.043-.667-1.662-.767v5.49c.619-.1 1.227-.307 1.662-.766a2.875 2.875 0 0 0 0-3.958ZM7.21 6.918a2.876 2.876 0 0 0-2.93.789c-.396.423-.569.983-.658 1.55h5.56c-.045-.295-.091-.59-.224-.859a2.879 2.879 0 0 0-1.748-1.48Zm9.254 3.841h-5.653l.013.1c.012.098.025.197.053.292a2.873 2.873 0 0 0 4.862 1.158 2.87 2.87 0 0 0 .398-.54v-.001c.111-.195.2-.403.263-.619.026-.092.038-.188.05-.283l.014-.107Zm-8.872 4.86c.437.459 1.045.666 1.665.766V10.89c-1.369.222-2.456 1.314-2.456 2.747 0 .738.283 1.447.791 1.981Z"/></svg>',Vt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M19 9v10h-2v-8h-2V9h4zM4 8.5h5V4a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v11.5a1 1 0 0 1-1 1H10a1 1 0 0 1-1-1V11H4v4.5a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1H3a1 1 0 0 1 1 1v4.5z"/></svg>',ft='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 8.5h5V4a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v11.5a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V11H3v4.5a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1H2a1 1 0 0 1 1 1v4.5zm16.076 8.343V18.5h-6.252c.067-.626.27-1.22.61-1.78.338-.561 1.006-1.305 2.005-2.232.804-.749 1.297-1.257 1.479-1.523.245-.368.368-.732.368-1.092 0-.398-.107-.703-.32-.917-.214-.214-.51-.32-.886-.32-.372 0-.669.111-.889.336-.22.224-.347.596-.38 1.117l-1.778-.178c.106-.982.438-1.686.997-2.114.558-.427 1.257-.64 2.095-.64.918 0 1.64.247 2.164.742.525.495.787 1.11.787 1.847 0 .419-.075.818-.225 1.197-.15.378-.388.775-.714 1.19-.216.275-.605.67-1.168 1.187-.563.516-.92.859-1.07 1.028a3.11 3.11 0 0 0-.365.495h3.542z"/></svg>',xt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 8.5h5V4a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v11.5a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V11H3v4.5a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1H2a1 1 0 0 1 1 1v4.5zm9.989 7.53 1.726-.209c.055.44.203.777.445 1.01.24.232.533.349.876.349.368 0 .678-.14.93-.42.251-.279.377-.655.377-1.13 0-.448-.12-.803-.362-1.066a1.153 1.153 0 0 0-.882-.393c-.228 0-.501.044-.819.133l.197-1.453c.482.012.85-.092 1.105-.315.253-.222.38-.517.38-.885 0-.313-.093-.563-.279-.75-.186-.185-.434-.278-.743-.278a1.07 1.07 0 0 0-.78.317c-.216.212-.347.52-.394.927l-1.644-.28c.114-.562.287-1.012.517-1.348.231-.337.553-.601.965-.794a3.24 3.24 0 0 1 1.387-.289c.876 0 1.579.28 2.108.838.436.457.653.973.653 1.549 0 .817-.446 1.468-1.339 1.955.533.114.96.37 1.28.768.319.398.478.878.478 1.441 0 .817-.298 1.513-.895 2.088-.596.576-1.339.864-2.228.864-.842 0-1.54-.243-2.094-.727-.555-.485-.876-1.118-.965-1.901z"/></svg>',Zt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.5h5V4a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v11.5a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1V11h-5v4.5a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v4.5zm13.55 10v-1.873h-3.81v-1.561l4.037-5.91h1.498v5.904h1.156v1.567h-1.156V18.5H17.05zm0-3.44v-3.18l-2.14 3.18h2.14z"/></svg>',bt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.5h5V4a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v11.5a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1V11h-5v4.5a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v4.5zm9.578 7.607 1.777-.184c.05.402.201.72.45.955a1.223 1.223 0 0 0 1.81-.101c.258-.303.387-.759.387-1.368 0-.572-.128-1-.384-1.286-.256-.285-.59-.428-1-.428-.512 0-.971.226-1.377.679l-1.448-.21.915-4.843h4.716v1.67H15.56l-.28 1.58a2.697 2.697 0 0 1 1.219-.298 2.68 2.68 0 0 1 2.012.863c.55.576.825 1.323.825 2.241a3.36 3.36 0 0 1-.666 2.05c-.605.821-1.445 1.232-2.52 1.232-.86 0-1.56-.23-2.101-.692-.542-.461-.866-1.081-.971-1.86z"/></svg>',Lt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.5h5V4a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v11.5a1 1 0 0 1-1 1h-.5a1 1 0 0 1-1-1V11h-5v4.5a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h.5a1 1 0 0 1 1 1v4.5zm15.595 2.973-1.726.19c-.043-.355-.153-.617-.33-.787-.178-.169-.409-.253-.692-.253-.377 0-.695.169-.956.507-.26.339-.424 1.043-.492 2.114.445-.525.997-.787 1.657-.787.745 0 1.383.284 1.914.85.531.568.797 1.3.797 2.197 0 .952-.28 1.716-.838 2.291-.559.576-1.276.864-2.152.864-.94 0-1.712-.365-2.317-1.095-.605-.73-.908-1.927-.908-3.59 0-1.705.316-2.935.946-3.688.63-.753 1.45-1.13 2.457-1.13.706 0 1.291.198 1.755.594.463.395.758.97.885 1.723zm-4.043 3.891c0 .58.133 1.028.4 1.343.266.315.57.473.914.473.33 0 .605-.13.825-.388.22-.258.33-.68.33-1.27 0-.604-.118-1.047-.355-1.329a1.115 1.115 0 0 0-.89-.422c-.342 0-.632.134-.869.403s-.355.666-.355 1.19z"/></svg>',Ct='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11 1a9 9 0 1 1-8.027 13.075l1.128-1.129A7.502 7.502 0 0 0 18.5 10a7.5 7.5 0 1 0-14.962.759l-.745-.746-.76.76A9 9 0 0 1 11 1z"/><path d="M.475 8.17a.75.75 0 0 1 .978.047l.075.082 1.284 1.643 1.681-1.284a.75.75 0 0 1 .978.057l.073.083a.75.75 0 0 1-.057.978l-.083.073-2.27 1.737a.75.75 0 0 1-.973-.052l-.074-.082-1.741-2.23a.75.75 0 0 1 .13-1.052z"/><path d="M11.5 5v4.999l3.196 3.196-1.06 1.06L10.1 10.72l-.1-.113V5z"/></svg>',It='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 9h16v2H2z"/></svg>',yt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17 0a2 2 0 0 1 2 2v7a1 1 0 0 1 1 1v5a1 1 0 0 1-.883.993l-.118.006L19 17a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2l-.001-1.001-.116-.006A1 1 0 0 1 0 15v-5a1 1 0 0 1 .999-1L1 2a2 2 0 0 1 2-2h14zm.499 15.999h-15L2.5 17a.5.5 0 0 0 .5.5h14a.5.5 0 0 0 .5-.5l-.001-1.001zm-3.478-6.013-.014.014H14v.007l-1.525 1.525-1.46-1.46-.015.013V10h-1v5h1v-3.53l1.428 1.43.048.043.131-.129L14 11.421V15h1v-5h-.965l-.014-.014zM2 10H1v5h1v-2h2v2h1v-5H4v2H2v-2zm7 0H6v1h1v4h1v-4h1v-1zm8 0h-1v5h3v-1h-2v-4zm0-8.5H3a.5.5 0 0 0-.5.5l-.001 6.999h15L17.5 2a.5.5 0 0 0-.5-.5zM10 7v1H4V7h6zm3-2v1H4V5h9zm-3-2v1H4V3h6z"/></svg>',Bt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M1.201 1c-.662 0-1.2.47-1.2 1.1v14.248c0 .64.533 1.152 1.185 1.152h6.623v-7.236L6.617 9.15a.694.694 0 0 0-.957-.033L1.602 13.55V2.553l14.798.003V9.7H18V2.1c0-.63-.547-1.1-1.2-1.1H1.202Zm11.723 2.805a2.094 2.094 0 0 0-1.621.832 2.127 2.127 0 0 0 1.136 3.357 2.13 2.13 0 0 0 2.611-1.506 2.133 2.133 0 0 0-.76-2.244 2.13 2.13 0 0 0-1.366-.44Z"/><path clip-rule="evenodd" d="M19.898 12.369v6.187a.844.844 0 0 1-.844.844h-8.719a.844.844 0 0 1-.843-.844v-7.312a.844.844 0 0 1 .843-.844h2.531a.843.843 0 0 1 .597.248l.838.852h4.75c.223 0 .441.114.6.272a.844.844 0 0 1 .247.597Zm-1.52.654-4.377.02-1.1-1.143H11v6h7.4l-.023-4.877Z"/></svg>',At='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M6.66 9.118a.693.693 0 0 1 .956.032l3.65 3.411 2.422-2.238a.695.695 0 0 1 .945 0L17.5 13.6V2.5h-15v11.1l4.16-4.482ZM17.8 1c.652 0 1.2.47 1.2 1.1v14.362c0 .64-.532 1.038-1.184 1.038H2.184C1.532 17.5 1 17.103 1 16.462V2.1C1 1.47 1.537 1 2.2 1h15.6Zm-5.655 6a2.128 2.128 0 0 1 .157-2.364A2.133 2.133 0 1 1 12.145 7Z"/></svg>',Et='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M1.201 1C.538 1 0 1.47 0 2.1v14.363c0 .64.534 1.037 1.186 1.037h9.494a2.97 2.97 0 0 1-.414-.287 2.998 2.998 0 0 1-1.055-2.03 3.003 3.003 0 0 1 .693-2.185l.383-.455-.02.018-3.65-3.41a.695.695 0 0 0-.957-.034L1.5 13.6V2.5h15v5.535a2.97 2.97 0 0 1 1.412.932l.088.105V2.1c0-.63-.547-1.1-1.2-1.1H1.202Zm11.713 2.803a2.146 2.146 0 0 0-2.049 1.992 2.14 2.14 0 0 0 1.28 2.096 2.13 2.13 0 0 0 2.644-3.11 2.134 2.134 0 0 0-1.875-.978Z"/><path d="M15.522 19.1a.79.79 0 0 0 .79-.79v-5.373l2.059 2.455a.79.79 0 1 0 1.211-1.015l-3.352-3.995a.79.79 0 0 0-.995-.179.784.784 0 0 0-.299.221l-3.35 3.99a.79.79 0 1 0 1.21 1.017l1.936-2.306v5.185c0 .436.353.79.79.79Z"/><path d="M15.522 19.1a.79.79 0 0 0 .79-.79v-5.373l2.059 2.455a.79.79 0 1 0 1.211-1.015l-3.352-3.995a.79.79 0 0 0-.995-.179.784.784 0 0 0-.299.221l-3.35 3.99a.79.79 0 1 0 1.21 1.017l1.936-2.306v5.185c0 .436.353.79.79.79Z"/></svg>',kt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M1.201 1C.538 1 0 1.47 0 2.1v14.363c0 .64.534 1.037 1.186 1.037h7.029a5.401 5.401 0 0 1 .615-4.338l.762-1.232-2.975-2.78a.696.696 0 0 0-.957-.033L1.5 13.6V2.5h15v6.023c.449.131.887.32 1.307.573l.058.033c.046.028.09.057.135.086V2.1c0-.63-.547-1.1-1.2-1.1H1.202Zm11.713 2.803a2.15 2.15 0 0 0-1.611.834 2.118 2.118 0 0 0-.438 1.158 2.14 2.14 0 0 0 1.277 2.096 2.132 2.132 0 0 0 2.645-3.11 2.13 2.13 0 0 0-1.873-.978Z"/><path d="M16.63 10.294a3.003 3.003 0 0 0-4.142.887l-.117.177a.647.647 0 0 0-.096.492.664.664 0 0 0 .278.418.7.7 0 0 0 .944-.234 1.741 1.741 0 0 1 2.478-.463 1.869 1.869 0 0 1 .476 2.55.637.637 0 0 0-.071.5.646.646 0 0 0 .309.396.627.627 0 0 0 .869-.19l.027-.041a3.226 3.226 0 0 0-.956-4.492Zm-6.061 3.78-.044.066a3.228 3.228 0 0 0 .82 4.403 3.005 3.005 0 0 0 4.275-.798l.13-.197a.626.626 0 0 0 .092-.475.638.638 0 0 0-.268-.402.713.713 0 0 0-.99.26l-.018.029a1.741 1.741 0 0 1-2.477.461 1.87 1.87 0 0 1-.475-2.55l.029-.047a.647.647 0 0 0 .086-.485.66.66 0 0 0-.275-.408l-.04-.027a.609.609 0 0 0-.845.17Z"/><path d="M15.312 13.925c.24-.36.154-.838-.19-1.067-.346-.23-.82-.124-1.059.236l-1.268 1.907c-.239.36-.153.838.192 1.067.345.23.818.123 1.057-.236l1.268-1.907Z"/></svg>',Dt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M19 4.5 14 0H3v12.673l.868-1.041c.185-.222.4-.402.632-.54V1.5h8v5h5v7.626a2.24 2.24 0 0 1 1.5.822V4.5ZM14 5V2l3.3 3H14Zm-3.692 12.5c.062.105.133.206.213.303L11.52 19H8v-.876a2.243 2.243 0 0 0 1.82-.624h.488Zm7.518-.657a.75.75 0 0 0-1.152-.96L15.5 17.29V12H14v5.29l-1.174-1.408a.75.75 0 0 0-1.152.96l2.346 2.816a.95.95 0 0 0 1.46 0l2.346-2.815Zm-15.056-.38a.75.75 0 0 1-.096-1.056l2.346-2.815a.95.95 0 0 1 1.46 0l2.346 2.815a.75.75 0 1 1-1.152.96L6.5 14.96V20H5v-5.04l-1.174 1.408a.75.75 0 0 1-1.056.096Z"/></svg>',St='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m14 0 5 4.5v8.892l-1.5-1.8V6.5h-5v-5h-8v3H3V0h11Zm0 2v3h3.3L14 2Z"/><path d="M10.74 18a2.76 2.76 0 0 1-.469-.5H3V19h9.25v-.374A2.737 2.737 0 0 1 10.74 18Z"/><path d="M8.5 6a1.5 1.5 0 0 1 1.493 1.356L10 7.5v7a1.5 1.5 0 0 1-1.356 1.493L8.5 16h-7a1.5 1.5 0 0 1-1.493-1.356L0 14.5v-7a1.5 1.5 0 0 1 1.356-1.493L1.5 6h7ZM7.483 8.795l-.042.088-.986 2.534-.947-2.528-.043-.092a.601.601 0 0 0-1.042.008l-.042.093-.883 2.465-.937-2.475-.042-.089a.6.6 0 0 0-1.107.42l.027.093 1.514 4 .044.092a.6.6 0 0 0 1.041-.01l.041-.092.88-2.458.925 2.467.046.096a.602.602 0 0 0 1.032 0l.043-.09 1.554-4 .028-.093a.6.6 0 0 0-1.104-.43v.001Zm4.191 6.612a.75.75 0 1 0 1.152.96L14 14.96V20h1.5v-5.04l1.174 1.408a.75.75 0 1 0 1.152-.96l-2.346-2.816a.95.95 0 0 0-1.46 0l-2.346 2.815Z"/></svg>',_t='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm5 6c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zM2.75 16.5h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 1 0 0 1.5zM1.632 6.95 5.02 9.358a.4.4 0 0 1-.013.661l-3.39 2.207A.4.4 0 0 1 1 11.892V7.275a.4.4 0 0 1 .632-.326z"/></svg>',Tt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><circle cx="10.0001" cy="9.79993" r="1.5"/><path d="M13.25 2.75V2h.035a6.272 6.272 0 0 1 .363.014c.21.013.517.041.785.109.397.1.738.281 1.007.55.268.269.429.587.524.907.182.608.15 1.314.108 1.913l-.03.408c-.038.487-.073.93-.053 1.353.026.527.136.879.333 1.112.223.263.494.428.72.528a2.077 2.077 0 0 0 .335.117l.01.002.613.109v.628h-2.402a3.34 3.34 0 0 1-.42-.415c-.509-.601-.655-1.345-.687-2.009-.025-.527.02-1.094.059-1.592.01-.12.018-.236.026-.347.044-.621.044-1.067-.049-1.377a.63.63 0 0 0-.148-.276.642.642 0 0 0-.313-.157 3.134 3.134 0 0 0-.512-.066 6.026 6.026 0 0 0-.286-.01h-.016L13.25 3.5h-.75V2h.75v.75Z"/><path d="M13.25 16.75v.75h.035a6.852 6.852 0 0 0 .363-.014 4.55 4.55 0 0 0 .785-.109c.397-.1.738-.28 1.007-.55.268-.269.429-.587.524-.907.182-.608.15-1.314.108-1.912l-.03-.41c-.038-.486-.073-.93-.053-1.352.026-.527.136-.879.333-1.112.223-.263.494-.428.72-.528a2.08 2.08 0 0 1 .335-.117l.01-.002.613-.109V9.75h-2.402a3.341 3.341 0 0 0-.42.416c-.509.6-.655 1.344-.687 2.008-.025.527.02 1.095.059 1.592.01.12.018.236.026.347.044.621.044 1.067-.049 1.378a.63.63 0 0 1-.148.275.643.643 0 0 1-.313.157 3.213 3.213 0 0 1-.512.066 6.178 6.178 0 0 1-.286.01l-.016.001H12.5v1.5h.75v-.75Z"/><path d="M6.75 2.75V2h-.035a6.278 6.278 0 0 0-.363.014 4.55 4.55 0 0 0-.785.109 2.13 2.13 0 0 0-1.008.55 2.119 2.119 0 0 0-.524.907c-.181.608-.15 1.314-.108 1.913l.031.408c.038.487.073.93.052 1.353-.025.527-.136.879-.333 1.112a2.013 2.013 0 0 1-.718.528 2.072 2.072 0 0 1-.337.117l-.01.002L2 9.122v.628h2.402a3.28 3.28 0 0 0 .42-.415c.509-.601.654-1.345.686-2.009.026-.527-.019-1.094-.058-1.592-.01-.12-.019-.236-.026-.347-.044-.621-.044-1.067.048-1.377a.63.63 0 0 1 .149-.276.642.642 0 0 1 .312-.157c.13-.032.323-.054.513-.066a6.027 6.027 0 0 1 .286-.01h.015L6.75 3.5h.75V2h-.75v.75Z"/><path d="M6.75 16.75v.75h-.035a6.86 6.86 0 0 1-.363-.014 4.549 4.549 0 0 1-.785-.109 2.131 2.131 0 0 1-1.008-.55 2.119 2.119 0 0 1-.524-.907c-.181-.608-.15-1.314-.108-1.912l.031-.41c.038-.486.073-.93.052-1.352-.025-.527-.136-.879-.333-1.112a2.013 2.013 0 0 0-.718-.528 2.075 2.075 0 0 0-.337-.117l-.01-.002L2 10.378V9.75h2.402c.144.119.286.257.42.416.509.6.654 1.344.686 2.008.026.527-.019 1.095-.058 1.592-.01.12-.019.236-.026.347-.044.621-.044 1.067.048 1.378a.63.63 0 0 0 .149.275.64.64 0 0 0 .312.157c.13.032.323.054.513.066a6.18 6.18 0 0 0 .286.01l.015.001H7.5v1.5h-.75v-.75Z"/></svg>',Ot='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m9.586 14.633.021.004c-.036.335.095.655.393.962.082.083.173.15.274.201h1.474a.6.6 0 1 1 0 1.2H5.304a.6.6 0 0 1 0-1.2h1.15c.474-.07.809-.182 1.005-.334.157-.122.291-.32.404-.597l2.416-9.55a1.053 1.053 0 0 0-.281-.823 1.12 1.12 0 0 0-.442-.296H8.15a.6.6 0 0 1 0-1.2h6.443a.6.6 0 1 1 0 1.2h-1.195c-.376.056-.65.155-.823.296-.215.175-.423.439-.623.79l-2.366 9.347z"/></svg>',Pt='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 20a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h8Zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H15.5a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1H35Z" fill-opacity="0.16"/><path d="M8.508 9.561c.378-.294.638-.637.778-1.03h1.004V15H9.053v-4.654a4.257 4.257 0 0 1-1.595.936v-1.121c.322-.105.672-.305 1.05-.6Z"/><path d="M13.033 13.765V15h-1.235v-1.235h1.235Z"/><path d="M14.332 23.969v-6.47h-1.004c-.14.394-.4.737-.778 1.03-.378.296-.728.496-1.05.6v1.122a4.257 4.257 0 0 0 1.595-.936v4.654h1.237Z"/><path d="M17.075 23.969v-1.235H15.84v1.235h1.235Z"/><path d="M21.174 23.969v-6.47H20.17c-.14.394-.4.737-.778 1.03-.378.296-.728.496-1.05.6v1.122a4.258 4.258 0 0 0 1.595-.936v4.654h1.237Z"/><path d="M23.917 23.969v-1.235h-1.235v1.235h1.235Z"/><path d="M19.032 27.5v6.469h-1.237v-4.654a4.257 4.257 0 0 1-1.595.936V29.13c.322-.105.672-.305 1.05-.6.378-.294.638-.637.778-1.03h1.004Z"/><path d="M21.775 32.734v1.235H20.54v-1.235h1.235Z"/><path d="M26.132 34.069v-6.47h-1.004c-.14.394-.4.737-.778 1.03-.378.296-.728.496-1.05.6v1.122a4.257 4.257 0 0 0 1.595-.936v4.654h1.237Z"/><path d="M28.875 34.069v-1.235H27.64v1.235h1.235Z"/><path d="M33.232 34.069v-6.47h-1.004c-.14.394-.4.737-.778 1.03-.378.296-.728.496-1.05.6v1.122a4.257 4.257 0 0 0 1.595-.936v4.654h1.237Z"/><path d="M35.975 34.069v-1.235H34.74v1.235h1.235Z"/></svg>',jt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.077 15 .991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955.741.741 0 0 1 .12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184z"/></svg>',Rt='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11 27a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0 1a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm0-10a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0 1a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm0-10a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0 1a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/></svg>',Ft='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M5.714 15.11c.624 0 1.11-.22 1.46-.66.421-.533.632-1.408.632-2.627 0-1.222-.21-2.096-.629-2.624-.351-.445-.839-.668-1.463-.668-.624 0-1.11.22-1.459.66-.422.533-.633 1.406-.633 2.619 0 1.236.192 2.095.576 2.577.384.482.89.723 1.516.723zm0-1.024a.614.614 0 0 1-.398-.14c-.115-.094-.211-.283-.287-.565-.077-.283-.115-.802-.115-1.558s.043-1.294.128-1.613c.064-.246.155-.417.272-.512a.617.617 0 0 1 .4-.143.61.61 0 0 1 .398.143c.116.095.211.284.288.567.076.283.114.802.114 1.558s-.043 1.292-.128 1.608c-.064.246-.155.417-.272.512a.617.617 0 0 1-.4.143zm6.078.914V8.531H10.79c-.14.393-.4.736-.778 1.03-.378.295-.728.495-1.05.6v1.121a4.257 4.257 0 0 0 1.595-.936V15h1.235zm3.344 0v-1.235h-1.235V15h1.235zm-9.422 9.11c.624 0 1.11-.22 1.46-.66.421-.533.632-1.408.632-2.627 0-1.222-.21-2.096-.629-2.624-.351-.445-.839-.668-1.463-.668-.624 0-1.11.22-1.459.66-.422.533-.633 1.406-.633 2.619 0 1.236.192 2.095.576 2.577.384.482.89.723 1.516.723zm0-1.024a.614.614 0 0 1-.398-.14c-.115-.094-.211-.283-.287-.565-.077-.283-.115-.802-.115-1.558s.043-1.294.128-1.613c.064-.246.155-.417.272-.512a.617.617 0 0 1 .4-.143.61.61 0 0 1 .398.143c.116.095.211.284.288.567.076.283.114.802.114 1.558s-.043 1.292-.128 1.608c-.064.246-.155.417-.272.512a.617.617 0 0 1-.4.143zm7.088.914v-1.147H10.35c.065-.111.149-.226.253-.343.104-.117.35-.354.74-.712.39-.357.66-.631.81-.821.225-.288.39-.562.493-.824.104-.263.156-.539.156-.829 0-.51-.181-.936-.544-1.279-.364-.342-.863-.514-1.499-.514-.58 0-1.063.148-1.45.444-.387.296-.617.784-.69 1.463l1.23.124c.024-.36.112-.619.264-.774.152-.155.357-.233.615-.233.261 0 .465.074.613.222.148.148.222.36.222.635 0 .25-.085.501-.255.756-.126.185-.467.536-1.024 1.055-.691.641-1.154 1.156-1.388 1.544-.235.389-.375.8-.422 1.233h4.328zm2.334 0v-1.235h-1.235V24h1.235zM5.714 34.11c.624 0 1.11-.22 1.46-.66.421-.533.632-1.408.632-2.627 0-1.222-.21-2.096-.629-2.624-.351-.445-.839-.668-1.463-.668-.624 0-1.11.22-1.459.66-.422.533-.633 1.406-.633 2.619 0 1.236.192 2.095.576 2.577.384.482.89.723 1.516.723zm0-1.024a.614.614 0 0 1-.398-.14c-.115-.094-.211-.283-.287-.565-.077-.283-.115-.802-.115-1.558s.043-1.294.128-1.613c.064-.246.155-.417.272-.512a.617.617 0 0 1 .4-.143.61.61 0 0 1 .398.143c.116.095.211.284.288.567.076.283.114.802.114 1.558s-.043 1.292-.128 1.608c-.064.246-.155.417-.272.512a.617.617 0 0 1-.4.143zm4.992 1.024c.616 0 1.13-.2 1.543-.598.413-.398.62-.88.62-1.446 0-.39-.111-.722-.332-.997a1.5 1.5 0 0 0-.886-.532c.618-.337.927-.788.927-1.353 0-.399-.15-.756-.452-1.073-.366-.386-.853-.58-1.46-.58a2.25 2.25 0 0 0-.96.2 1.617 1.617 0 0 0-.667.55c-.16.232-.28.544-.359.933l1.139.194c.032-.282.123-.495.272-.642.15-.146.33-.22.54-.22.214 0 .386.065.515.194s.193.302.193.518c0 .255-.088.46-.264.613-.175.154-.43.227-.764.218l-.136 1.006c.22-.061.408-.092.566-.092.24 0 .444.09.611.272.167.182.25.428.25.739 0 .328-.086.589-.26.782a.833.833 0 0 1-.644.29.841.841 0 0 1-.607-.242c-.167-.16-.27-.394-.308-.698l-1.195.145c.062.542.284.98.668 1.316.384.335.867.503 1.45.503zm4.43-.11v-1.235h-1.235V34h1.235z"/></svg>',Nt='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M10.29 15V8.531H9.286c-.14.393-.4.736-.778 1.03-.378.295-.728.495-1.05.6v1.121a4.257 4.257 0 0 0 1.595-.936V15h1.235zm3.343 0v-1.235h-1.235V15h1.235zM11.3 24v-1.147H8.848c.064-.111.148-.226.252-.343.104-.117.351-.354.74-.712.39-.357.66-.631.81-.821.225-.288.39-.562.494-.824.104-.263.156-.539.156-.829 0-.51-.182-.936-.545-1.279-.363-.342-.863-.514-1.499-.514-.58 0-1.063.148-1.45.444-.387.296-.617.784-.69 1.463l1.23.124c.024-.36.112-.619.264-.774.153-.155.358-.233.616-.233.26 0 .465.074.613.222.148.148.222.36.222.635 0 .25-.085.501-.255.756-.126.185-.468.536-1.024 1.055-.692.641-1.155 1.156-1.389 1.544-.234.389-.375.8-.422 1.233H11.3zm2.333 0v-1.235h-1.235V24h1.235zM9.204 34.11c.615 0 1.129-.2 1.542-.598.413-.398.62-.88.62-1.446 0-.39-.11-.722-.332-.997a1.5 1.5 0 0 0-.886-.532c.619-.337.928-.788.928-1.353 0-.399-.151-.756-.453-1.073-.366-.386-.852-.58-1.459-.58a2.25 2.25 0 0 0-.96.2 1.617 1.617 0 0 0-.668.55c-.16.232-.28.544-.358.933l1.138.194c.032-.282.123-.495.272-.642.15-.146.33-.22.54-.22.215 0 .386.065.515.194s.193.302.193.518c0 .255-.087.46-.263.613-.176.154-.43.227-.765.218l-.136 1.006c.22-.061.409-.092.567-.092.24 0 .444.09.61.272.168.182.251.428.251.739 0 .328-.087.589-.261.782a.833.833 0 0 1-.644.29.841.841 0 0 1-.607-.242c-.167-.16-.27-.394-.307-.698l-1.196.145c.062.542.285.98.668 1.316.384.335.868.503 1.45.503zm4.43-.11v-1.235h-1.236V34h1.235z"/></svg>',Ut='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11 27a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0-9a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0-9a3 3 0 1 1 0 6 3 3 0 0 1 0-6z"/></svg>',Wt='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M9.62 14.105c.272 0 .528-.05.768-.153s.466-.257.677-.462c.009.024.023.072.044.145.047.161.086.283.119.365h1.221a2.649 2.649 0 0 1-.222-.626c-.04-.195-.059-.498-.059-.908l.013-1.441c0-.536-.055-.905-.165-1.105-.11-.201-.3-.367-.569-.497-.27-.13-.68-.195-1.23-.195-.607 0-1.064.108-1.371.325-.308.217-.525.55-.65 1.002l1.12.202c.076-.217.176-.369.299-.455.123-.086.294-.13.514-.13.325 0 .546.05.663.152.118.101.176.27.176.508v.123c-.222.093-.622.194-1.2.303-.427.082-.755.178-.982.288-.227.11-.403.268-.53.474a1.327 1.327 0 0 0-.188.706c0 .398.138.728.415.988.277.261.656.391 1.136.391zm.368-.87a.675.675 0 0 1-.492-.189.606.606 0 0 1-.193-.448c0-.176.08-.32.241-.435.106-.07.33-.142.673-.215a7.19 7.19 0 0 0 .751-.19v.247c0 .296-.016.496-.048.602a.773.773 0 0 1-.295.409 1.07 1.07 0 0 1-.637.22zm4.645.765v-1.235h-1.235V14h1.235zM10.2 25.105c.542 0 1.003-.215 1.382-.646.38-.43.57-1.044.57-1.84 0-.771-.187-1.362-.559-1.774a1.82 1.82 0 0 0-1.41-.617c-.522 0-.973.216-1.354.65v-2.32H7.594V25h1.147v-.686a1.9 1.9 0 0 0 .67.592c.26.133.523.2.79.2zm-.299-.975c-.354 0-.638-.164-.852-.492-.153-.232-.229-.59-.229-1.073 0-.468.098-.818.295-1.048a.93.93 0 0 1 .738-.345c.302 0 .55.118.743.354.193.236.29.62.29 1.154 0 .5-.096.868-.288 1.1-.192.233-.424.35-.697.35zm4.478.87v-1.235h-1.234V25h1.234zm-4.017 9.105c.6 0 1.08-.142 1.437-.426.357-.284.599-.704.725-1.261l-1.213-.207c-.061.326-.167.555-.316.688a.832.832 0 0 1-.576.2.916.916 0 0 1-.75-.343c-.185-.228-.278-.62-.278-1.173 0-.498.091-.853.274-1.066.183-.212.429-.318.736-.318.232 0 .42.061.565.184.145.123.238.306.28.55l1.216-.22c-.146-.501-.387-.874-.722-1.119-.336-.244-.788-.366-1.356-.366-.695 0-1.245.214-1.653.643-.407.43-.61 1.03-.61 1.8 0 .762.202 1.358.608 1.788.406.431.95.646 1.633.646zM14.633 34v-1.235h-1.235V34h1.235z"/></svg>',$t='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11.88 8.7V7.558h-1.234V8.7h1.234zm0 5.3V9.333h-1.234V14h1.234zm2.5 0v-1.235h-1.234V14h1.235zm-4.75 4.7v-1.142H8.395V18.7H9.63zm0 5.3v-4.667H8.395V24H9.63zm2.5-5.3v-1.142h-1.234V18.7h1.235zm0 5.3v-4.667h-1.234V24h1.235zm2.501 0v-1.235h-1.235V24h1.235zM7.38 28.7v-1.142H6.145V28.7H7.38zm0 5.3v-4.667H6.145V34H7.38zm2.5-5.3v-1.142H8.646V28.7H9.88zm0 5.3v-4.667H8.646V34H9.88zm2.5-5.3v-1.142h-1.234V28.7h1.235zm0 5.3v-4.667h-1.234V34h1.235zm2.501 0v-1.235h-1.235V34h1.235z"/></svg>',qt='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M14 27v6H8v-6h6zm0-9v6H8v-6h6zm0-9v6H8V9h6z"/></svg>',Kt='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="m7.88 15 .532-1.463h2.575L11.549 15h1.415l-2.58-6.442H9.01L6.5 15h1.38zm2.69-2.549H8.811l.87-2.39.887 2.39zM14.88 15v-1.235h-1.234V15h1.234zM9.352 25c.83-.006 1.352-.02 1.569-.044.346-.038.636-.14.872-.305.236-.166.422-.387.558-.664.137-.277.205-.562.205-.855 0-.372-.106-.695-.317-.97-.21-.276-.512-.471-.905-.585a1.51 1.51 0 0 0 .661-.567 1.5 1.5 0 0 0 .244-.83c0-.28-.066-.53-.197-.754a1.654 1.654 0 0 0-.495-.539 1.676 1.676 0 0 0-.672-.266c-.25-.042-.63-.063-1.14-.063H7.158V25h2.193zm.142-3.88H8.46v-1.49h.747c.612 0 .983.007 1.112.022.217.026.38.102.49.226.11.125.165.287.165.486a.68.68 0 0 1-.192.503.86.86 0 0 1-.525.23 11.47 11.47 0 0 1-.944.023h.18zm.17 2.795H8.46v-1.723h1.05c.592 0 .977.03 1.154.092.177.062.313.16.406.295a.84.84 0 0 1 .14.492c0 .228-.06.41-.181.547a.806.806 0 0 1-.473.257c-.126.026-.423.04-.892.04zM14.88 25v-1.235h-1.234V25h1.234zm-5.018 9.11c.691 0 1.262-.17 1.711-.512.45-.341.772-.864.965-1.567l-1.261-.4c-.109.472-.287.818-.536 1.037-.25.22-.547.33-.892.33-.47 0-.85-.173-1.143-.519-.293-.345-.44-.925-.44-1.74 0-.767.15-1.322.447-1.665.297-.343.684-.514 1.162-.514.346 0 .64.096.881.29.242.193.4.457.477.79l1.288-.307c-.147-.516-.367-.911-.66-1.187-.492-.465-1.132-.698-1.92-.698-.902 0-1.63.296-2.184.89-.554.593-.83 1.426-.83 2.498 0 1.014.275 1.813.825 2.397.551.585 1.254.877 2.11.877zM14.88 34v-1.235h-1.234V34h1.234z"/></svg>',Gt='<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11.916 15V8.558h-1.301V15h1.3zm2.465 0v-1.235h-1.235V15h1.235zM9.665 25v-6.442h-1.3V25h1.3zm2.5 0v-6.442h-1.3V25h1.3zm2.466 0v-1.235h-1.235V25h1.235zm-7.216 9v-6.442h-1.3V34h1.3zm2.5 0v-6.442h-1.3V34h1.3zm2.501 0v-6.442h-1.3V34h1.3zm2.465 0v-1.235h-1.235V34h1.235z"/></svg>',Jt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M4.2 3c-.584 0-1.145.23-1.557.643A2.203 2.203 0 0 0 2 5.199v8.719a2.194 2.194 0 0 0 2.2 2.195h11.624a2.194 2.194 0 0 0 2.196-2.195V7.621a2.194 2.194 0 0 0-2.195-2.2h-5.393l-1.237-2.06A.752.752 0 0 0 8.56 3H4.2Zm0 1.488h3.935l1.236 2.06a.75.75 0 0 0 .64.362h5.813a.712.712 0 0 1 .707.71v6.298a.707.707 0 0 1-.707.707H4.2a.71.71 0 0 1-.71-.707V5.199a.711.711 0 0 1 .71-.71Z"/></svg>',Qt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M12.68 13.74h-.001l4.209 4.208a1 1 0 1 0 1.414-1.414l-4.267-4.268a6 6 0 1 0-1.355 1.474ZM13 9a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/></svg>',Xt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M5.085 6.22 2.943 4.078a.75.75 0 1 1 1.06-1.06l2.592 2.59A11.094 11.094 0 0 1 10 5.068c4.738 0 8.578 3.101 8.578 5.083 0 1.197-1.401 2.803-3.555 3.887l1.714 1.713a.75.75 0 0 1-.09 1.138.488.488 0 0 1-.15.084.75.75 0 0 1-.821-.16L6.17 7.304c-.258.11-.51.233-.757.365l6.239 6.24-.006.005.78.78c-.388.094-.78.166-1.174.215l-1.11-1.11h.011L4.55 8.197a7.2 7.2 0 0 0-.665.514l-.112.098 4.897 4.897-.005.006 1.276 1.276a10.164 10.164 0 0 1-1.477-.117l-.479-.479-.009.009-4.863-4.863-.022.031a2.563 2.563 0 0 0-.124.2c-.043.077-.08.158-.108.241a.534.534 0 0 0-.028.133.29.29 0 0 0 .008.072.927.927 0 0 0 .082.226c.067.133.145.26.234.379l3.242 3.365.025.01.59.623c-3.265-.918-5.59-3.155-5.59-4.668 0-1.194 1.448-2.838 3.663-3.93zm7.07.531a4.632 4.632 0 0 1 1.108 5.992l.345.344.046-.018a9.313 9.313 0 0 0 2-1.112c.256-.187.5-.392.727-.613.137-.134.27-.277.392-.431.072-.091.141-.185.203-.286.057-.093.107-.19.148-.292a.72.72 0 0 0 .036-.12.29.29 0 0 0 .008-.072.492.492 0 0 0-.028-.133.999.999 0 0 0-.036-.096 2.165 2.165 0 0 0-.071-.145 2.917 2.917 0 0 0-.125-.2 3.592 3.592 0 0 0-.263-.335 5.444 5.444 0 0 0-.53-.523 7.955 7.955 0 0 0-1.054-.768 9.766 9.766 0 0 0-1.879-.891c-.337-.118-.68-.219-1.027-.301zm-2.85.21-.069.002a.508.508 0 0 0-.254.097.496.496 0 0 0-.104.679.498.498 0 0 0 .326.199l.045.005c.091.003.181.003.272.012a2.45 2.45 0 0 1 2.017 1.513c.024.061.043.125.069.185a.494.494 0 0 0 .45.287h.008a.496.496 0 0 0 .35-.158.482.482 0 0 0 .13-.335.638.638 0 0 0-.048-.219 3.379 3.379 0 0 0-.36-.723 3.438 3.438 0 0 0-2.791-1.543l-.028-.001h-.013z"/></svg>',Yt='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M15.346 9.422a.151.151 0 0 1 .284 0l.548 1.484a.152.152 0 0 0 .09.089l1.483.549a.151.151 0 0 1 0 .284l-1.483.548a.151.151 0 0 0-.09.09l-.548 1.483a.152.152 0 0 1-.142.1.151.151 0 0 1-.142-.1l-.549-1.483a.15.15 0 0 0-.09-.09l-1.483-.548a.15.15 0 0 1 0-.284l1.484-.549a.152.152 0 0 0 .089-.09l.549-1.483Z"/><path d="M16.306 1.742a.151.151 0 0 1 .284 0l.549 1.483a.15.15 0 0 0 .089.09l1.483.548a.151.151 0 0 1 .072.229.151.151 0 0 1-.072.055l-1.483.549a.15.15 0 0 0-.09.09l-.548 1.482a.151.151 0 0 1-.284 0l-.549-1.483a.15.15 0 0 0-.09-.09l-1.483-.548a.151.151 0 0 1 0-.284l1.484-.549a.152.152 0 0 0 .09-.089l.548-1.483Z"/><path d="M7.665 1.742a.151.151 0 0 1 .284 0l.549 1.483a.151.151 0 0 0 .09.09l1.482.548a.151.151 0 0 1 .072.229.151.151 0 0 1-.072.055l-1.483.549a.151.151 0 0 0-.09.09L7.95 6.267a.151.151 0 0 1-.284 0l-.549-1.483a.151.151 0 0 0-.089-.09l-1.483-.548a.151.151 0 0 1 0-.284l1.483-.549a.151.151 0 0 0 .09-.089l.548-1.483-.001.001Z"/><path d="M14.72 7.946a.848.848 0 0 0 .25-.591.824.824 0 0 0-.241-.588l-1.943-1.938a.812.812 0 0 0-.588-.241.838.838 0 0 0-.591.25l-1.545 1.539 3.115 3.115 1.542-1.546h.001Z"/><path clip-rule="evenodd" d="M1.19 15.636a.96.96 0 0 1 .281-.679l7.835-7.834 3.121 3.12-7.834 7.835a.959.959 0 0 1-1.358 0l-1.764-1.764a.96.96 0 0 1-.28-.678Zm9.22-5.391-1.121-1.12-6.479 6.478 1.121 1.121 6.479-6.479Z"/></svg>',te='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path class="ck-icon__fill" d="M10.798 1.59 3.002 12.875l1.895 1.852 2.521 1.402 6.997-12.194z"/><path d="m2.556 16.727.234-.348c-.297-.151-.462-.293-.498-.426-.036-.137.002-.416.115-.837.094-.25.15-.449.169-.595a4.495 4.495 0 0 0 0-.725c-.209-.621-.303-1.041-.284-1.26.02-.218.178-.506.475-.862l6.77-9.414c.539-.91 1.605-.85 3.199.18 1.594 1.032 2.188 1.928 1.784 2.686l-5.877 10.36c-.158.412-.333.673-.526.782-.193.108-.604.179-1.232.21-.362.131-.608.237-.738.318-.13.081-.305.238-.526.47-.293.265-.504.397-.632.397-.096 0-.27-.075-.524-.226l-.31.41-1.6-1.12zm-.279.415 1.575 1.103-.392.515H1.19l1.087-1.618zm8.1-13.656-4.953 6.9L8.75 12.57l4.247-7.574c.175-.25-.188-.647-1.092-1.192-.903-.546-1.412-.652-1.528-.32zM8.244 18.5 9.59 17h9.406v1.5H8.245z"/></svg>',ee='<svg viewBox="0 0 64 42" xmlns="http://www.w3.org/2000/svg"><path d="M47.426 17V3.713L63.102 0v19.389h-.001l.001.272c0 1.595-2.032 3.43-4.538 4.098-2.506.668-4.538-.083-4.538-1.678 0-1.594 2.032-3.43 4.538-4.098.914-.244 2.032-.565 2.888-.603V4.516L49.076 7.447v9.556A1.014 1.014 0 0 0 49 17h-1.574zM29.5 17h-8.343a7.073 7.073 0 1 0-4.657 4.06v3.781H3.3a2.803 2.803 0 0 1-2.8-2.804V8.63a2.803 2.803 0 0 1 2.8-2.805h4.082L8.58 2.768A1.994 1.994 0 0 1 10.435 1.5h8.985c.773 0 1.477.448 1.805 1.149l1.488 3.177H26.7c1.546 0 2.8 1.256 2.8 2.805V17zm-11.637 0H17.5a1 1 0 0 0-1 1v.05A4.244 4.244 0 1 1 17.863 17zm29.684 2c.97 0 .953-.048.953.889v20.743c0 .953.016.905-.953.905H19.453c-.97 0-.953.048-.953-.905V19.89c0-.937-.016-.889.97-.889h28.077zm-4.701 19.338V22.183H24.154v16.155h18.692zM20.6 21.375v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616v-1.616H20.6zm0 3.231v1.616h1.616V37.53H20.6zm24.233-16.155v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615v-1.616h-1.615zm0 3.231v1.616h1.615V37.53h-1.615zM29.485 25.283a.4.4 0 0 1 .593-.35l9.05 4.977a.4.4 0 0 1 0 .701l-9.05 4.978a.4.4 0 0 1-.593-.35v-9.956z"/></svg>',ae='<svg viewBox="0 0 22 20" xmlns="http://www.w3.org/2000/svg"><path d="M1.587 1.5c-.612 0-.601-.029-.601.551v14.84c0 .59-.01.559.591.559h18.846c.602 0 .591.03.591-.56V2.052c0-.58.01-.55-.591-.55H1.587Zm.701.971h1.003v1H2.288v-1Zm16.448 0h1.003v1h-1.003v-1Zm-14.24 1h13.008v12H4.467l.029-12Zm-2.208 1h1.003v1H2.288v-1Zm16.448 0h1.003v1h-1.003v-1Zm-16.448 2h1.003v1H2.288v-1Zm16.448 0h1.003v1h-1.003v-1Zm-16.448 2h1.003v1H2.288v-1Zm16.448 0h1.003v1h-1.003v-1Zm-16.448 2h1.003v1H2.288v-1Zm16.448 0h1.003v1h-1.003v-1Zm-16.448 2h1.003l-.029 1h-.974v-1Zm16.448 0h1.003v1h-1.003v-1Zm-16.448 2h.974v1h-.974v-1Zm16.448 0h1.003v1h-1.003v-1Z"/><path d="M8.374 6.648a.399.399 0 0 1 .395-.4.402.402 0 0 1 .2.049l5.148 2.824a.4.4 0 0 1 0 .7l-5.148 2.824a.403.403 0 0 1-.595-.35V6.648Z"/></svg>',ie='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7.85 6.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 1 1 0 1.5h-9.5Z"/><path d="M3 8V2.7H.5v1h1V8H3Z"/><path d="M13.42 14.185a.75.75 0 0 0 .53 1.28h3.4a.75.75 0 1 0 0-1.5h-3.4a.75.75 0 0 0-.53.22Z"/><path d="M5.636 8.035V6.8H4.4v1.235h1.236Z"/><path d="M9 17.865v-5.3H6.5v1h1v4.3H9Z"/><path d="M11.636 17.9v-1.235H10.4V17.9h1.236Z"/><path d="M3.2 17.865v-5.3H.7v1h1v4.3h1.5Z"/><path d="M5.836 17.9v-1.235H4.6V17.9h1.236Z"/></svg>',le='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.537 14.813a.888.888 0 1 1-1.254-1.255L10.84 10 7.283 6.442a.888.888 0 1 1 1.254-1.255L12.74 9.39a.888.888 0 0 1-.16 1.382l-4.043 4.042z"/></svg>',ne='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm1 15H9v-2h2v2zm0-4H9V5h2v6z"/></svg>',oe='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 5.75c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zM3.5 3v5H2V3.7H1v-1h2.5V3zM.343 17.857l2.59-3.257H2.92a.6.6 0 1 0-1.04 0H.302a2 2 0 1 1 3.995 0h-.001c-.048.405-.16.734-.333.988-.175.254-.59.692-1.244 1.312H4.3v1h-4l.043-.043zM7 14.75a.75.75 0 0 1 .75-.75h9.5a.75.75 0 1 1 0 1.5h-9.5a.75.75 0 0 1-.75-.75z"/></svg>',se='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm0 12h16v1.5H2z"/><path d="M15.003 7v5.5a1 1 0 0 1-1 1H5.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H6.5V12h6.997V7.5z"/></svg>',he='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm0 12h16v1.5H2z"/><path d="M18 7v5.5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1zm-1.505.5H3.504V12h12.991V7.5z"/></svg>',re='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm11.5 9H18v1.5h-4.5zm0-3H18v1.5h-4.5zm0-3H18v1.5h-4.5zM2 15h16v1.5H2z"/><path d="M12.003 7v5.5a1 1 0 0 1-1 1H2.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H3.5V12h6.997V7.5z"/></svg>',ve='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm0 12h16v1.5H2zm0-9h5v1.5H2zm0 3h5v1.5H2zm0 3h5v1.5H2z"/><path d="M18.003 7v5.5a1 1 0 0 1-1 1H8.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H9.5V12h6.997V7.5z"/></svg>',ce='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm11.5 9H18v1.5h-4.5zM2 15h16v1.5H2z"/><path d="M12.003 7v5.5a1 1 0 0 1-1 1H2.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H3.5V12h6.997V7.5z"/></svg>',de='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm0 12h16v1.5H2z"/><path d="M12.003 7v5.5a1 1 0 0 1-1 1H2.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H3.5V12h6.997V7.5z"/></svg>',me='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm0 12h16v1.5H2z"/><path d="M18.003 7v5.5a1 1 0 0 1-1 1H8.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H9.5V12h6.997V7.5z"/></svg>',ge='<svg xmlns="http://www.w3.org/2000/svg" xmlns:v="https://vecta.io/nano" viewBox="0 0 20 20"><path d="M.95 1.43a.95.95 0 0 0-.95.95v3.1a.95.95 0 0 0 .95.95h.75v6.3H.95a.95.95 0 0 0-.95.95v3.1a.95.95 0 0 0 .95.95h3.1a.95.95 0 0 0 .95-.95v-.65h1.932l1.539-1.5H5v-.95a.95.95 0 0 0-.95-.95H3.2v-6.3h.85A.95.95 0 0 0 5 5.48v-.55h10v.55a.95.95 0 0 0 .95.95h3.1a.95.95 0 0 0 .95-.95v-3.1a.95.95 0 0 0-.95-.95h-3.1a.95.95 0 0 0-.95.95v1.05H5V2.38a.95.95 0 0 0-.95-.95H.95zm.55 3.5v-2h2v2h-2zm0 9.3v2h2v-2h-2zm15-11.3v2h2v-2h-2z"/><path d="M8.139 20.004v-2.388l7.045-7.048 2.391 2.391-7.046 7.046h-2.39zm11.421-9.101a.64.64 0 0 1-.138.206l-1.165 1.168-2.391-2.391 1.167-1.163a.63.63 0 0 1 .206-.138.635.635 0 0 1 .243-.049.63.63 0 0 1 .449.187l1.491 1.488c.059.059.108.129.138.206s.049.16.049.243a.6.6 0 0 1-.049.243z"/></svg>',pe='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M2.5 17v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zm2 0v1h-1v-1h1zM1 15.5v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm-19-2v1H0v-1h1zm19 0v1h-1v-1h1zm0-2v1h-1v-1h1zm-19 0v1H0v-1h1zM14.5 2v1h-1V2h1zm2 0v1h-1V2h1zm2 0v1h-1V2h1zm-8 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm-2 0v1h-1V2h1zm8 0v1h-1V2h1zm-10 0v1h-1V2h1z"/><path d="M18.095 2H1.905C.853 2 0 2.895 0 4v12c0 1.105.853 2 1.905 2h16.19C19.147 18 20 17.105 20 16V4c0-1.105-.853-2-1.905-2zm0 1.5c.263 0 .476.224.476.5v12c0 .276-.213.5-.476.5H1.905a.489.489 0 0 1-.476-.5V4c0-.276.213-.5.476-.5h16.19z"/></svg>',we='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2.5 16.5v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1ZM1 15v1H0v-1h1Zm19 0v1h-1v-1h1ZM1 13v1H0v-1h1Zm19 0v1h-1v-1h1ZM1 11v1H0v-1h1Zm19 0v1h-1v-1h1ZM1 9v1H0V9h1Zm19 0v1h-1V9h1ZM1 7v1H0V7h1Zm19 0v1h-1V7h1ZM1 5v1H0V5h1Zm19 0v1h-1V5h1Zm0-2v1h-1V3h1ZM1 3v1H0V3h1Zm13.5-1.5v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm-8 0v1h-1v-1h1Zm-2 0v1h-1v-1h1Zm-2 0v1h-1v-1h1Zm-2 0v1h-1v-1h1Zm8 0v1h-1v-1h1Zm-10 0v1h-1v-1h1Z"/><path d="M13 5.5H2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2ZM13 7a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5v-8A.5.5 0 0 1 2 7h11Z"/></svg>',ue='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2.5 16.5v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1ZM1 15v1H0v-1h1Zm19 0v1h-1v-1h1ZM1 13v1H0v-1h1Zm19 0v1h-1v-1h1ZM1 11v1H0v-1h1Zm19 0v1h-1v-1h1ZM1 9v1H0V9h1Zm19 0v1h-1V9h1ZM1 7v1H0V7h1Zm19 0v1h-1V7h1ZM1 5v1H0V5h1Zm19 0v1h-1V5h1Zm0-2v1h-1V3h1ZM1 3v1H0V3h1Zm13.5-1.5v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm-8 0v1h-1v-1h1Zm-2 0v1h-1v-1h1Zm-2 0v1h-1v-1h1Zm-2 0v1h-1v-1h1Zm8 0v1h-1v-1h1Zm-10 0v1h-1v-1h1Z"/><path d="M10 7.5H2a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2ZM10 9a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5v-6A.5.5 0 0 1 2 9h8Z"/></svg>',Me='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2.5 16.5v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1ZM1 15v1H0v-1h1Zm19 0v1h-1v-1h1ZM1 13v1H0v-1h1Zm19 0v1h-1v-1h1ZM1 11v1H0v-1h1Zm19 0v1h-1v-1h1ZM1 9v1H0V9h1Zm19 0v1h-1V9h1ZM1 7v1H0V7h1Zm19 0v1h-1V7h1ZM1 5v1H0V5h1Zm19 0v1h-1V5h1Zm0-2v1h-1V3h1ZM1 3v1H0V3h1Zm13.5-1.5v1h-1v-1h1Zm2 0v1h-1v-1h1Zm2 0v1h-1v-1h1Zm-8 0v1h-1v-1h1Zm-2 0v1h-1v-1h1Zm-2 0v1h-1v-1h1Zm-2 0v1h-1v-1h1Zm8 0v1h-1v-1h1Zm-10 0v1h-1v-1h1Z"/><path d="M7 9.5H2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2ZM7 11a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5v-4A.5.5 0 0 1 2 11h5Z"/></svg>',He='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.223 5.001a5.277 5.277 0 0 0-4.408 2.258c-3.224.423-4.908 4.123-3.032 6.814l.004.008.002.004c.48.627 1.712 1.84 3.588 1.84h7.936c.667 0 1.32-.179 1.894-.522a3.838 3.838 0 0 0 1.381-1.46v-.005c1.13-2.16-.133-4.777-2.488-5.298-.617-1.853-2.177-3.242-4.111-3.565a5.273 5.273 0 0 0-.766-.074Zm-.092 1.5a2.5 2.5 0 0 1 .23.008c.077.004.154.014.231.021l.226.035a5.203 5.203 0 0 1 .45.116 3.31 3.31 0 0 1 .433.166 4.079 4.079 0 0 1 .606.348 4.195 4.195 0 0 1 .37.293 3.54 3.54 0 0 1 .33.348 3.517 3.517 0 0 1 .505.778 3.182 3.182 0 0 0-.42.117c-.082.03-.164.06-.244.094l-1.49.627-2.686-1.61a4.39 4.39 0 0 0-1.002-.445c.015-.01.032-.026.047-.039a3.744 3.744 0 0 1 .4-.289 3.713 3.713 0 0 1 .424-.23 3.02 3.02 0 0 1 .219-.094c.074-.03.15-.053.225-.076a3.77 3.77 0 0 1 .222-.06c.077-.02.157-.035.233-.05.075-.015.15-.025.228-.033.077-.007.154-.015.233-.02.078-.003.154-.005.23-.005Zm-3.8 2.193h.003c.54.001 1.111.156 1.551.428l1.783 1.07-5.867 2.471c-.535-1.29-.15-2.788 1.059-3.537l.007-.004a2.88 2.88 0 0 1 1.463-.428Zm7.974 1.33.152.008c.086.008.173.018.258.033a1.956 1.956 0 0 1 .477.145 2.179 2.179 0 0 1 .918.756c.046.066.09.133.127.2a2.284 2.284 0 0 1 .269.919c.004.081.008.165.002.248-.003.055-.012.111-.02.166l-3.507-2.102.459-.193.015-.008a2.118 2.118 0 0 1 .41-.125 2.297 2.297 0 0 1 .44-.047Zm-3.008 1.112 4.506 2.699a2.217 2.217 0 0 1-.338.26 2.228 2.228 0 0 1-.668.275c-.078.019-.157.03-.24.04-.081.007-.164.015-.246.015H6.373a3.09 3.09 0 0 1-.168-.004 1.904 1.904 0 0 1-.164-.016l-.154-.023c-.051-.008-.102-.014-.15-.026a2.942 2.942 0 0 1-.77-.3 2.889 2.889 0 0 1-.21-.133c-.012-.008-.019-.016-.03-.024l6.57-2.763Z"/></svg>',ze='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 3.75c0 .414.336.75.75.75h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 0 0-.75.75zm5 6c0 .414.336.75.75.75h9.5a.75.75 0 1 0 0-1.5h-9.5a.75.75 0 0 0-.75.75zM2.75 16.5h14.5a.75.75 0 1 0 0-1.5H2.75a.75.75 0 1 0 0 1.5zm1.618-9.55L.98 9.358a.4.4 0 0 0 .013.661l3.39 2.207A.4.4 0 0 0 5 11.892V7.275a.4.4 0 0 0-.632-.326z"/></svg>',Ve='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3.598.687h1.5v5h-1.5zm14.5 0h1.5v5h-1.5z"/><path d="M19.598 4.187v1.5h-16v-1.5zm-16 14.569h1.5v-5h-1.5zm14.5 0h1.5v-5h-1.5z"/><path d="M19.598 15.256v-1.5h-16v1.5zM5.081 9h6v2h-6zm8 0h6v2h-6zm-9.483 1L0 12.5v-5z"/></svg>',fe='<svg viewBox="2 2 56 18" xmlns="http://www.w3.org/2000/svg"><path d="m12.527 14.733-.514.022-.057-.057-.058-.058.006-2.44.007-2.44-.834-.023-.833-.022.023-.406.024-.405.536-.1.536-.098.25-.145.25-.145.17-.429.172-.428.399-.024.398-.023v7.08l.02.059.02.059zm17.894.016h-.457l.043-.138.043-.137.635-2.004.636-2.004.437-1.375.436-1.375.055-.196.054-.197.38-.024.378-.024.07.069.07.07-1.162 3.667-1.16 3.667h-.458zm11.733 0h-.55V9.72h-1.65v-.864h.385l.4-.086.398-.086.233-.195.232-.196.138-.386.137-.387h.827v7.229zm6.404 0h-.51V13.02h-3.143v-1.01l1.044-1.44 1.045-1.44.59-.824.592-.825h.854l.02 2.298.021 2.298h1.02v.943H49.07v1.729zM47 12.06l1.008-.022.022-1.4.021-1.401-.07.044-.07.043-.901 1.279-.901 1.278-.058.1-.059.101z"/><path d="m12.256 22.893-10.53.01-.157-.038-.157-.038-.255-.12L.9 22.59v-.19l.228-.218.228-.218.16.086.16.085h20.922l.16-.085.16-.086.228.218.227.218v.189l-.294.146-.295.147z"/></svg>',xe='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31 20"><mask id="a" maskUnits="userSpaceOnUse" x="13" y="1" width="17" height="18"><rect x="13" y="1" width="17" height="18"/><path d="M14 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H15a1 1 0 0 1-1-1V3Z"/><path d="M27 3.25a1.5 1.5 0 0 1 1.5 1.5v1.7a2.25 2.25 0 0 1-1.932 2.226l-4.424.632a.75.75 0 0 0-.644.743V11a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H20a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1v-.95a2.25 2.25 0 0 1 1.932-2.226l4.424-.632A.75.75 0 0 0 27 6.449V3.25Z"/></mask><path d="M14 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H15a1 1 0 0 1-1-1V3Z"/><path d="M27 3.25a1.5 1.5 0 0 1 1.5 1.5v1.7a2.25 2.25 0 0 1-1.932 2.226l-4.424.632a.75.75 0 0 0-.644.743V11a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H20a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1v-.95a2.25 2.25 0 0 1 1.932-2.226l4.424-.632A.75.75 0 0 0 27 6.449V3.25Z"/><path d="M14 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H15a1 1 0 0 1-1-1V3Z" stroke-width="2" mask="url(#a)"/><path d="M27 3.25a1.5 1.5 0 0 1 1.5 1.5v1.7a2.25 2.25 0 0 1-1.932 2.226l-4.424.632a.75.75 0 0 0-.644.743V11a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H20a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1v-.95a2.25 2.25 0 0 1 1.932-2.226l4.424-.632A.75.75 0 0 0 27 6.449V3.25Z" stroke-width="2" mask="url(#a)"/><mask id="b" maskUnits="userSpaceOnUse" x="1.75" y="1.165" width="12" height="17"><rect x="1.75" y="1.165" width="12" height="17"/><path d="m12.25 9.96-9.5-7.795 2 12.124 2.384-2.53 2.75 4.762 1.732-1-2.75-4.763 3.384-.799Z"/></mask><path d="m12.25 9.96-9.5-7.795 2 12.124 2.384-2.53 2.75 4.762 1.732-1-2.75-4.763 3.384-.799Z"/><path d="m12.25 9.96-9.5-7.795 2 12.124 2.384-2.53 2.75 4.762 1.732-1-2.75-4.763 3.384-.799Z" stroke-width="2" stroke-linejoin="round" mask="url(#b)"/></svg>',Ze='<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31 20" width="30" height="20">\n <path d="M14 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H15a1 1 0 0 1-1-1V3Z" fill="#000"/>\n <path d="M27 3.25a1.5 1.5 0 0 1 1.5 1.5v1.7a2.25 2.25 0 0 1-1.932 2.226l-4.424.632a.75.75 0 0 0-.644.743V11a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H20a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1v-.95a2.25 2.25 0 0 1 1.932-2.226l4.424-.632A.75.75 0 0 0 27 6.449V3.25Z" fill="#000"/>\n <path fill-rule="evenodd" clip-rule="evenodd" d="M26.855 2.25H27a2.5 2.5 0 0 1 2.5 2.5v1.7a3.25 3.25 0 0 1-2.79 3.216l-4.21.602a2 2 0 0 1 1 1.732v5a2 2 0 0 1-2 2H20a2 2 0 0 1-2-2v-5a2 2 0 0 1 1-1.732v-.217A3.25 3.25 0 0 1 21.129 7H15a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h10a2 2 0 0 1 1.855 1.25ZM20 10.05V11a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h1.5a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1v-.95c0-.016 0-.033.002-.05a.75.75 0 0 1 .642-.692l4.424-.632A2.25 2.25 0 0 0 28.5 6.45V4.75a1.496 1.496 0 0 0-1.5-1.5v3.2a.75.75 0 0 1-.644.742l-4.424.632A2.25 2.25 0 0 0 20 10.05ZM15 2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H15Z" fill="#fff"/>\n <path d="M2.5 2.5A.5.5 0 0 1 3 2h2.5a.5.5 0 0 1 .354.146l.646.647.646-.647A.5.5 0 0 1 7.5 2H10a.5.5 0 0 1 0 1H7.707L7 3.707V10h.5a.5.5 0 0 1 0 1H7v4.793l.707.707H10a.5.5 0 0 1 0 1H7.5a.5.5 0 0 1-.354-.146l-.646-.647-.646.647a.5.5 0 0 1-.354.146H3a.5.5 0 0 1 0-1h2.293L6 15.793V11h-.5a.5.5 0 0 1 0-1H6V3.707L5.293 3H3a.5.5 0 0 1-.5-.5Z" fill="#000"/>\n <path fill-rule="evenodd" clip-rule="evenodd" d="m5.793 3.5-.5-.5H3a.5.5 0 0 1 0-1h2.5a.5.5 0 0 1 .354.146l.145.146.501.5.646-.646A.5.5 0 0 1 7.5 2H10a.5.5 0 0 1 0 1H7.707L7 3.707V10h.5a.5.5 0 0 1 0 1H7v4.793l.707.707H10a.5.5 0 0 1 0 1H7.5a.5.5 0 0 1-.354-.146l-.646-.647-.5.5-.146.147a.5.5 0 0 1-.354.146H3a.5.5 0 0 1 0-1h2.293L6 15.793V11h-.5a.5.5 0 0 1 0-1H6V3.707L5.793 3.5Zm-.914.5L5 4.121v4.964a1.5 1.5 0 0 0 0 2.83v3.464l-.121.121H3a1.5 1.5 0 0 0 0 3h2.5a1.5 1.5 0 0 0 1-.382 1.5 1.5 0 0 0 1 .382H10a1.5 1.5 0 0 0 0-3H8.121L8 15.379v-3.464a1.5 1.5 0 0 0 0-2.83V4.121L8.121 4H10a1.5 1.5 0 0 0 0-3H7.5a1.5 1.5 0 0 0-1 .382A1.5 1.5 0 0 0 5.5 1H3a1.5 1.5 0 1 0 0 3h1.879Z" fill="#fff"/>\n</svg>\n',be='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3Z"/><path d="M16 3.25a1.5 1.5 0 0 1 1.5 1.5v1.7a2.25 2.25 0 0 1-1.932 2.226l-4.424.632a.75.75 0 0 0-.644.743V11a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1v-.95a2.25 2.25 0 0 1 1.932-2.226l4.424-.632A.75.75 0 0 0 16 6.449V3.25Z"/></svg>',Le='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.5 5.5H7v5h3.5a2.5 2.5 0 1 0 0-5zM5 3h6.5v.025a5 5 0 0 1 0 9.95V13H7v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/></svg>',Ce='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m7.3 17.37-.061.088a1.518 1.518 0 0 1-.934.535l-4.178.663-.806-4.153a1.495 1.495 0 0 1 .187-1.058l.056-.086L8.77 2.639c.958-1.351 2.803-1.076 4.296-.03 1.497 1.047 2.387 2.693 1.433 4.055L7.3 17.37zM9.14 4.728l-5.545 8.346 3.277 2.294 5.544-8.346L9.14 4.728zM6.07 16.512l-3.276-2.295.53 2.73 2.746-.435zM9.994 3.506 13.271 5.8c.316-.452-.16-1.333-1.065-1.966-.905-.634-1.895-.78-2.212-.328zM8 18.5 9.375 17H19v1.5H8z"/></svg>',Ie='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path class="ck-icon__fill" d="M10.126 2.268 2.002 13.874l1.895 1.852 2.521 1.402L14.47 5.481l-1.543-2.568-2.801-.645z"/><path d="m4.5 18.088-2.645-1.852-.04-2.95-.006-.005.006-.008v-.025l.011.008L8.73 2.97c.165-.233.356-.417.567-.557l-1.212.308L4.604 7.9l-.83-.558 3.694-5.495 2.708-.69 1.65 1.145.046.018.85-1.216 2.16 1.512-.856 1.222c.828.967 1.144 2.141.432 3.158L7.55 17.286l.006.005-3.055.797H4.5zm-.634.166-1.976.516-.026-1.918 2.002 1.402zM9.968 3.817l-.006-.004-6.123 9.184 3.277 2.294 6.108-9.162.005.003c.317-.452-.16-1.332-1.064-1.966-.891-.624-1.865-.776-2.197-.349zM8.245 18.5 9.59 17h9.406v1.5H8.245z"/></svg>',ye='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M6.999 2H15a1 1 0 0 1 0 2h-1.004v13a1 1 0 1 1-2 0V4H8.999v13a1 1 0 1 1-2 0v-7A4 4 0 0 1 3 6a4 4 0 0 1 3.999-4z"/></svg>',Be='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M10 19a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm3.45-9.872a1 1 0 0 1 0 1.744l-4.96 2.79A1 1 0 0 1 7 12.79V7.21a1 1 0 0 1 1.49-.872l4.96 2.79Z"/></svg>',Ae='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 0 0-1 1v6H3a1 1 0 1 0 0 2h6v6a1 1 0 1 0 2 0v-6h6a1 1 0 1 0 0-2h-6V3a1 1 0 0 0-1-1Z"/></svg>',Ee='<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">\n<circle cx="9.00037" cy="9.79993" r="1.5"/>\n<path d="M5.75024 2.75005C5.75019 2.00005 5.75006 2.00005 5.75006 2.00005L5.74877 2.00005L5.74647 2.00006L5.73927 2.00009L5.71503 2.0003C5.6947 2.00053 5.66619 2.00098 5.63111 2.00185C5.56123 2.0036 5.46388 2.00707 5.35241 2.01402C5.14095 2.02722 4.83482 2.05536 4.56712 2.12276C4.1703 2.22267 3.82938 2.40399 3.55967 2.67392C3.29221 2.94161 3.1311 3.26001 3.03544 3.5803C2.85401 4.18776 2.8854 4.89393 2.92747 5.49256C2.9373 5.6324 2.94792 5.76849 2.95828 5.90131C2.99629 6.38849 3.03087 6.83163 3.01038 7.25369C2.98475 7.78147 2.87469 8.13279 2.6777 8.3656C2.45517 8.6286 2.1841 8.79405 1.95875 8.89436C1.84756 8.94386 1.75282 8.97509 1.68956 8.99319C1.65813 9.00219 1.63513 9.00776 1.62253 9.01062L1.61304 9.01269L1.00024 9.12173V9.75005H3.4023C3.54579 9.63123 3.68814 9.49364 3.82278 9.33451C4.33087 8.73405 4.47638 7.99036 4.50861 7.32643C4.5342 6.79933 4.48942 6.23163 4.4502 5.73429C4.44071 5.61404 4.43155 5.49785 4.42378 5.3874C4.38011 4.76596 4.37986 4.32043 4.4727 4.00956C4.51418 3.87069 4.56668 3.78828 4.62078 3.73414C4.67264 3.68223 4.76124 3.6207 4.93336 3.57736C5.06269 3.5448 5.25656 3.52293 5.44585 3.51111C5.53475 3.50556 5.61296 3.50277 5.66854 3.50139C5.6962 3.5007 5.71789 3.50036 5.73209 3.5002L5.74748 3.50007L5.75054 3.50005L6.5003 3.5L6.50019 2L5.75006 2.00005L5.75024 2.75005Z"/>\n<path d="M5.75024 16.7501C5.75019 17.5001 5.75006 17.5001 5.75006 17.5001L5.74877 17.5001L5.74647 17.5001L5.73927 17.5L5.71503 17.4998C5.6947 17.4996 5.66619 17.4991 5.63111 17.4983C5.56123 17.4965 5.46388 17.493 5.35241 17.4861C5.14095 17.4729 4.83482 17.4448 4.56712 17.3774C4.1703 17.2774 3.82938 17.0961 3.55967 16.8262C3.29221 16.5585 3.1311 16.2401 3.03544 15.9198C2.85401 15.3124 2.8854 14.6062 2.92747 14.0076C2.9373 13.8677 2.94792 13.7316 2.95828 13.5988C2.99629 13.1116 3.03087 12.6685 3.01038 12.2464C2.98475 11.7186 2.87469 11.3673 2.6777 11.1345C2.45517 10.8715 2.1841 10.7061 1.95875 10.6058C1.84756 10.5563 1.75282 10.525 1.68956 10.5069C1.65813 10.4979 1.63513 10.4924 1.62253 10.4895L1.61304 10.4874L1.00024 10.3784V9.75005H3.4023C3.54579 9.86887 3.68814 10.0065 3.82278 10.1656C4.33087 10.7661 4.47638 11.5098 4.50861 12.1737C4.5342 12.7008 4.48942 13.2685 4.4502 13.7658C4.44071 13.8861 4.43155 14.0023 4.42378 14.1127C4.38011 14.7341 4.37986 15.1797 4.4727 15.4906C4.51418 15.6294 4.56668 15.7118 4.62078 15.766C4.67264 15.8179 4.76124 15.8794 4.93336 15.9228C5.06269 15.9553 5.25656 15.9772 5.44585 15.989C5.53475 15.9945 5.61296 15.9973 5.66854 15.9987C5.6962 15.9994 5.71789 15.9998 5.73209 15.9999L5.74748 16L5.75054 16.0001L6.5003 16.0001L6.50019 17.5001L5.75006 17.5001L5.75024 16.7501Z"/>\n<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2514 2.00005L12.2501 2.00005L11.5 2L11.4999 3.5L12.2496 3.50005L12.2527 3.50007L12.2681 3.5002C12.2823 3.50036 12.304 3.5007 12.3316 3.50139C12.3872 3.50277 12.4654 3.50556 12.5543 3.51111C12.7436 3.52293 12.9375 3.5448 13.0668 3.57736C13.2389 3.6207 13.3275 3.68223 13.3794 3.73414C13.4335 3.78828 13.486 3.87069 13.5275 4.00956C13.6203 4.32043 13.6201 4.76596 13.5764 5.3874C13.5686 5.49785 13.5595 5.61404 13.55 5.73429C13.5108 6.23163 13.466 6.79933 13.4916 7.32643C13.5238 7.99036 13.6693 8.73405 14.1774 9.33451C14.312 9.49364 14.4544 9.63123 14.5979 9.75005C14.4544 9.86887 14.312 10.0065 14.1774 10.1656C13.7121 10.7154 13.5509 11.3854 13.5023 12.0042C13.6011 12.0012 13.7003 11.9997 13.7999 11.9997C14.208 11.9997 14.6093 12.0247 15.0018 12.0729C15.0416 11.6402 15.1479 11.3408 15.3225 11.1345C15.545 10.8715 15.8161 10.7061 16.0414 10.6058C16.1526 10.5563 16.2474 10.525 16.3106 10.5069C16.342 10.4979 16.365 10.4924 16.3776 10.4895L16.3871 10.4874L16.9999 10.3784V9.75005V9.12173L16.3871 9.01269L16.3776 9.01062C16.365 9.00776 16.342 9.00219 16.3106 8.99319C16.2474 8.97509 16.1526 8.94386 16.0414 8.89436C15.8161 8.79405 15.545 8.6286 15.3225 8.3656C15.1255 8.13279 15.0154 7.78147 14.9898 7.25369C14.9693 6.83163 15.0039 6.38849 15.0419 5.90131C15.0523 5.76849 15.0629 5.6324 15.0727 5.49256C15.1148 4.89393 15.1462 4.18776 14.9647 3.5803C14.8691 3.26001 14.708 2.94161 14.4405 2.67392C14.1708 2.40399 13.8299 2.22267 13.433 2.12276C13.1654 2.05536 12.8592 2.02722 12.6478 2.01402C12.5363 2.00707 12.4389 2.0036 12.3691 2.00185C12.334 2.00098 12.3055 2.00053 12.2851 2.0003L12.2609 2.00009L12.2537 2.00006L12.2514 2.00005Z"/>\n<path fill-rule="evenodd" clip-rule="evenodd" d="M9.00335 17.2062L9.00308 17.2065C8.7234 17.5118 8.24919 17.5327 7.94372 17.2532C7.63816 16.9735 7.61716 16.4991 7.89681 16.1935L8.45008 16.6999C7.89681 16.1935 7.89697 16.1934 7.89713 16.1932L7.89751 16.1928L7.89844 16.1918L7.90098 16.189L7.90879 16.1806L7.93517 16.1526C7.95746 16.1292 7.98914 16.0963 8.02971 16.0555C8.11079 15.9738 8.22768 15.8597 8.37644 15.724C8.6732 15.4532 9.10079 15.0927 9.62744 14.7314C10.6647 14.0198 12.1659 13.2499 13.8501 13.2499C15.5343 13.2499 17.0355 14.0198 18.0727 14.7314C18.5994 15.0927 19.027 15.4532 19.3237 15.724C19.4725 15.8597 19.5894 15.9738 19.6705 16.0555C19.711 16.0963 19.7427 16.1292 19.765 16.1526L19.7914 16.1806L19.7992 16.189L19.8017 16.1918L19.8027 16.1928L19.803 16.1932C19.8032 16.1934 19.8034 16.1935 19.2501 16.6999L19.8034 16.1935C20.083 16.4991 20.062 16.9735 19.7565 17.2532C19.4511 17.5326 18.9772 17.5118 18.6975 17.207L18.6971 17.2065L18.6968 17.2062L18.6945 17.2037L18.6783 17.1865C18.6629 17.1704 18.6386 17.1452 18.6059 17.1123C18.5404 17.0463 18.4414 16.9494 18.3127 16.8321C18.0546 16.5966 17.6814 16.282 17.2242 15.9683C16.9805 15.8012 16.7185 15.6381 16.4421 15.4883C16.7016 15.9322 16.8502 16.4487 16.8502 16.9999C16.8502 18.6567 15.5071 19.9999 13.8502 19.9999C12.1934 19.9999 10.8502 18.6567 10.8502 16.9999C10.8502 16.4486 10.9989 15.932 11.2584 15.4881C10.9819 15.6379 10.7198 15.8011 10.476 15.9683C10.0188 16.282 9.64555 16.5966 9.38746 16.8321C9.25879 16.9494 9.15975 17.0463 9.09425 17.1123C9.06153 17.1452 9.03726 17.1704 9.02192 17.1865L9.00572 17.2037L9.00335 17.2062Z"/>\n<circle cx="14.8253" cy="16.1749" r="1.125" fill="white"/>\n</svg>\n',ke='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.463 5.187a.888.888 0 1 1 1.254 1.255L9.16 10l3.557 3.557a.888.888 0 1 1-1.254 1.255L7.26 10.61a.888.888 0 0 1 .16-1.382l4.043-4.042z"/></svg>',De='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M15 2.5H5v4h10v-4zm-1 1v2H6v-2h8z"/><path d="M16 5.5a2.5 2.5 0 0 1 2.495 2.336L18.5 8v5a2.5 2.5 0 0 1-2.336 2.495L16 15.5h-1V14h1a1 1 0 0 0 .993-.883L17 13V8a1 1 0 0 0-.883-.993L16 7H4a1 1 0 0 0-.993.883L3 8v5a1 1 0 0 0 .883.993L4 14h1v1.5H4a2.5 2.5 0 0 1-2.495-2.336L1.5 13V8a2.5 2.5 0 0 1 2.336-2.495L4 5.5h12zM6.5 8a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1h2z"/><path d="M15 12H5v7h10v-7zm-1 1v5H6v-5h8z"/><path d="M7 14h6v1H7zm0 2h6v1H7z"/></svg>',Se='<svg xmlns="http://www.w3.org/2000/svg" width="53" height="10" viewBox="0 0 53 10"><path fill="#1C2331" d="M31.724 1.492a15.139 15.139 0 0 0 .045 1.16 2.434 2.434 0 0 0-.687-.34 3.68 3.68 0 0 0-1.103-.166 2.332 2.332 0 0 0-1.14.255 1.549 1.549 0 0 0-.686.87c-.15.41-.225.98-.225 1.712 0 .939.148 1.659.444 2.161.297.503.792.754 1.487.754.452.015.9-.094 1.294-.316.296-.174.557-.4.771-.669l.14.852h1.282V.007h-1.623v1.485ZM31 6.496a1.77 1.77 0 0 1-.494.061.964.964 0 0 1-.521-.127.758.758 0 0 1-.296-.466 3.984 3.984 0 0 1-.093-.992 4.208 4.208 0 0 1 .098-1.052.753.753 0 0 1 .307-.477 1.08 1.08 0 0 1 .55-.122c.233-.004.466.026.69.089l.483.144v2.553c-.11.076-.213.143-.307.2a1.73 1.73 0 0 1-.417.189ZM35.68 0l-.702.004c-.322.002-.482.168-.48.497l.004.581c.002.33.164.493.486.49l.702-.004c.322-.002.481-.167.48-.496L36.165.49c-.002-.33-.164-.493-.486-.491ZM36.145 2.313l-1.612.01.034 5.482 1.613-.01-.035-5.482ZM39.623.79 37.989.8 38 2.306l-.946.056.006 1.009.949-.006.024 2.983c.003.476.143.844.419 1.106.275.26.658.39 1.148.387.132 0 .293-.01.483-.03.19-.02.38-.046.57-.08.163-.028.324-.068.482-.119l-.183-1.095-.702.004a.664.664 0 0 1-.456-.123.553.553 0 0 1-.14-.422l-.016-2.621 1.513-.01-.006-1.064-1.514.01-.01-1.503ZM46.226 2.388c-.41-.184-.956-.274-1.636-.27-.673.004-1.215.101-1.627.29-.402.179-.72.505-.888.91-.18.419-.268.979-.264 1.68.004.688.1 1.24.285 1.655.172.404.495.724.9.894.414.18.957.268 1.63.264.68-.004 1.224-.099 1.632-.284.4-.176.714-.501.878-.905.176-.418.263-.971.258-1.658-.004-.702-.097-1.261-.28-1.677a1.696 1.696 0 0 0-.888-.9Zm-.613 3.607a.77.77 0 0 1-.337.501 1.649 1.649 0 0 1-1.317.009.776.776 0 0 1-.343-.497 4.066 4.066 0 0 1-.105-1.02 4.136 4.136 0 0 1 .092-1.03.786.786 0 0 1 .337-.507 1.59 1.59 0 0 1 1.316-.008.79.79 0 0 1 .344.502c.078.337.113.683.105 1.03.012.343-.019.685-.092 1.02ZM52.114 2.07a2.67 2.67 0 0 0-1.128.278c-.39.191-.752.437-1.072.73l-.157-.846-1.273.008.036 5.572 1.623-.01-.024-3.78c.35-.124.646-.22.887-.286.26-.075.53-.114.8-.118l.45-.003.144-1.546-.286.001ZM22.083 7.426l-1.576-2.532a2.137 2.137 0 0 0-.172-.253 1.95 1.95 0 0 0-.304-.29.138.138 0 0 1 .042-.04 1.7 1.7 0 0 0 .328-.374l1.75-2.71c.01-.015.025-.028.024-.048-.01-.01-.021-.007-.031-.007L20.49 1.17a.078.078 0 0 0-.075.045l-.868 1.384c-.23.366-.46.732-.688 1.099a.108.108 0 0 1-.112.06c-.098-.005-.196-.001-.294-.002-.018 0-.038.006-.055-.007.002-.02.002-.039.005-.058a4.6 4.6 0 0 0 .046-.701V1.203c0-.02-.009-.032-.03-.03h-.033L16.93 1.17c-.084 0-.073-.01-.073.076v6.491c-.001.018.006.028.025.027h1.494c.083 0 .072.007.072-.071v-2.19c0-.055-.003-.11-.004-.166a3.366 3.366 0 0 0-.05-.417h.06c.104 0 .209.002.313-.002a.082.082 0 0 1 .084.05c.535.913 1.07 1.824 1.607 2.736a.104.104 0 0 0 .103.062c.554-.003 1.107-.002 1.66-.002l.069-.003-.019-.032-.188-.304ZM27.112 6.555c-.005-.08-.004-.08-.082-.08h-2.414c-.053 0-.106-.003-.159-.011a.279.279 0 0 1-.246-.209.558.558 0 0 1-.022-.15c0-.382 0-.762-.002-1.143 0-.032.007-.049.042-.044h2.504c.029.003.037-.012.034-.038V3.814c0-.089.013-.078-.076-.078h-2.44c-.07 0-.062.003-.062-.06v-.837c0-.047.004-.093.013-.14a.283.283 0 0 1 .241-.246.717.717 0 0 1 .146-.011h2.484c.024.002.035-.009.036-.033l.003-.038.03-.496c.01-.183.024-.365.034-.548.005-.085.003-.087-.082-.094-.218-.018-.437-.038-.655-.05a17.845 17.845 0 0 0-.657-.026 72.994 72.994 0 0 0-1.756-.016 1.7 1.7 0 0 0-.471.064 1.286 1.286 0 0 0-.817.655c-.099.196-.149.413-.145.633v3.875c0 .072.003.144.011.216a1.27 1.27 0 0 0 .711 1.029c.228.113.48.167.734.158.757-.005 1.515.002 2.272-.042.274-.016.548-.034.82-.053.03-.002.043-.008.04-.041-.008-.104-.012-.208-.019-.312a69.964 69.964 0 0 1-.05-.768ZM16.14 7.415l-.127-1.075c-.004-.03-.014-.04-.044-.037a13.125 13.125 0 0 1-.998.073c-.336.01-.672.02-1.008.016-.116-.001-.233-.014-.347-.039a.746.746 0 0 1-.45-.262c-.075-.1-.132-.211-.167-.33a3.324 3.324 0 0 1-.126-.773 9.113 9.113 0 0 1-.015-.749c0-.285.022-.57.065-.852.023-.158.066-.312.127-.46a.728.728 0 0 1 .518-.443 1.64 1.64 0 0 1 .397-.048c.628-.001 1.255.003 1.882.05.022.001.033-.006.036-.026l.003-.031.06-.55c.019-.177.036-.355.057-.532.004-.034-.005-.046-.04-.056a5.595 5.595 0 0 0-1.213-.21 10.783 10.783 0 0 0-.708-.02c-.24-.003-.48.01-.719.041a3.477 3.477 0 0 0-.625.14 1.912 1.912 0 0 0-.807.497c-.185.2-.33.433-.424.688a4.311 4.311 0 0 0-.24 1.096c-.031.286-.045.572-.042.86-.006.43.024.86.091 1.286.04.25.104.497.193.734.098.279.26.53.473.734.214.205.473.358.756.446.344.11.702.17 1.063.177a8.505 8.505 0 0 0 1.578-.083 6.11 6.11 0 0 0 .766-.18c.03-.008.047-.023.037-.057a.157.157 0 0 1-.003-.025Z"/><path fill="#AFE229" d="M6.016 6.69a1.592 1.592 0 0 0-.614.21c-.23.132-.422.32-.56.546-.044.072-.287.539-.287.539l-.836 1.528.009.006c.038.025.08.046.123.063.127.046.26.07.395.073.505.023 1.011-.007 1.517-.003.29.009.58.002.869-.022a.886.886 0 0 0 .395-.116.962.962 0 0 0 .312-.286c.056-.083.114-.163.164-.249.24-.408.48-.816.718-1.226.075-.128.148-.257.222-.386l.112-.192a1.07 1.07 0 0 0 .153-.518l-1.304.023s-1.258-.005-1.388.01Z"/><path fill="#771BFF" d="m2.848 9.044.76-1.39.184-.352c-.124-.067-.245-.14-.367-.21-.346-.204-.706-.384-1.045-.6a.984.984 0 0 1-.244-.207c-.108-.134-.136-.294-.144-.46-.021-.409-.002-.818-.009-1.227-.003-.195 0-.39.003-.585.004-.322.153-.553.427-.713l.833-.488c.22-.13.44-.257.662-.385.05-.029.105-.052.158-.077.272-.128.519-.047.76.085l.044.028c.123.06.242.125.358.196.318.178.635.357.952.537.095.056.187.117.275.184.194.144.254.35.266.578.016.284.007.569.006.853-.001.28.004.558 0 .838.592-.003 1.259 0 1.259 0l.723-.013c-.003-.292-.007-.584-.007-.876 0-.524.015-1.048-.016-1.571-.024-.42-.135-.8-.492-1.067a5.02 5.02 0 0 0-.506-.339A400.52 400.52 0 0 0 5.94.787C5.722.664 5.513.524 5.282.423 5.255.406 5.228.388 5.2.373 4.758.126 4.305-.026 3.807.21c-.097.046-.197.087-.29.14A699.896 699.896 0 0 0 .783 1.948c-.501.294-.773.717-.778 1.31-.004.36-.009.718-.001 1.077.016.754-.017 1.508.024 2.261.016.304.07.6.269.848.127.15.279.28.448.382.622.4 1.283.734 1.92 1.11l.183.109Z"/></svg>\n',_e='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 10.423a6.5 6.5 0 0 1 6.056-6.408l.038.67C6.448 5.423 5.354 7.663 5.22 10H9c.552 0 .5.432.5.986v4.511c0 .554-.448.503-1 .503h-5c-.552 0-.5-.449-.5-1.003v-4.574zm8 0a6.5 6.5 0 0 1 6.056-6.408l.038.67c-2.646.739-3.74 2.979-3.873 5.315H17c.552 0 .5.432.5.986v4.511c0 .554-.448.503-1 .503h-5c-.552 0-.5-.449-.5-1.003v-4.574z"/></svg>',Te='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m14.958 9.367-2.189 1.837a.75.75 0 0 0 .965 1.149l3.788-3.18a.747.747 0 0 0 .21-.284.75.75 0 0 0-.17-.945L13.77 4.762a.75.75 0 1 0-.964 1.15l2.331 1.955H6.22A.75.75 0 0 0 6 7.9a4 4 0 1 0 1.477 7.718l-.344-1.489A2.5 2.5 0 1 1 6.039 9.4l-.008-.032h8.927z"/></svg>',Oe='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M5.2 7h9.2c.6 0 1 .4 1 1v9.9c0 .5-.4 1-1 1H5.2a1 1 0 0 1-1-1V8c0-.6.4-1 1-1zm1 1.5c-.3 0-.5.2-.5.5v8c0 .3.2.5.5.5h.5c.2 0 .5-.2.5-.5V9c0-.3-.3-.5-.5-.5h-.5zm3.2 0c-.2 0-.5.2-.5.5v8c0 .3.3.5.5.5h.5c.3 0 .5-.2.5-.5V9c0-.3-.2-.5-.5-.5h-.4zm3.5 0c-.2 0-.5.2-.5.5v8c0 .3.3.5.5.5h.5c.3 0 .5-.2.5-.5V9c0-.3-.2-.5-.5-.5h-.5zm-1.4-7.1H8.3L6.5 3.6H3.8c-.5 0-.7.3-.7.8s.2.7.7.7h12c.6 0 .9-.2.9-.7 0-.5-.3-.8-1-.8h-2.4l-1.8-2.2z"/></svg>',Pe='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.69 14.915c.053.052.173.083.36.093a.366.366 0 0 1 .345.485l-.003.01a.738.738 0 0 1-.697.497h-2.67a.374.374 0 0 1-.353-.496l.013-.038a.681.681 0 0 1 .644-.458c.197-.012.325-.043.386-.093a.28.28 0 0 0 .072-.11L9.592 4.5H6.269c-.359-.017-.609.013-.75.09-.142.078-.289.265-.442.563-.192.29-.516.464-.864.464H4.17a.43.43 0 0 1-.407-.569L4.46 3h13.08l-.62 2.043a.81.81 0 0 1-.775.574h-.114a.486.486 0 0 1-.486-.486c.001-.284-.054-.464-.167-.54-.112-.076-.367-.106-.766-.091h-3.28l-2.68 10.257c-.006.074.007.127.038.158zM3 17h8a.5.5 0 1 1 0 1H3a.5.5 0 1 1 0-1zm11.299 1.17a.75.75 0 1 1-1.06-1.06l1.414-1.415-1.415-1.414a.75.75 0 0 1 1.06-1.06l1.415 1.414 1.414-1.415a.75.75 0 1 1 1.06 1.06l-1.413 1.415 1.414 1.415a.75.75 0 0 1-1.06 1.06l-1.415-1.414-1.414 1.414z"/></svg>',je='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M5.2 7h9.2c.6 0 1 .4 1 1v9.9c0 .5-.4 1-1 1H5.2a1 1 0 0 1-1-1V8c0-.6.4-1 1-1Zm1 1.5c-.3 0-.5.2-.5.5v8c0 .3.2.5.5.5h.5c.2 0 .5-.2.5-.5V9c0-.3-.3-.5-.5-.5h-.5Zm3.2 0c-.2 0-.5.2-.5.5v8c0 .3.3.5.5.5h.5c.3 0 .5-.2.5-.5V9c0-.3-.2-.5-.5-.5h-.5Zm3.5 0c-.2 0-.5.2-.5.5v8c0 .3.3.5.5.5h.5c.3 0 .5-.2.5-.5V9c0-.3-.2-.5-.5-.5h-.5Zm-1.4-7.1H8.3L6.5 3.6H3.8c-.5 0-.7.3-.7.8s.2.7.7.7h12c.6 0 .9-.2.9-.7 0-.5-.3-.8-1-.8h-2.4l-1.8-2.2Z"/></svg>',Re='<svg viewBox="0 0 10 8" xmlns="http://www.w3.org/2000/svg"><path d="M9.055.263v3.972h-6.77M1 4.216l2-2.038m-2 2 2 2.038"/></svg>',Fe='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11 1a9 9 0 1 1-8.027 13.075l1.128-1.129A7.502 7.502 0 0 0 18.5 10a7.5 7.5 0 1 0-14.962.759l-.745-.746-.76.76A9 9 0 0 1 11 1z"/><path d="M.475 8.17a.75.75 0 0 1 .978.047l.075.082 1.284 1.643 1.681-1.284a.75.75 0 0 1 .978.057l.073.083a.75.75 0 0 1-.057.978l-.083.073-2.27 1.737a.75.75 0 0 1-.973-.052l-.074-.082-1.741-2.23a.75.75 0 0 1 .13-1.052z"/><path d="M11.5 5v4.999l3.196 3.196-1.06 1.06L10.1 10.72l-.1-.113V5z"/></svg>',Ne='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M9.61 2.66a1.406 1.406 0 1 0-1.407 0v.891H3.28a2.11 2.11 0 0 0-2.11 2.11v10.312a2.11 2.11 0 0 0 2.11 2.109h5.684l-.054-1.157.18-.25H3.28a.703.703 0 0 1-.703-.702V5.66c0-.389.315-.704.703-.704h11.25c.388 0 .703.315.703.704v2.484l.358-.497a2.492 2.492 0 0 1 1.048-.84V5.66a2.11 2.11 0 0 0-2.11-2.11H9.61v-.89Z"/><path d="M5.625 10.817c.518 0 .937-.63.937-1.407 0-.776-.42-1.406-.937-1.406-.518 0-.938.63-.938 1.406 0 .777.42 1.407.938 1.407Z"/><path d="M13.125 9.41c0 .777-.42 1.407-.938 1.407s-.937-.63-.937-1.407c0-.776.42-1.406.937-1.406.518 0 .938.63.938 1.406Z"/><path d="M.937 8.004A.937.937 0 0 0 0 8.942v1.875c0 .517.42.937.937.937v-3.75Z"/><path d="M6.128 12.51a.782.782 0 0 1 1.085.216c.272.408.907.707 1.693.707s1.421-.3 1.693-.707a.782.782 0 0 1 1.302.868c-.666 1-1.906 1.403-2.995 1.403-1.089 0-2.329-.404-2.995-1.403a.782.782 0 0 1 .217-1.085Z"/><path d="m16.987 8.91-.622.864 2.879 2.074.622-.864a.71.71 0 0 0-.161-.99l-1.728-1.245a.71.71 0 0 0-.99.161Z"/><path d="M11.635 19.951a.355.355 0 0 1-.449-.31l-.214-2.38 4.978-6.911 2.88 2.074-4.978 6.91-2.217.617Z"/></svg>',Ue='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M.75 15.5a.75.75 0 0 1 .75.75V18l.008.09A.5.5 0 0 0 2 18.5h1.75a.75.75 0 1 1 0 1.5H1.5l-.144-.007a1.5 1.5 0 0 1-1.35-1.349L0 18.5v-2.25a.75.75 0 0 1 .75-.75zm18.5 0a.75.75 0 0 1 .75.75v2.25l-.007.144a1.5 1.5 0 0 1-1.349 1.35L18.5 20h-2.25a.75.75 0 1 1 0-1.5H18a.5.5 0 0 0 .492-.41L18.5 18v-1.75a.75.75 0 0 1 .75-.75zm-10.45 3c.11 0 .2.09.2.2v1.1a.2.2 0 0 1-.2.2H7.2a.2.2 0 0 1-.2-.2v-1.1c0-.11.09-.2.2-.2h1.6zm4 0c.11 0 .2.09.2.2v1.1a.2.2 0 0 1-.2.2h-1.6a.2.2 0 0 1-.2-.2v-1.1c0-.11.09-.2.2-.2h1.6zm.45-5.5a.75.75 0 1 1 0 1.5h-8.5a.75.75 0 1 1 0-1.5h8.5zM1.3 11c.11 0 .2.09.2.2v1.6a.2.2 0 0 1-.2.2H.2a.2.2 0 0 1-.2-.2v-1.6c0-.11.09-.2.2-.2h1.1zm18.5 0c.11 0 .2.09.2.2v1.6a.2.2 0 0 1-.2.2h-1.1a.2.2 0 0 1-.2-.2v-1.6c0-.11.09-.2.2-.2h1.1zm-4.55-2a.75.75 0 1 1 0 1.5H4.75a.75.75 0 1 1 0-1.5h10.5zM1.3 7c.11 0 .2.09.2.2v1.6a.2.2 0 0 1-.2.2H.2a.2.2 0 0 1-.2-.2V7.2c0-.11.09-.2.2-.2h1.1zm18.5 0c.11 0 .2.09.2.2v1.6a.2.2 0 0 1-.2.2h-1.1a.2.2 0 0 1-.2-.2V7.2c0-.11.09-.2.2-.2h1.1zm-4.55-2a.75.75 0 1 1 0 1.5h-2.5a.75.75 0 1 1 0-1.5h2.5zm-5 0a.75.75 0 1 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5h5.5zm-6.5-5a.75.75 0 0 1 0 1.5H2a.5.5 0 0 0-.492.41L1.5 2v1.75a.75.75 0 0 1-1.5 0V1.5l.007-.144A1.5 1.5 0 0 1 1.356.006L1.5 0h2.25zM18.5 0l.144.007a1.5 1.5 0 0 1 1.35 1.349L20 1.5v2.25a.75.75 0 1 1-1.5 0V2l-.008-.09A.5.5 0 0 0 18 1.5h-1.75a.75.75 0 1 1 0-1.5h2.25zM8.8 0c.11 0 .2.09.2.2v1.1a.2.2 0 0 1-.2.2H7.2a.2.2 0 0 1-.2-.2V.2c0-.11.09-.2.2-.2h1.6zm4 0c.11 0 .2.09.2.2v1.1a.2.2 0 0 1-.2.2h-1.6a.2.2 0 0 1-.2-.2V.2c0-.11.09-.2.2-.2h1.6z"/></svg>',We='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3.734 6.375H1.75a.75.75 0 0 1 0-1.5h1.984a2.626 2.626 0 0 1 5.032 0h9.48a.75.75 0 0 1 0 1.5h-9.48a2.626 2.626 0 0 1-5.032 0Zm1.141-.75a1.375 1.375 0 1 1 2.75 0 1.375 1.375 0 0 1-2.75 0ZM16.263 14.625h1.983a.75.75 0 0 1 0 1.5h-1.983a2.626 2.626 0 0 1-5.033 0H1.75a.75.75 0 0 1 0-1.5h9.48a2.626 2.626 0 0 1 5.033 0Zm-1.142.75a1.375 1.375 0 1 1-2.75 0 1.375 1.375 0 0 1 2.75 0Z"/></svg>\n',$e='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m6.395 9.196 2.545-.007V6.498a.598.598 0 0 1 .598-.598h.299a.598.598 0 0 1 .598.598v6.877a.598.598 0 0 1-.598.598h-.299a.598.598 0 0 1-.598-.598v-2.691l-2.545.007v2.691a.598.598 0 0 1-.598.598h-.299a.598.598 0 0 1-.598-.598V6.505a.598.598 0 0 1 .598-.598h.299a.598.598 0 0 1 .598.598v2.691Z"/><path d="M15.094 13.417V6.462a.562.562 0 0 0-.562-.562h-.782a1 1 0 0 0-.39.08l-1.017.43a.562.562 0 0 0-.343.517v.197c0 .4.406.67.775.519l.819-.337v6.111c0 .31.251.562.561.562h.377c.31 0 .562-.251.562-.562Z"/><path d="M0 15.417v1.5h1.5v-1.5H0Z"/><path d="M18.5 15.417v1.5H20v-1.5h-1.5Z"/><path d="M18.5 12.333v1.5H20v-1.5h-1.5Z"/><path d="M18.5 9.25v1.5H20v-1.5h-1.5Z"/><path d="M18.5 6.167v1.5H20v-1.5h-1.5Z"/><path d="M0 18.5v.5a1 1 0 0 0 1 1h.5v-1.5H0Z"/><path d="M3.083 18.5V20h1.5v-1.5h-1.5Z"/><path d="M6.167 18.5V20h1.5v-1.5h-1.5Z"/><path d="M9.25 18.5V20h1.5v-1.5h-1.5Z"/><path d="M12.333 18.5V20h1.5v-1.5h-1.5Z"/><path d="M15.417 18.5V20h1.5v-1.5h-1.5Z"/><path d="M18.5 18.5V20h.5a1 1 0 0 0 1-1v-.5h-1.5Z"/><path clip-rule="evenodd" d="M0 1a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v3.583h-1.5V1.5h-17v12.333H0V1Z"/></svg>',qe='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m12.5 0 5 4.5v15.003h-16V0h11zM3 1.5v3.25l-1.497 1-.003 8 1.5 1v3.254L7.685 18l-.001 1.504H17.5V8.002L16 9.428l-.004-4.22-4.222-3.692L3 1.5z"/><path d="M4.06 6.64a.75.75 0 0 1 .958 1.15l-.085.07L2.29 9.75l2.646 1.89c.302.216.4.62.232.951l-.058.095a.75.75 0 0 1-.951.232l-.095-.058-3.5-2.5V9.14l3.496-2.5zm4.194 6.22a.75.75 0 0 1-.958-1.149l.085-.07 2.643-1.89-2.646-1.89a.75.75 0 0 1-.232-.952l.058-.095a.75.75 0 0 1 .95-.232l.096.058 3.5 2.5v1.22l-3.496 2.5zm7.644-.836 2.122 2.122-5.825 5.809-2.125-.005.003-2.116zm2.539-1.847 1.414 1.414a.5.5 0 0 1 0 .707l-1.06 1.06-2.122-2.12 1.061-1.061a.5.5 0 0 1 .707 0z"/></svg>',Ke='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2.5a7.47 7.47 0 0 1 4.231 1.31 7.268 7.268 0 0 1 2.703 3.454 7.128 7.128 0 0 1 .199 4.353c-.39 1.436-1.475 2.72-2.633 3.677h2.013c0-.226.092-.443.254-.603a.876.876 0 0 1 1.229 0c.163.16.254.377.254.603v.853c0 .209-.078.41-.22.567a.873.873 0 0 1-.547.28l-.101.006h-4.695a.517.517 0 0 1-.516-.518v-1.265c0-.21.128-.398.317-.489a5.601 5.601 0 0 0 2.492-2.371 5.459 5.459 0 0 0 .552-3.693 5.53 5.53 0 0 0-1.955-3.2A5.71 5.71 0 0 0 10 4.206 5.708 5.708 0 0 0 6.419 5.46 5.527 5.527 0 0 0 4.46 8.663a5.457 5.457 0 0 0 .554 3.695 5.6 5.6 0 0 0 2.497 2.37.55.55 0 0 1 .317.49v1.264c0 .286-.23.518-.516.518H2.618a.877.877 0 0 1-.614-.25.845.845 0 0 1-.254-.603v-.853c0-.226.091-.443.254-.603a.876.876 0 0 1 1.228 0c.163.16.255.377.255.603h1.925c-1.158-.958-2.155-2.241-2.545-3.678a7.128 7.128 0 0 1 .199-4.352 7.268 7.268 0 0 1 2.703-3.455A7.475 7.475 0 0 1 10 2.5z"/></svg>',Ge='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M7 16.4c-.8-.4-1.5-.9-2.2-1.5a.6.6 0 0 1-.2-.5l.3-.6h1c1 1.2 2.1 1.7 3.7 1.7 1 0 1.8-.3 2.3-.6.6-.4.6-1.2.6-1.3.2-1.2-.9-2.1-.9-2.1h2.1c.3.7.4 1.2.4 1.7v.8l-.6 1.2c-.6.8-1.1 1-1.6 1.2a6 6 0 0 1-2.4.6c-1 0-1.8-.3-2.5-.6zM6.8 9 6 8.3c-.4-.5-.5-.8-.5-1.6 0-.7.1-1.3.5-1.8.4-.6 1-1 1.6-1.3a6.3 6.3 0 0 1 4.7 0 4 4 0 0 1 1.7 1l.3.7c0 .1.2.4-.2.7-.4.2-.9.1-1 0a3 3 0 0 0-1.2-1c-.4-.2-1-.3-2-.4-.7 0-1.4.2-2 .6-.8.6-1 .8-1 1.5 0 .8.5 1 1.2 1.5.6.4 1.1.7 1.9 1H6.8z"/><path d="M3 10.5V9h14v1.5z"/></svg>',Je='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M15.875 4.419a.75.75 0 0 0-1.5 0v7.25H6.818l2.33-1.955a.75.75 0 0 0-.963-1.15l-3.792 3.182a.75.75 0 0 0-.17.945c.046.11.118.208.21.284l3.788 3.18a.75.75 0 1 0 .965-1.149l-2.19-1.837h7.629c.69 0 1.25-.56 1.25-1.25v-7.5Z"/></svg>',Qe='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m7.03 10.349 3.818-3.819a.8.8 0 1 1 1.132 1.132L8.16 11.48l3.819 3.818a.8.8 0 1 1-1.132 1.132L7.03 12.61l-3.818 3.82a.8.8 0 1 1-1.132-1.132L5.9 11.48 2.08 7.662A.8.8 0 1 1 3.212 6.53l3.818 3.82zm8.147 7.829h2.549c.254 0 .447.05.58.152a.49.49 0 0 1 .201.413.54.54 0 0 1-.159.393c-.105.108-.266.162-.48.162h-3.594c-.245 0-.435-.066-.572-.197a.621.621 0 0 1-.205-.463c0-.114.044-.265.132-.453a1.62 1.62 0 0 1 .288-.444c.433-.436.824-.81 1.172-1.122.348-.312.597-.517.747-.615.267-.183.49-.368.667-.553.177-.185.312-.375.405-.57.093-.194.139-.384.139-.57a1.008 1.008 0 0 0-.554-.917 1.197 1.197 0 0 0-.56-.133c-.426 0-.761.182-1.005.546a2.332 2.332 0 0 0-.164.39 1.609 1.609 0 0 1-.258.488c-.096.114-.237.17-.423.17a.558.558 0 0 1-.405-.156.568.568 0 0 1-.161-.427c0-.218.05-.446.151-.683.101-.238.252-.453.452-.646s.454-.349.762-.467a2.998 2.998 0 0 1 1.081-.178c.498 0 .923.076 1.274.228a1.916 1.916 0 0 1 1.004 1.032 1.984 1.984 0 0 1-.156 1.794c-.2.32-.405.572-.613.754-.208.182-.558.468-1.048.857-.49.39-.826.691-1.008.906a2.703 2.703 0 0 0-.24.309z"/></svg>',Xe='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M15.677 8.678h2.549c.254 0 .447.05.58.152a.49.49 0 0 1 .201.413.54.54 0 0 1-.159.393c-.105.108-.266.162-.48.162h-3.594c-.245 0-.435-.066-.572-.197a.621.621 0 0 1-.205-.463c0-.114.044-.265.132-.453a1.62 1.62 0 0 1 .288-.444c.433-.436.824-.81 1.172-1.122.348-.312.597-.517.747-.615.267-.183.49-.368.667-.553.177-.185.312-.375.405-.57.093-.194.139-.384.139-.57a1.008 1.008 0 0 0-.554-.917 1.197 1.197 0 0 0-.56-.133c-.426 0-.761.182-1.005.546a2.332 2.332 0 0 0-.164.39 1.609 1.609 0 0 1-.258.488c-.096.114-.237.17-.423.17a.558.558 0 0 1-.405-.156.568.568 0 0 1-.161-.427c0-.218.05-.446.151-.683.101-.238.252-.453.452-.646s.454-.349.762-.467a2.998 2.998 0 0 1 1.081-.178c.498 0 .923.076 1.274.228a1.916 1.916 0 0 1 1.004 1.032 1.984 1.984 0 0 1-.156 1.794c-.2.32-.405.572-.613.754-.208.182-.558.468-1.048.857-.49.39-.826.691-1.008.906a2.703 2.703 0 0 0-.24.309zM7.03 10.349l3.818-3.819a.8.8 0 1 1 1.132 1.132L8.16 11.48l3.819 3.818a.8.8 0 1 1-1.132 1.132L7.03 12.61l-3.818 3.82a.8.8 0 1 1-1.132-1.132L5.9 11.48 2.08 7.662A.8.8 0 1 1 3.212 6.53l3.818 3.82z"/></svg>',Ye='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.105 18-.17 1H2.5A1.5 1.5 0 0 1 1 17.5v-15A1.5 1.5 0 0 1 2.5 1h15A1.5 1.5 0 0 1 19 2.5v9.975l-.85-.124-.15-.302V8h-5v4h.021l-.172.351-1.916.28-.151.027c-.287.063-.54.182-.755.341L8 13v5h3.105zM2 12h5V8H2v4zm10-4H8v4h4V8zM2 2v5h5V2H2zm0 16h5v-5H2v5zM13 7h5V2h-5v5zM8 2v5h4V2H8z" opacity=".6"/><path d="m15.5 11.5 1.323 2.68 2.957.43-2.14 2.085.505 2.946L15.5 18.25l-2.645 1.39.505-2.945-2.14-2.086 2.957-.43L15.5 11.5zM13 6a1 1 0 0 1 1 1v3.172a2.047 2.047 0 0 0-.293.443l-.858 1.736-1.916.28-.151.027A1.976 1.976 0 0 0 9.315 14H7a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6zm-1 2H8v4h4V8z"/></svg>',ta='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2.5 1h15A1.5 1.5 0 0 1 19 2.5v15a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 1 17.5v-15A1.5 1.5 0 0 1 2.5 1zM2 2v16h16V2H2z" opacity=".6"/><path d="M18 7v1H2V7h16zm0 5v1H2v-1h16z" opacity=".6"/><path d="M14 1v18a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1zm-2 1H8v4h4V2zm0 6H8v4h4V8zm0 6H8v4h4v-4z"/></svg>',ea='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2.5 1h15A1.5 1.5 0 0 1 19 2.5v15a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 1 17.5v-15A1.5 1.5 0 0 1 2.5 1zM2 2v16h16V2H2z" opacity=".6"/><path d="M7 2h1v16H7V2zm5 0h1v7h-1V2zm6 5v1H2V7h16zM8 12v1H2v-1h6z" opacity=".6"/><path d="M7 7h12a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zm1 2v9h10V9H8z"/></svg>',aa='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 19a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v8.022a6.47 6.47 0 0 0-1.5-.709V2a.5.5 0 0 0-.5-.5H3a.5.5 0 0 0-.5.5v15a.5.5 0 0 0 .5.5h6.313c.173.534.412 1.037.709 1.5H3Z"/><path d="M9.174 14a6.489 6.489 0 0 0-.155 1H6v-1h3.174Z"/><path d="M10.022 12a6.51 6.51 0 0 0-.524 1H4v-1h6.022Z"/><path d="M12.034 10c-.448.283-.86.62-1.224 1H6v-1h6.034Z"/><path d="M12 4v1H4V4h8Z"/><path d="M14 7V6H6v1h8Z"/><path d="M15 9V8H7v1h8Z"/><path clip-rule="evenodd" d="M20 15.5a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM15.5 13a.5.5 0 0 0-.5.5V15h-1.5a.5.5 0 0 0 0 1H15v1.5a.5.5 0 0 0 1 0V16h1.5a.5.5 0 0 0 0-1H16v-1.5a.5.5 0 0 0-.5-.5Z"/></svg>',ia='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8 2v5h4V2h1v5h5v1h-5v4h.021l-.172.351-1.916.28-.151.027c-.287.063-.54.182-.755.341L8 13v5H7v-5H2v-1h5V8H2V7h5V2h1zm4 6H8v4h4V8z" opacity=".6"/><path d="m15.5 11.5 1.323 2.68 2.957.43-2.14 2.085.505 2.946L15.5 18.25l-2.645 1.39.505-2.945-2.14-2.086 2.957-.43L15.5 11.5zM17 1a2 2 0 0 1 2 2v9.475l-.85-.124-.857-1.736a2.048 2.048 0 0 0-.292-.44L17 3H3v14h7.808l.402.392L10.935 19H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h14z"/></svg>',la='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2.5 1h15A1.5 1.5 0 0 1 19 2.5v15a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 1 17.5v-15A1.5 1.5 0 0 1 2.5 1zM2 2v16h16V2H2z" opacity=".6"/><path d="M7 2h1v16H7V2zm5 0h1v16h-1V2z" opacity=".6"/><path d="M1 6h18a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm1 2v4h4V8H2zm6 0v4h4V8H8zm6 0v4h4V8h-4z"/></svg>',na='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 5.5v3h4v-3H3Zm0 4v3h4v-3H3Zm0 4v3h4v-3H3Zm5 3h4v-3H8v3Zm5 0h4v-3h-4v3Zm4-4v-3h-4v3h4Zm0-4v-3h-4v3h4Zm1.5 8A1.5 1.5 0 0 1 17 18H3a1.5 1.5 0 0 1-1.5-1.5V3c.222-.863 1.068-1.5 2-1.5h13c.932 0 1.778.637 2 1.5v13.5Zm-6.5-4v-3H8v3h4Zm0-4v-3H8v3h4Z"/></svg>',oa='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M1.5 4.121C1.5 2.95 2.426 2 3.568 2h12.864c1.142 0 2.068.95 2.068 2.121V16.38c0 1.171-.926 2.121-2.068 2.121H3.568c-1.142 0-2.068-.95-2.068-2.121V4.12Zm2.068-.707a.699.699 0 0 0-.69.707V6.38h14.244V4.12a.698.698 0 0 0-.69-.707H3.568Zm13.554 4.38h-4.968v3.939h4.968V7.794Zm0 5.353h-4.968v3.939h4.278c.381 0 .69-.317.69-.707v-3.232Zm-6.347 3.939V7.794H2.878v8.585c0 .39.309.707.69.707h7.207Z"/></svg>',sa='<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45">\n <path fill="#F0F0F0" d="M2 0h41s2 0 2 2v41s0 2 -2 2h-41s-2 0 -2 -2v-41s0 -2 2 -2" />\n <path fill="#D5D5D5" d="M11 10h10s1 0 1 1v24s0 1 -1 1h-10s-1 0 -1 -1v-24s0 -1 1 -1" />\n <path fill="#D5D5D5" d="M25 10h10s1 0 1 1v10s0 1 -1 1h-10s-1 0 -1 -1v-10s0 -1 1 -1" />\n <path fill="#D5D5D5" d="M25 24h10s1 0 1 1v10s0 1 -1 1h-10s-1 0 -1 -1v-10s0 -1 1 -1" />\n</svg>\n',ha='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="M8 0H3a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2ZM2.5 2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5v15a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V2Z"/><path clip-rule="evenodd" d="M13 0h5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2Zm0 1.5a.5.5 0 0 0-.5.5v5a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 .5-.5V2a.5.5 0 0 0-.5-.5h-5Z"/><path clip-rule="evenodd" d="M13 10h5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2Zm0 1.5a.5.5 0 0 0-.5.5v5a.5.5 0 0 0 .5.5h5a.5.5 0 0 0 .5-.5v-5a.5.5 0 0 0-.5-.5h-5Z"/></svg>',ra='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3.035 1C2.446 1 2 1.54 2 2.098V10.5h1.5v-8h13v8H18V2.098C18 1.539 17.48 1 16.9 1H3.035Zm10.453 2.61a1.885 1.885 0 0 0-1.442.736 1.89 1.89 0 0 0 1.011 2.976 1.903 1.903 0 0 0 2.253-1.114 1.887 1.887 0 0 0-1.822-2.598ZM7.463 8.163a.611.611 0 0 0-.432.154L5.071 10.5h5.119L7.88 8.348a.628.628 0 0 0-.417-.185Zm6.236 1.059a.62.62 0 0 0-.42.164L12.07 10.5h2.969l-.92-1.113a.618.618 0 0 0-.42-.165ZM.91 11.5a.91.91 0 0 0-.91.912v6.877c0 .505.405.91.91.91h18.178a.91.91 0 0 0 .912-.91v-6.877a.908.908 0 0 0-.912-.912H.91ZM3.668 13h1.947l2.135 5.7H5.898l-.28-.946H3.601l-.278.945H1.516L3.668 13Zm4.947 0h1.801v4.3h2.7v1.4h-4.5V13h-.001Zm4.5 0h5.4v1.4h-1.798v4.3h-1.701v-4.3h-1.9V13h-.001Zm-8.517 1.457-.614 2.059h1.262l-.648-2.059Z"/></svg>',va='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M9.816 11.5 7.038 4.785 4.261 11.5h5.555Zm.62 1.5H3.641l-1.666 4.028H.312l5.789-14h1.875l5.789 14h-1.663L10.436 13Z"/><path d="m12.09 17-.534-1.292.848-1.971.545 1.319L12.113 17h-.023Zm1.142-5.187.545 1.319L15.5 9.13l1.858 4.316h-3.45l.398.965h3.467L18.887 17H20l-3.873-9h-1.254l-1.641 3.813Z"/></svg>',ca='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><circle cx="9.5" cy="4.5" r="1.5"/><circle cx="9.5" cy="10.5" r="1.5"/><circle cx="9.5" cy="16.5" r="1.5"/></svg>',da='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m2.315 14.705 2.224-2.24a.689.689 0 0 1 .963 0 .664.664 0 0 1 0 .949L2.865 16.07a.682.682 0 0 1-.112.089.647.647 0 0 1-.852-.051L.688 14.886a.635.635 0 0 1 0-.903.647.647 0 0 1 .91 0l.717.722zm5.185.045a.75.75 0 0 1 .75-.75h9.5a.75.75 0 1 1 0 1.5h-9.5a.75.75 0 0 1-.75-.75zM2.329 5.745l2.21-2.226a.689.689 0 0 1 .963 0 .664.664 0 0 1 0 .95L2.865 7.125a.685.685 0 0 1-.496.196.644.644 0 0 1-.468-.187L.688 5.912a.635.635 0 0 1 0-.903.647.647 0 0 1 .91 0l.73.736zM7.5 5.75A.75.75 0 0 1 8.25 5h9.5a.75.75 0 1 1 0 1.5h-9.5a.75.75 0 0 1-.75-.75z"/></svg>',ma='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 2.28C2 1.574 2.574 1 3.272 1l11.456.001c.703 0 1.272.573 1.272 1.28v8.453l-1.5 1.464V2.465c0-.003-11-.005-11-.005V16.5h2.356c.124.225.28.434.462.62l.868.88-3.914-.001A1.274 1.274 0 0 1 2 16.719V2.28Z"/><path d="M14.525 18H9.293l-1.48-1.5h3.75l.332.336.344-.336H14.5v-2.207L16 12.83v3.73L14.525 18Z"/><path d="M9.706 12.638a2.838 2.838 0 0 0-1.38-.36 2.817 2.817 0 0 0-1.602.5H6.18a.635.635 0 0 1-.633-.639.64.64 0 0 1 .633-.639h3.133a.639.639 0 0 1 .393 1.138Z"/><path d="M5.546 6.154c0-.353.292-.64.636-.64h5.682a.637.637 0 0 1 .449 1.09.641.641 0 0 1-.449.188H6.182a.636.636 0 0 1-.635-.639l-.001.001Z"/><path d="M5.546 9.138c0-.352.292-.638.636-.638h5.682a.635.635 0 0 1 .45 1.088.641.641 0 0 1-.45.189H6.182a.636.636 0 0 1-.636-.639Z"/><path d="m13.117 19.374 6.192-6.044a1.316 1.316 0 0 0 0-1.876 1.354 1.354 0 0 0-1.899 0l-5.515 5.382-2.63-2.666a1.312 1.312 0 0 0-.938-.393 1.315 1.315 0 0 0-.939.394 1.354 1.354 0 0 0 0 1.898l3.49 3.538a1.317 1.317 0 0 0 1.447.29 2.74 2.74 0 0 0 .792-.523Z" style="fill:#1FB11F"/></svg>',ga='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M2 2.28C2 1.574 2.574 1 3.272 1l11.456.001c.703 0 1.272.573 1.272 1.28v8.106l-.889.899-.611-.619V2.465c0-.003-11-.005-11-.005V16.5h6.458l-.136.138-.003.003c-.372.378-.628.85-.745 1.359l-5.802-.001A1.274 1.274 0 0 1 2 16.719V2.28Z"/><path d="M14.338 18h-3.676c.06-.112.136-.216.227-.308l1.178-1.192H14.5v-3.699l.61.618.89-.899v4.199c0 .383-.168.726-.431.96l-.457-.462-.774.783Z"/><path d="M9.043 11.5a2.853 2.853 0 0 0 .066 1.278H6.18a.635.635 0 0 1-.632-.639.64.64 0 0 1 .633-.639h2.863Z"/><path d="M12.493 9.233a2.816 2.816 0 0 0-2.434.544H6.182a.636.636 0 0 1-.636-.639c0-.352.292-.638.636-.638h5.682a.635.635 0 0 1 .629.733Z"/><path d="M5.546 6.154c0-.353.292-.64.636-.64h5.682a.637.637 0 0 1 .449 1.09.641.641 0 0 1-.449.188H6.182a.636.636 0 0 1-.635-.639l-.001.001Z"/><path d="m15.11 13.42 2.348-2.374a1.318 1.318 0 0 1 1.877 0 1.354 1.354 0 0 1 0 1.9l-2.346 2.372 2.346 2.374a1.354 1.354 0 0 1 0 1.898 1.316 1.316 0 0 1-1.877 0l-2.346-2.373-2.346 2.373a1.316 1.316 0 0 1-1.877 0 1.354 1.354 0 0 1 0-1.898l2.346-2.374-2.346-2.373a1.354 1.354 0 0 1 0-1.899 1.318 1.318 0 0 1 1.877 0l2.346 2.374h-.001Z" style="fill:#DA2020"/></svg>',pa='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M6.182 5.514a.643.643 0 0 0-.636.64v-.001a.636.636 0 0 0 .636.639h5.682a.641.641 0 0 0 .636-.638.637.637 0 0 0-.636-.64H6.182Z"/><path d="M6.182 8.5a.642.642 0 0 0-.588.882.636.636 0 0 0 .588.395h5.682a.641.641 0 0 0 .636-.639.635.635 0 0 0-.636-.638H6.182Z"/><path d="M6.18 11.5a.64.64 0 0 0 0 1.278h3.133a.64.64 0 0 0 0-1.278H6.18Z"/><path d="m11.772 18.308.154-.23c-.195-.098-.304-.192-.328-.28-.024-.09 0-.274.076-.551.062-.166.099-.296.11-.393a2.934 2.934 0 0 0 0-.479c-.137-.41-.2-.685-.186-.83.013-.145.117-.335.313-.57l4.465-6.207c.356-.6 1.059-.56 2.11.118 1.05.68 1.443 1.272 1.177 1.772l-3.876 6.833c-.105.27-.22.444-.347.515-.128.07-.4.119-.813.139a2.954 2.954 0 0 0-.487.21c-.127.09-.244.193-.347.31-.193.174-.332.262-.416.262-.064 0-.178-.05-.346-.15l-.204.27-1.056-.739Zm-.184.274 1.039.727-.26.34h-1.496l.717-1.067Z"/><path clip-rule="evenodd" d="M3.272 1A1.28 1.28 0 0 0 2 2.28v14.439a1.276 1.276 0 0 0 1.272 1.28h6.838a2.13 2.13 0 0 1 .003-.61 4.08 4.08 0 0 1 .156-.67c.011-.029.02-.052.025-.069v-.008a7.395 7.395 0 0 1-.042-.142H3.5V2.46s11 .002 11 .005v6.341l.627-.872c.204-.32.49-.614.873-.819V2.281c0-.707-.569-1.28-1.272-1.28L3.272 1ZM16 9.29l-1.5 2.085V16.5h-2.708c.005.118.002.236-.007.354a.904.904 0 0 1-.013.075l-.001.005a3.108 3.108 0 0 1-.097.312c-.027.101-.048.19-.062.266a.734.734 0 0 0-.014.287.25.25 0 0 0 .068.105.65.65 0 0 0 .088.074l.017.011.016.01h.175L14.73 18c.697 0 1.271-.573 1.271-1.281v-7.43Z"/></svg>',wa='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M3 18v-1.5h14V18zm2.2-8V3.6c0-.4.4-.6.8-.6.3 0 .7.2.7.6v6.2c0 2 1.3 2.8 3.2 2.8 1.9 0 3.4-.9 3.4-2.9V3.6c0-.3.4-.5.8-.5.3 0 .7.2.7.5V10c0 2.7-2.2 4-4.9 4-2.6 0-4.7-1.2-4.7-4z"/></svg>',ua='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m5.042 9.367 2.189 1.837a.75.75 0 0 1-.965 1.149l-3.788-3.18a.747.747 0 0 1-.21-.284.75.75 0 0 1 .17-.945L6.23 4.762a.75.75 0 1 1 .964 1.15L4.863 7.866h8.917A.75.75 0 0 1 14 7.9a4 4 0 1 1-1.477 7.718l.344-1.489a2.5 2.5 0 1 0 1.094-4.73l.008-.032H5.042z"/></svg>',Ma='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.077 15 .991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955.741.741 0 0 1 .12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184zm4.919 10.562-1.414 1.414a.75.75 0 1 1-1.06-1.06l1.414-1.415-1.415-1.414a.75.75 0 0 1 1.061-1.06l1.414 1.414 1.414-1.415a.75.75 0 0 1 1.061 1.061l-1.414 1.414 1.414 1.415a.75.75 0 0 1-1.06 1.06l-1.415-1.414z"/></svg>',Ha='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M1.201 1C.538 1 0 1.47 0 2.1v14.363c0 .64.534 1.037 1.186 1.037H5.06l5.058-5.078L6.617 9.15a.696.696 0 0 0-.957-.033L1.5 13.6V2.5h15v4.354a3.478 3.478 0 0 1 1.5.049V2.1c0-.63-.547-1.1-1.2-1.1H1.202Zm11.713 2.803a2.147 2.147 0 0 0-2.049 1.992 2.14 2.14 0 0 0 1.28 2.096 2.13 2.13 0 0 0 2.642-3.11 2.129 2.129 0 0 0-1.873-.978ZM8.089 17.635v2.388h2.389l7.046-7.046-2.39-2.39-7.045 7.048Zm11.282-6.507a.637.637 0 0 0 .139-.692.603.603 0 0 0-.139-.205l-1.49-1.488a.63.63 0 0 0-.899 0l-1.166 1.163 2.39 2.39 1.165-1.168Z"/></svg>',za='<svg viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_146_268)"><path d="M1.801 1.4C1.138 1.4.6 1.87.6 2.5v14.363c0 .64.534 1.037 1.186 1.037h9.494a2.97 2.97 0 0 1-.414-.287A2.998 2.998 0 0 1 9.81 15.59v-.007a3.003 3.003 0 0 1 .693-2.186l.383-.455-.02.018-3.65-3.41a.695.695 0 0 0-.957-.034L2.1 14V2.9h15v5.535a2.97 2.97 0 0 1 1.412.932l.088.105V2.5c0-.63-.547-1.1-1.2-1.1H1.802Zm11.713 2.803a2.146 2.146 0 0 0-2.049 1.992 2.14 2.14 0 0 0 1.28 2.096 2.13 2.13 0 0 0 2.644-3.11 2.134 2.134 0 0 0-1.875-.978Z"/><path d="M16.122 19.5a.79.79 0 0 0 .79-.79v-5.373l2.059 2.455a.79.79 0 0 0 1.211-1.015l-3.351-3.995a.79.79 0 0 0-.996-.179.786.786 0 0 0-.299.221l-3.35 3.99a.79.79 0 1 0 1.21 1.017l1.936-2.306v5.185c0 .436.353.79.79.79Z"/><path d="M16.122 19.5a.79.79 0 0 0 .79-.79v-5.373l2.059 2.455a.79.79 0 0 0 1.211-1.015l-3.351-3.995a.79.79 0 0 0-.996-.179.786.786 0 0 0-.299.221l-3.35 3.99a.79.79 0 1 0 1.21 1.017l1.936-2.306v5.185c0 .436.353.79.79.79Z"/></g><defs><clipPath id="clip0_146_268"><rect width="20" height="20" transform="translate(0.599976 0.399963)"/></clipPath></defs></svg>',Va='<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M12.748 2a4.622 4.622 0 0 0-3.174 1.362L8.02 4.915a.783.783 0 0 0 .002 1.11.786.786 0 0 0 1.11 0l1.55-1.553c1.185-1.185 3.024-1.195 4.116-.104l.851.854c1.092 1.091 1.083 2.927-.101 4.11l-1.555 1.554a.787.787 0 0 0 .557 1.34.78.78 0 0 0 .553-.23l1.554-1.552c1.758-1.757 1.838-4.597.102-6.332l-.854-.853A4.31 4.31 0 0 0 12.748 2Zm-.488 4.973a.78.78 0 0 0-.553.23L7.2 11.71l-.004.002a.784.784 0 0 0 1.11 1.107l.003-.002 4.504-4.505a.785.785 0 0 0-.554-1.339Zm-6.79.815a.791.791 0 0 0-.554.234L3.36 9.573c-1.757 1.758-1.836 4.597-.101 6.332l.853.852c1.736 1.734 4.572 1.655 6.33-.102l1.547-1.547.006-.008a.777.777 0 0 0 .244-.554.782.782 0 0 0-.799-.797.774.774 0 0 0-.56.248l-1.545 1.547c-1.184 1.184-3.021 1.195-4.113.104l-.854-.854c-1.091-1.091-1.083-2.927.102-4.111l1.552-1.555a.787.787 0 0 0 .233-.555.79.79 0 0 0-.06-.3.793.793 0 0 0-.173-.253s-.104-.14-.183-.185c-.051-.03-.133-.047-.37-.047Z"/></svg>',fa='<svg viewBox="0 0 11 10" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M5.5 5C8.538 5 11 7.015 11 9.5c0 .17-.011.336-.034.5H.034A3.732 3.732 0 0 1 0 9.5C0 7.015 2.462 5 5.5 5zm0-5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z"/></svg>',xa='<svg width="12" height="12" viewBox="0 0 13 13" xmlns="http://www.w3.org/2000/svg">\n<path fill-rule="evenodd" clip-rule="evenodd" d="M12 6C12 9.31371 9.31371 12 6 12C2.68629 12 0 9.31371 0 6C0 2.68629 2.68629 0 6 0C9.31371 0 12 2.68629 12 6ZM5.27988 2.40003H6.71988V6.72003H5.27988V2.40003ZM6.72009 8.16003H5.28009V9.60003H6.72009V8.16003Z" fill="#DB3700"/>\n</svg>\n';function Za(t){const e=t.editing.view,a=p.BalloonPanelView.defaultPositions;return{target:e.domConverter.viewToDom(e.document.selection.getSelectedElement()),positions:[a.northArrowSouth,a.northArrowSouthWest,a.northArrowSouthEast,a.southArrowNorth,a.southArrowNorthWest,a.southArrowNorthEast]}}var ba=a("ckeditor5/src/utils.js");class La extends p.View{constructor(t){super(t),this.focusTracker=new ba.FocusTracker,this.keystrokes=new ba.KeystrokeHandler,this.decorativeToggle=this._decorativeToggleView(),this.labeledInput=this._createLabeledInputView(),this.saveButtonView=this._createButton(Drupal.t("Save"),F,"ck-button-save"),this.saveButtonView.type="submit",this.cancelButtonView=this._createButton(Drupal.t("Cancel"),P,"ck-button-cancel","cancel"),this._focusables=new p.ViewCollection,this._focusCycler=new p.FocusCycler({focusables:this._focusables,focusTracker:this.focusTracker,keystrokeHandler:this.keystrokes,actions:{focusPrevious:"shift + tab",focusNext:"tab"}}),this.setTemplate({tag:"form",attributes:{class:["ck","ck-media-alternative-text-form","ck-vertical-form"],tabindex:"-1"},children:[{tag:"div",children:[this.decorativeToggle]},this.labeledInput,this.saveButtonView,this.cancelButtonView]}),(0,p.injectCssTransitionDisabler)(this)}render(){super.render(),this.keystrokes.listenTo(this.element),(0,p.submitHandler)({view:this}),[this.decorativeToggle,this.labeledInput,this.saveButtonView,this.cancelButtonView].forEach((t=>{this._focusables.add(t),this.focusTracker.add(t.element)}))}_createButton(t,e,a,i){const l=new p.ButtonView(this.locale);return l.set({label:t,icon:e,tooltip:!0}),l.extendTemplate({attributes:{class:a}}),i&&l.delegate("execute").to(this,i),l}_createLabeledInputView(){const t=new p.LabeledFieldView(this.locale,p.createLabeledInputText);return t.bind("class").to(this.decorativeToggle,"isOn",(t=>t?"ck-hidden":"")),t.label=Drupal.t("Alternative text override"),t}_decorativeToggleView(){const t=new p.SwitchButtonView(this.locale);return t.set({withText:!0,label:Drupal.t("Decorative image")}),t.on("execute",(()=>{t.isOn&&(this.labeledInput.fieldView.element.value=""),t.set("isOn",!t.isOn)})),t}}class Ca extends e.Plugin{static get requires(){return[p.ContextualBalloon]}static get pluginName(){return"MediaImageTextAlternativeUi"}init(){this._createButton(),this._createForm()}destroy(){super.destroy(),this._form.destroy()}_createButton(){const t=this.editor;t.ui.componentFactory.add("mediaImageTextAlternative",(e=>{const a=t.commands.get("mediaImageTextAlternative"),i=new p.ButtonView(e);return i.set({label:Drupal.t("Override media image alternative text"),icon:Xt,tooltip:!0}),i.bind("isVisible").to(a,"isEnabled"),this.listenTo(i,"execute",(()=>{this._showForm()})),i}))}_createForm(){const t=this.editor,e=t.editing.view.document;this._balloon=this.editor.plugins.get("ContextualBalloon"),this._form=new La(t.locale),this._form.render(),this.listenTo(this._form,"submit",(()=>{t.execute("mediaImageTextAlternative",{newValue:this._form.decorativeToggle.isOn?'""':this._form.labeledInput.fieldView.element.value}),this._hideForm(!0)})),this.listenTo(this._form,"cancel",(()=>{this._hideForm(!0)})),this._form.keystrokes.set("Esc",((t,e)=>{this._hideForm(!0),e()})),this.listenTo(t.ui,"update",(()=>{h(e.selection)?this._isVisible&&function(t){const e=t.plugins.get("ContextualBalloon");if(h(t.editing.view.document.selection)){const a=Za(t);e.updatePosition(a)}}(t):this._hideForm(!0)})),(0,p.clickOutsideHandler)({emitter:this._form,activator:()=>this._isVisible,contextElements:[this._balloon.view.element],callback:()=>this._hideForm()})}_showForm(){if(this._isVisible)return;const t=this.editor,e=t.commands.get("mediaImageTextAlternative"),a=this._form.decorativeToggle,i=t.plugins.get("DrupalMediaMetadataRepository"),l=this._form.labeledInput;this._form.disableCssTransitions(),this._isInBalloon||this._balloon.add({view:this._form,position:Za(t)}),a.isOn='""'===e.value,l.fieldView.element.value=e.value||"",l.fieldView.value=l.fieldView.element.value,this._form.defaultAltText="";const o=t.model.document.selection.getSelectedElement();n(o)&&i.getMetadata(o).then((t=>{this._form.defaultAltText=t.imageSourceMetadata?t.imageSourceMetadata.alt:"",l.infoText=Drupal.t(`Leave blank to use the default alternative text: "${this._form.defaultAltText}".`)})).catch((t=>{console.warn(t.toString())})),this._form.enableCssTransitions()}_hideForm(t){this._isInBalloon&&(this._form.focusTracker.isFocused&&this._form.saveButtonView.focus(),this._balloon.remove(this._form),t&&this.editor.editing.view.focus())}get _isVisible(){return this._balloon.visibleView===this._form}get _isInBalloon(){return this._balloon.hasView(this._form)}}class Ia extends e.Plugin{static get requires(){return[z,Ca]}static get pluginName(){return"MediaImageTextAlternative"}}function ya(t,e,a){if(e.attributes)for(const[i,l]of Object.entries(e.attributes))t.setAttribute(i,l,a);e.styles&&t.setStyle(e.styles,a),e.classes&&t.addClass(e.classes,a)}function Ba(t,e,a){if(!a.consumable.consume(e.item,t.name))return;const i=a.mapper.toViewElement(e.item);ya(a.writer,e.attributeNewValue,i)}class Aa extends e.Plugin{constructor(t){if(super(t),!t.plugins.has("GeneralHtmlSupport"))return;t.plugins.has("DataFilter")&&t.plugins.has("DataSchema")||console.error("DataFilter and DataSchema plugins are required for Drupal Media to integrate with General HTML Support plugin.");const{schema:e}=t.model,{conversion:a}=t,i=this.editor.plugins.get("DataFilter");this.editor.plugins.get("DataSchema").registerBlockElement({model:"drupalMedia",view:"drupal-media"}),i.on("register:drupal-media",((t,l)=>{"drupalMedia"===l.model&&(e.extend("drupalMedia",{allowAttributes:["htmlLinkAttributes","htmlAttributes"]}),a.for("upcast").add(function(t){return e=>{e.on("element:drupal-media",((e,a,i)=>{function l(e,l){const n=t.processViewAttributes(e,i);n&&i.writer.setAttribute(l,n,a.modelRange)}const n=a.viewItem,o=n.parent;l(n,"htmlAttributes"),o.is("element","a")&&l(o,"htmlLinkAttributes")}),{priority:"low"})}}(i)),a.for("editingDowncast").add((t=>{t.on("attribute:linkHref:drupalMedia",((t,e,a)=>{if(!a.consumable.consume(e.item,"attribute:htmlLinkAttributes:drupalMedia"))return;const i=a.mapper.toViewElement(e.item),l=function(t,e,a){const i=t.createRangeOn(e);for(const{item:t}of i.getWalker())if(t.is("element",a))return t}(a.writer,i,"a");ya(a.writer,e.item.getAttribute("htmlLinkAttributes"),l)}),{priority:"low"})})),a.for("dataDowncast").add((t=>{t.on("attribute:linkHref:drupalMedia",((t,e,a)=>{if(!a.consumable.consume(e.item,"attribute:htmlLinkAttributes:drupalMedia"))return;const i=a.mapper.toViewElement(e.item).parent;ya(a.writer,e.item.getAttribute("htmlLinkAttributes"),i)}),{priority:"low"}),t.on("attribute:htmlAttributes:drupalMedia",Ba,{priority:"low"})})),t.stop())}))}static get pluginName(){return"DrupalMediaGeneralHtmlSupport"}}class Ea extends e.Plugin{static get requires(){return[g,Aa,w,u,Ia]}static get pluginName(){return"DrupalMedia"}}var ka=a("ckeditor5/src/engine.js");function Da(t){return Array.from(t.getChildren()).find((t=>"drupal-media"===t.name))}function Sa(t){return e=>{e.on(`attribute:${t.id}:drupalMedia`,((e,a,i)=>{const l=i.mapper.toViewElement(a.item);let n=Array.from(l.getChildren()).find((t=>"a"===t.name));if(n=!n&&l.is("element","a")?l:Array.from(l.getAncestors()).find((t=>"a"===t.name)),n){for(const[e,a]of(0,ba.toMap)(t.attributes))i.writer.setAttribute(e,a,n);t.classes&&i.writer.addClass(t.classes,n);for(const e in t.styles)Object.prototype.hasOwnProperty.call(t.styles,e)&&i.writer.setStyle(e,t.styles[e],n)}}))}}function _a(t,e){return t=>{t.on("element:a",((t,a,i)=>{const l=a.viewItem;if(!Da(l))return;const n=new ka.Matcher(e._createPattern()).match(l);if(!n)return;if(!i.consumable.consume(l,n.match))return;const o=a.modelCursor.nodeBefore;i.writer.setAttribute(e.id,!0,o)}),{priority:"high"})}}class Ta extends e.Plugin{static get requires(){return["LinkEditing","DrupalMediaEditing"]}static get pluginName(){return"DrupalLinkMediaEditing"}init(){const{editor:t}=this;t.model.schema.extend("drupalMedia",{allowAttributes:["linkHref"]}),t.conversion.for("upcast").add((t=>{t.on("element:a",((t,e,a)=>{const i=e.viewItem,l=Da(i);if(!l)return;if(!a.consumable.consume(i,{attributes:["href"],name:!0}))return;const n=i.getAttribute("href");if(null===n)return;const o=a.convertItem(l,e.modelCursor);e.modelRange=o.modelRange,e.modelCursor=o.modelCursor;const s=e.modelCursor.nodeBefore;s&&s.is("element","drupalMedia")&&a.writer.setAttribute("linkHref",n,s)}),{priority:"high"})})),t.conversion.for("editingDowncast").add((t=>{t.on("attribute:linkHref:drupalMedia",((t,e,a)=>{const{writer:i}=a;if(!a.consumable.consume(e.item,t.name))return;const l=a.mapper.toViewElement(e.item),n=Array.from(l.getChildren()).find((t=>"a"===t.name));if(n)e.attributeNewValue?i.setAttribute("href",e.attributeNewValue,n):(i.move(i.createRangeIn(n),i.createPositionAt(l,0)),i.remove(n));else{const t=Array.from(l.getChildren()).find((t=>t.getAttribute("data-drupal-media-preview"))),a=i.createContainerElement("a",{href:e.attributeNewValue});i.insert(i.createPositionAt(l,0),a),i.move(i.createRangeOn(t),i.createPositionAt(a,0))}}),{priority:"high"})})),t.conversion.for("dataDowncast").add((t=>{t.on("attribute:linkHref:drupalMedia",((t,e,a)=>{const{writer:i}=a;if(!a.consumable.consume(e.item,t.name))return;const l=a.mapper.toViewElement(e.item),n=i.createContainerElement("a",{href:e.attributeNewValue});i.insert(i.createPositionBefore(l),n),i.move(i.createRangeOn(l),i.createPositionAt(n,0))}),{priority:"high"})})),this._enableManualDecorators();if(t.commands.get("link").automaticDecorators.length>0)throw new Error("The Drupal Media plugin is not compatible with automatic link decorators. To use Drupal Media, disable any plugins providing automatic link decorators.")}_enableManualDecorators(){const t=this.editor,e=t.commands.get("link");for(const a of e.manualDecorators)t.model.schema.extend("drupalMedia",{allowAttributes:a.id}),t.conversion.for("downcast").add(Sa(a)),t.conversion.for("upcast").add(_a(0,a))}}class Oa extends e.Plugin{static get requires(){return["LinkEditing","LinkUI","DrupalMediaEditing"]}static get pluginName(){return"DrupalLinkMediaUi"}init(){const{editor:t}=this,e=t.editing.view.document;this.listenTo(e,"click",((e,a)=>{this._isSelectedLinkedMedia(t.model.document.selection)&&(a.preventDefault(),e.stop())}),{priority:"high"}),this._createToolbarLinkMediaButton()}_createToolbarLinkMediaButton(){const{editor:t}=this;t.ui.componentFactory.add("drupalLinkMedia",(e=>{const a=new p.ButtonView(e),i=t.plugins.get("LinkUI"),l=t.commands.get("link");return a.set({isEnabled:!0,label:Drupal.t("Link media"),icon:'<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="m11.077 15 .991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955.741.741 0 0 1 .12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184z"/></svg>\n',keystroke:"Ctrl+K",tooltip:!0,isToggleable:!0}),a.bind("isEnabled").to(l,"isEnabled"),a.bind("isOn").to(l,"value",(t=>!!t)),this.listenTo(a,"execute",(()=>{this._isSelectedLinkedMedia(t.model.document.selection)?i._addToolbarView():i._showUI(!0)})),a}))}_isSelectedLinkedMedia(t){const e=t.getSelectedElement();return e?.is("element","drupalMedia")&&e.hasAttribute("linkHref")}}class Pa extends e.Plugin{static get requires(){return[Ta,Oa]}static get pluginName(){return"DrupalLinkMedia"}}var ja=a("ckeditor5/src/icons.js");const Ra={get inline(){return{name:"inline",title:"In line",icon:ja.IconObjectInline,modelElements:["imageInline"],isDefault:!0}},get alignLeft(){return{name:"alignLeft",title:"Left aligned image",icon:ja.IconObjectInlineLeft,modelElements:["imageBlock","imageInline"],className:"image-style-align-left"}},get alignBlockLeft(){return{name:"alignBlockLeft",title:"Left aligned image",icon:ja.IconObjectLeft,modelElements:["imageBlock"],className:"image-style-block-align-left"}},get alignCenter(){return{name:"alignCenter",title:"Centered image",icon:ja.IconObjectCenter,modelElements:["imageBlock"],className:"image-style-align-center"}},get alignRight(){return{name:"alignRight",title:"Right aligned image",icon:ja.IconObjectInlineRight,modelElements:["imageBlock","imageInline"],className:"image-style-align-right"}},get alignBlockRight(){return{name:"alignBlockRight",title:"Right aligned image",icon:ja.IconObjectRight,modelElements:["imageBlock"],className:"image-style-block-align-right"}},get block(){return{name:"block",title:"Centered image",icon:ja.IconObjectCenter,modelElements:["imageBlock"],isDefault:!0}},get side(){return{name:"side",title:"Side image",icon:ja.IconObjectInlineRight,modelElements:["imageBlock"],className:"image-style-side"}}},Fa=(()=>({full:ja.IconObjectFullWidth,left:ja.IconObjectLeft,right:ja.IconObjectRight,center:ja.IconObjectCenter,inlineLeft:ja.IconObjectInlineLeft,inlineRight:ja.IconObjectInlineRight,inline:ja.IconObjectInline}))(),Na=[{name:"imageStyle:wrapText",title:"Wrap text",defaultItem:"imageStyle:alignLeft",items:["imageStyle:alignLeft","imageStyle:alignRight"]},{name:"imageStyle:breakText",title:"Break text",defaultItem:"imageStyle:block",items:["imageStyle:alignBlockLeft","imageStyle:block","imageStyle:alignBlockRight"]}];function Ua(t){(0,ba.logWarning)("image-style-configuration-definition-invalid",t)}const Wa={normalizeStyles:function(t){return(t.configuredStyles.options||[]).map((t=>function(t){t="string"==typeof t?Ra[t]?{...Ra[t]}:{name:t}:function(t,e){const a={...e};for(const i in t)Object.prototype.hasOwnProperty.call(e,i)||(a[i]=t[i]);return a}(Ra[t.name],t);"string"==typeof t.icon&&(t.icon=Fa[t.icon]||t.icon);return t}(t))).filter((e=>function(t,{isBlockPluginLoaded:e,isInlinePluginLoaded:a}){const{modelElements:i,name:l}=t;if(!(i&&i.length&&l))return Ua({style:t}),!1;{const l=[e?"imageBlock":null,a?"imageInline":null];if(!i.some((t=>l.includes(t))))return(0,ba.logWarning)("image-style-missing-dependency",{style:t,missingPlugins:i.map((t=>"imageBlock"===t?"ImageBlockEditing":"ImageInlineEditing"))}),!1}return!0}(e,t)))},getDefaultStylesConfiguration:function(t,e){return t&&e?{options:["inline","alignLeft","alignRight","alignCenter","alignBlockLeft","alignBlockRight","block","side"]}:t?{options:["block","side"]}:e?{options:["inline","alignLeft","alignRight"]}:{}},getDefaultDropdownDefinitions:function(t){return t.has("ImageBlockEditing")&&t.has("ImageInlineEditing")?[...Na]:[]},warnInvalidStyle:Ua,DEFAULT_OPTIONS:Ra,DEFAULT_ICONS:Fa,DEFAULT_DROPDOWN_DEFINITIONS:Na};function $a(t,e,a){for(const i of e)if(a.checkAttribute(t,i))return!0;return!1}function qa(t,e,a){const i=t.getSelectedElement();if(i&&$a(i,a,e))return i;let{parent:l}=t.getFirstPosition();for(;l;){if(l.is("element")&&$a(l,a,e))return l;l=l.parent}return null}class Ka extends e.Command{constructor(t,e){super(t),this.styles={},Object.keys(e).forEach((t=>{this.styles[t]=new Map(e[t].map((t=>[t.name,t])))})),this.modelAttributes=[];for(const t of Object.keys(e)){const e=c(t);this.modelAttributes.push(e)}}refresh(){const{editor:t}=this,e=qa(t.model.document.selection,t.model.schema,this.modelAttributes);this.isEnabled=!!e,this.isEnabled?this.value=this.getValue(e):this.value=!1}getValue(t){const e={};return Object.keys(this.styles).forEach((a=>{const i=c(a);if(t.hasAttribute(i))e[a]=t.getAttribute(i);else for(const[,t]of this.styles[a])t.isDefault&&(e[a]=t.name)})),e}execute(t={}){const{editor:{model:e}}=this,{value:a,group:i}=t,l=c(i);e.change((t=>{const n=qa(e.document.selection,e.schema,this.modelAttributes);!a||this.styles[i].get(a).isDefault?t.removeAttribute(l,n):t.setAttribute(l,a,n)}))}}function Ga(t,e){for(const a of e)if(a.name===t)return a}class Ja extends e.Plugin{init(){const{editor:e}=this,a=e.config.get("drupalElementStyles");this.normalizedStyles={},Object.keys(a).forEach((e=>{this.normalizedStyles[e]=a[e].map((e=>("string"==typeof e.icon&&t[e.icon]&&(e.icon=t[e.icon]),e.name&&(e.name=`${e.name}`),e))).filter((t=>t.isDefault||t.attributeName&&t.attributeValue?t.modelElements&&Array.isArray(t.modelElements)?!!t.name||(console.warn("drupalElementStyles options must include a name."),!1):(console.warn("drupalElementStyles options must include an array of supported modelElements."),!1):(console.warn(`${t.attributeValue} drupalElementStyles options must include attributeName and attributeValue.`),!1)))})),this._setupConversion(),e.commands.add("drupalElementStyle",new Ka(e,this.normalizedStyles))}_setupConversion(){const{editor:t}=this,{schema:e}=t.model;Object.keys(this.normalizedStyles).forEach((a=>{const i=c(a),l=(n=this.normalizedStyles[a],(t,e,a)=>{if(!a.consumable.consume(e.item,t.name))return;const i=Ga(e.attributeNewValue,n),l=Ga(e.attributeOldValue,n),o=a.mapper.toViewElement(e.item),s=a.writer;l&&("class"===l.attributeName?s.removeClass(l.attributeValue,o):s.removeAttribute(l.attributeName,o)),i&&("class"===i.attributeName?s.addClass(i.attributeValue,o):i.isDefault||s.setAttribute(i.attributeName,i.attributeValue,o))});var n;const o=function(t,e){const a=t.filter((t=>!t.isDefault));return(t,i,l)=>{if(!i.modelRange)return;const n=i.viewItem,o=(0,ba.first)(i.modelRange.getItems());if(o&&l.schema.checkAttribute(o,e))for(const t of a)if("class"===t.attributeName)l.consumable.consume(n,{classes:t.attributeValue})&&l.writer.setAttribute(e,t.name,o);else if(l.consumable.consume(n,{attributes:[t.attributeName]}))for(const t of a)t.attributeValue===n.getAttribute(t.attributeName)&&l.writer.setAttribute(e,t.name,o)}}(this.normalizedStyles[a],i);t.editing.downcastDispatcher.on(`attribute:${i}`,l),t.data.downcastDispatcher.on(`attribute:${i}`,l);[...new Set(this.normalizedStyles[a].map((t=>t.modelElements)).flat())].forEach((t=>{e.extend(t,{allowAttributes:i})})),t.data.upcastDispatcher.on("element",o,{priority:"low"})}))}static get pluginName(){return"DrupalElementStyleEditing"}}const Qa=t=>t,Xa=(t,e)=>(t?`${t}: `:"")+e;function Ya(t,e){return`drupalElementStyle:${e}:${t}`}class ti extends e.Plugin{static get requires(){return[Ja]}init(){const{plugins:t}=this.editor,e=this.editor.config.get("drupalMedia.toolbar")||[],a=t.get("DrupalElementStyleEditing").normalizedStyles;Object.keys(a).forEach((t=>{a[t].forEach((e=>{this._createButton(e,t,a[t])}))}));e.filter(r).filter((t=>{const e=[];if(!t.display)return console.warn("dropdown configuration must include a display key specifying either listDropdown or splitButton."),!1;t.items.includes(t.defaultItem)||console.warn("defaultItem must be part of items in the dropdown configuration.");for(const a of t.items){const t=a.split(":")[1];e.push(t)}return!!e.every((t=>t===e[0]))||(console.warn("dropdown configuration should only contain buttons from one group."),!1)})).forEach((t=>{if(t.items.length>=2){const e=t.name.split(":")[1];switch(t.display){case"splitButton":this._createDropdown(t,a[e]);break;case"listDropdown":this._createListDropdown(t,a[e])}}}))}updateOptionVisibility(t,e,a,i){const{selection:l}=this.editor.model.document,n={};n[i]=t;const o=l?l.getSelectedElement():qa(l,this.editor.model.schema,n),s=t.filter((function(t){for(const[e,a]of(0,ba.toMap)(t.modelAttributes))if(o&&o.hasAttribute(e))return a.includes(o.getAttribute(e));return!0}));a.hasOwnProperty("model")?s.includes(e)?a.model.set({class:""}):a.model.set({class:"ck-hidden"}):s.includes(e)?a.set({class:""}):a.set({class:"ck-hidden"})}_createDropdown(t,e){const a=this.editor.ui.componentFactory;a.add(t.name,(i=>{let l;const{defaultItem:n,items:o,title:s}=t,h=o.filter((t=>{const a=t.split(":")[1];return e.find((({name:e})=>Ya(e,a)===t))})).map((t=>{const e=a.create(t);return t===n&&(l=e),e}));o.length!==h.length&&Wa.warnInvalidStyle({dropdown:t});const r=(0,p.createDropdown)(i,p.SplitButtonView),v=r.buttonView;return(0,p.addToolbarToDropdown)(r,h),v.set({label:Xa(s,l.label),class:null,tooltip:!0}),v.bind("icon").toMany(h,"isOn",((...t)=>{const e=t.findIndex(Qa);return e<0?l.icon:h[e].icon})),v.bind("label").toMany(h,"isOn",((...t)=>{const e=t.findIndex(Qa);return Xa(s,e<0?l.label:h[e].label)})),v.bind("isOn").toMany(h,"isOn",((...t)=>t.some(Qa))),v.bind("class").toMany(h,"isOn",((...t)=>t.some(Qa)?"ck-splitbutton_flatten":null)),v.on("execute",(()=>{h.some((({isOn:t})=>t))?r.isOpen=!r.isOpen:l.fire("execute")})),r.bind("isEnabled").toMany(h,"isEnabled",((...t)=>t.some(Qa))),r}))}_createButton(t,e,a){const i=t.name;this.editor.ui.componentFactory.add(Ya(i,e),(l=>{const n=this.editor.commands.get("drupalElementStyle"),o=new p.ButtonView(l);return o.set({label:t.title,icon:t.icon,tooltip:!0,isToggleable:!0}),o.bind("isEnabled").to(n,"isEnabled"),o.bind("isOn").to(n,"value",(t=>t&&t[e]===i)),o.on("execute",this._executeCommand.bind(this,i,e)),this.listenTo(this.editor.ui,"update",(()=>{this.updateOptionVisibility(a,t,o,e)})),o}))}getDropdownListItemDefinitions(t,e,a){const i=new ba.Collection;return t.forEach((e=>{const l={type:"button",model:new p.ViewModel({group:a,commandValue:e.name,label:e.title,withText:!0,class:""})};i.add(l),this.listenTo(this.editor.ui,"update",(()=>{this.updateOptionVisibility(t,e,l,a)}))})),i}_createListDropdown(t,e){const a=this.editor.ui.componentFactory;a.add(t.name,(i=>{let l;const{defaultItem:n,items:o,title:s,defaultText:h}=t,r=t.name.split(":")[1],v=o.filter((t=>e.find((({name:e})=>Ya(e,r)===t)))).map((t=>{const e=a.create(t);return t===n&&(l=e),e}));o.length!==v.length&&Wa.warnInvalidStyle({dropdown:t});const c=(0,p.createDropdown)(i,p.DropdownButtonView),d=c.buttonView;d.set({label:Xa(s,l.label),class:null,tooltip:h,withText:!0});const m=this.editor.commands.get("drupalElementStyle");return d.bind("label").to(m,"value",(t=>{if(t?.[r])for(const a of e)if(a.name===t[r])return a.title;return h})),c.bind("isOn").to(m),c.bind("isEnabled").to(this),(0,p.addListToDropdown)(c,this.getDropdownListItemDefinitions(e,m,r)),this.listenTo(c,"execute",(t=>{this._executeCommand(t.source.commandValue,t.source.group)})),c}))}_executeCommand(t,e){this.editor.execute("drupalElementStyle",{value:t,group:e}),this.editor.editing.view.focus()}static get pluginName(){return"DrupalElementStyleUi"}}class ei extends e.Plugin{static get requires(){return[Ja,ti]}static get pluginName(){return"DrupalElementStyle"}}function ai(t){const e=t.getFirstPosition().findAncestor("caption");return e&&n(e.parent)?e:null}function ii(t){for(const e of t.getChildren())if(e&&e.is("element","caption"))return e;return null}class li extends e.Command{refresh(){const t=this.editor.model.document.selection,e=t.getSelectedElement();if(!e)return this.isEnabled=!!s(t),void(this.value=!!ai(t));this.isEnabled=n(e),this.isEnabled?this.value=!!ii(e):this.value=!1}execute(t={}){const{focusCaptionOnShow:e}=t;this.editor.model.change((t=>{this.value?this._hideDrupalMediaCaption(t):this._showDrupalMediaCaption(t,e)}))}_showDrupalMediaCaption(t,e){const a=this.editor.model.document.selection,i=this.editor.plugins.get("DrupalMediaCaptionEditing"),l=s(a),n=i._getSavedCaption(l)||t.createElement("caption");t.append(n,l),e&&t.setSelection(n,"in")}_hideDrupalMediaCaption(t){const e=this.editor,a=e.model.document.selection,i=e.plugins.get("DrupalMediaCaptionEditing");let l,n=a.getSelectedElement();n?l=ii(n):(l=ai(a),n=s(a)),i._saveCaption(n,l),t.setSelection(n,"on"),t.remove(l)}}class ni extends e.Plugin{static get requires(){return[]}static get pluginName(){return"DrupalMediaCaptionEditing"}constructor(t){super(t),this._savedCaptionsMap=new WeakMap}init(){const t=this.editor,e=t.model.schema;e.isRegistered("caption")?e.extend("caption",{allowIn:"drupalMedia"}):e.register("caption",{allowIn:"drupalMedia",allowContentOf:"$block",isLimit:!0}),t.commands.add("toggleMediaCaption",new li(t)),this._setupConversion()}_setupConversion(){const t=this.editor,e=t.editing.view;var a;t.conversion.for("upcast").add(function(t){const e=(e,a,i)=>{const{viewItem:l}=a,{writer:n,consumable:o}=i;if(!a.modelRange||!o.consume(l,{attributes:["data-caption"]}))return;const s=n.createElement("caption"),h=a.modelRange.start.nodeAfter,r=t.data.processor.toView(l.getAttribute("data-caption"));i.consumable.constructor.createFrom(r,i.consumable),i.convertChildren(r,s),n.append(s,h)};return t=>{t.on("element:drupal-media",e,{priority:"low"})}}(t)),t.conversion.for("editingDowncast").elementToElement({model:"caption",view:(t,{writer:a})=>{if(!n(t.parent))return null;const i=a.createEditableElement("figcaption");return i.placeholder=Drupal.t("Enter media caption"),(0,ka.enablePlaceholder)({view:e,element:i,keepOnFocus:!0}),(0,l.toWidgetEditable)(i,a)}}),t.editing.mapper.on("modelToViewPosition",(a=e,(t,e)=>{const i=e.modelPosition,l=i.parent;if(!n(l))return;const o=e.mapper.toViewElement(l);e.viewPosition=a.createPositionAt(o,i.offset+1)})),t.conversion.for("dataDowncast").add(function(t){return e=>{e.on("insert:caption",((e,a,i)=>{const{consumable:l,writer:o,mapper:s}=i;if(!n(a.item.parent)||!l.consume(a.item,"insert"))return;const h=t.model.createRangeIn(a.item),r=o.createDocumentFragment();s.bindElements(a.item,r);for(const{item:e}of Array.from(h)){const a={item:e,range:t.model.createRangeOn(e)},l=`insert:${e.name||"$text"}`;t.data.downcastDispatcher.fire(l,a,i);for(const l of e.getAttributeKeys())Object.assign(a,{attributeKey:l,attributeOldValue:null,attributeNewValue:a.item.getAttribute(l)}),t.data.downcastDispatcher.fire(`attribute:${l}`,a,i)}for(const t of o.createRangeIn(r).getItems())s.unbindViewElement(t);s.unbindViewElement(r);const v=t.data.processor.toData(r);if(v){const t=s.toViewElement(a.item.parent);o.setAttribute("data-caption",v,t)}}))}}(t))}_getSavedCaption(t){const e=this._savedCaptionsMap.get(t);return e?ka.Element.fromJSON(e):null}_saveCaption(t,e){this._savedCaptionsMap.set(t,e.toJSON())}}class oi extends e.Plugin{static get requires(){return[]}static get pluginName(){return"DrupalMediaCaptionUI"}init(){const{editor:t}=this,e=t.editing.view;t.ui.componentFactory.add("toggleDrupalMediaCaption",(a=>{const i=new p.ButtonView(a),l=t.commands.get("toggleMediaCaption");return i.set({label:Drupal.t("Caption media"),icon:j,tooltip:!0,isToggleable:!0}),i.bind("isOn","isEnabled").to(l,"value","isEnabled"),i.bind("label").to(l,"value",(t=>t?Drupal.t("Toggle caption off"):Drupal.t("Toggle caption on"))),this.listenTo(i,"execute",(()=>{t.execute("toggleMediaCaption",{focusCaptionOnShow:!0});const a=ai(t.model.document.selection);if(a){const i=t.editing.mapper.toViewElement(a);e.scrollToTheSelection(),e.change((t=>{t.addClass("drupal-media__caption_highlighted",i)}))}t.editing.view.focus()})),i}))}}class si extends e.Plugin{static get requires(){return[ni,oi]}static get pluginName(){return"DrupalMediaCaption"}}const hi={DrupalMedia:Ea,MediaImageTextAlternative:Ia,MediaImageTextAlternativeEditing:z,MediaImageTextAlternativeUi:Ca,DrupalLinkMedia:Pa,DrupalMediaCaption:si,DrupalElementStyle:ei}})(),i=i.default})()));
\ No newline at end of file diff --git a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalImage/src/imagealternativetext/drupalimagealternativetextui.js b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalImage/src/imagealternativetext/drupalimagealternativetextui.js index 50abc4fe3b8..dedcf00f63b 100644 --- a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalImage/src/imagealternativetext/drupalimagealternativetextui.js +++ b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalImage/src/imagealternativetext/drupalimagealternativetextui.js @@ -8,7 +8,8 @@ * @module drupalImage/imagealternativetext/drupalimagealternativetextui */ -import { Plugin, icons } from 'ckeditor5/src/core'; +import { Plugin } from 'ckeditor5/src/core'; +import { IconLowVision } from '@ckeditor/ckeditor5-icons'; import { ButtonView, ContextualBalloon, @@ -147,7 +148,7 @@ export default class DrupalImageAlternativeTextUi extends Plugin { view.set({ label: Drupal.t('Change image alternative text'), - icon: icons.lowVision, + icon: IconLowVision, tooltip: true, }); diff --git a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalImage/src/imagealternativetext/ui/imagealternativetextformview.js b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalImage/src/imagealternativetext/ui/imagealternativetextformview.js index 8c6ceadd4a1..8bfd9e47fbd 100644 --- a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalImage/src/imagealternativetext/ui/imagealternativetextformview.js +++ b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalImage/src/imagealternativetext/ui/imagealternativetextformview.js @@ -17,7 +17,7 @@ import { submitHandler, } from 'ckeditor5/src/ui'; import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils'; -import { icons } from 'ckeditor5/src/core'; +import { IconCheck, IconCancel } from '@ckeditor/ckeditor5-icons'; /** * A class rendering alternative text form view. @@ -70,7 +70,7 @@ export default class ImageAlternativeTextFormView extends View { */ this.saveButtonView = this._createButton( Drupal.t('Save'), - icons.check, + IconCheck, 'ck-button-save', ); this.saveButtonView.type = 'submit'; @@ -94,7 +94,7 @@ export default class ImageAlternativeTextFormView extends View { */ this.cancelButtonView = this._createButton( Drupal.t('Cancel'), - icons.cancel, + IconCancel, 'ck-button-cancel', 'cancel', ); diff --git a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalelementstyle/drupalelementstyleediting.js b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalelementstyle/drupalelementstyleediting.js index 6b717b1ad0b..677b7b18a23 100644 --- a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalelementstyle/drupalelementstyleediting.js +++ b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalelementstyle/drupalelementstyleediting.js @@ -1,7 +1,8 @@ /* eslint-disable import/no-extraneous-dependencies */ /* cspell:ignore drupalelementstyle drupalelementstylecommand */ /* cspell:ignore drupalelementstyleediting */ -import { Plugin, icons } from 'ckeditor5/src/core'; +import { Plugin } from 'ckeditor5/src/core'; +import * as icons from '@ckeditor/ckeditor5-icons'; import { first } from 'ckeditor5/src/utils'; import DrupalElementStyleCommand from './drupalelementstylecommand'; import { groupNameToModelAttributeKey } from '../utils'; @@ -163,7 +164,7 @@ function viewToModelStyleAttribute(styles, modelAttribute) { * drupalElementStyles: * side: * - name: 'side' - * icon: 'objectBlockRight' + * icon: 'IconObjectRight' * title: 'Side image' * attributeName: 'class' * attributeValue: 'image-side' @@ -171,12 +172,12 @@ function viewToModelStyleAttribute(styles, modelAttribute) { * align: * - name: 'right' * title: 'Right aligned media' - * icon: 'objectRight' + * icon: 'IconObjectInlineRight' * attributeName: 'data-align' * modelElements: [ 'drupalMedia' ] * - name: 'left' * title: 'Left aligned media' - * icon: 'objectLeft' + * icon: 'IconObjectInlineLeft' * attributeName: 'data-align' * attributeValue: 'left' * modelElements: [ 'drupalMedia' ] diff --git a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupallinkmedia/drupallinkmediaui.js b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupallinkmedia/drupallinkmediaui.js index cb4685e49f2..3148fc0b944 100644 --- a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupallinkmedia/drupallinkmediaui.js +++ b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupallinkmedia/drupallinkmediaui.js @@ -83,7 +83,7 @@ export default class DrupalLinkMediaUI extends Plugin { // depending on whether the media is already linked. this.listenTo(button, 'execute', () => { if (this._isSelectedLinkedMedia(editor.model.document.selection)) { - plugin._addActionsView(); + plugin._addToolbarView(); } else { plugin._showUI(true); } @@ -103,8 +103,7 @@ export default class DrupalLinkMediaUI extends Plugin { _isSelectedLinkedMedia(selection) { const selectedModelElement = selection.getSelectedElement(); return ( - !!selectedModelElement && - selectedModelElement.is('element', 'drupalMedia') && + selectedModelElement?.is('element', 'drupalMedia') && selectedModelElement.hasAttribute('linkHref') ); } diff --git a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalmediacaption/drupalmediacaptionui.js b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalmediacaption/drupalmediacaptionui.js index c5f89893c8e..5c1afcf987b 100644 --- a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalmediacaption/drupalmediacaptionui.js +++ b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/drupalmediacaption/drupalmediacaptionui.js @@ -1,5 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies */ -import { Plugin, icons } from 'ckeditor5/src/core'; +import { Plugin } from 'ckeditor5/src/core'; +import { IconCaption } from '@ckeditor/ckeditor5-icons'; import { ButtonView } from 'ckeditor5/src/ui'; import { getMediaCaptionFromModelSelection } from './utils'; @@ -34,7 +35,7 @@ export default class DrupalMediaCaptionUI extends Plugin { const captionCommand = editor.commands.get('toggleMediaCaption'); button.set({ label: Drupal.t('Caption media'), - icon: icons.caption, + icon: IconCaption, tooltip: true, isToggleable: true, }); diff --git a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativecommand.js b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativecommand.js index b4623eee772..2ae8ca9d809 100644 --- a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativecommand.js +++ b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativecommand.js @@ -22,8 +22,7 @@ export default class MediaImageTextAlternativeCommand extends Command { this.editor.model.document.selection, ); this.isEnabled = - !!drupalMediaElement && - drupalMediaElement.getAttribute('drupalMediaIsImage') && + drupalMediaElement?.getAttribute('drupalMediaIsImage') && drupalMediaElement.getAttribute('drupalMediaIsImage') !== METADATA_ERROR; if (this.isEnabled) { diff --git a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativeui.js b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativeui.js index 151fb2a3029..6ab77c7b60f 100644 --- a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativeui.js +++ b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/mediaimagetextalternativeui.js @@ -2,7 +2,8 @@ /* cspell:ignore imagetextalternative mediaimagetextalternative */ /* cspell:ignore mediaimagetextalternativeediting textalternativeformview */ -import { Plugin, icons } from 'ckeditor5/src/core'; +import { Plugin } from 'ckeditor5/src/core'; +import { IconLowVision } from '@ckeditor/ckeditor5-icons'; import { ButtonView, ContextualBalloon, @@ -66,7 +67,7 @@ export default class MediaImageTextAlternativeUi extends Plugin { view.set({ label: Drupal.t('Override media image alternative text'), - icon: icons.lowVision, + icon: IconLowVision, tooltip: true, }); diff --git a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/ui/textalternativeformview.js b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/ui/textalternativeformview.js index 4ba0020a2e1..b6702acbab9 100644 --- a/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/ui/textalternativeformview.js +++ b/core/modules/ckeditor5/js/ckeditor5_plugins/drupalMedia/src/mediaimagetextalternative/ui/textalternativeformview.js @@ -14,7 +14,7 @@ import { Template, } from 'ckeditor5/src/ui'; import { FocusTracker, KeystrokeHandler } from 'ckeditor5/src/utils'; -import { icons } from 'ckeditor5/src/core'; +import { IconCheck, IconCancel } from '@ckeditor/ckeditor5-icons'; export default class TextAlternativeFormView extends View { /** @@ -50,7 +50,7 @@ export default class TextAlternativeFormView extends View { */ this.saveButtonView = this._createButton( Drupal.t('Save'), - icons.check, + IconCheck, 'ck-button-save', ); this.saveButtonView.type = 'submit'; @@ -60,7 +60,7 @@ export default class TextAlternativeFormView extends View { */ this.cancelButtonView = this._createButton( Drupal.t('Cancel'), - icons.cancel, + IconCancel, 'ck-button-cancel', 'cancel', ); diff --git a/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Alignment.php b/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Alignment.php index b74b08fb347..c1eb1eebfeb 100644 --- a/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Alignment.php +++ b/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Alignment.php @@ -117,7 +117,7 @@ class Alignment extends CKEditor5PluginDefault implements CKEditor5PluginConfigu $subset = HTMLRestrictions::fromString(implode($all_elements)); foreach ($plugin_definition->getCKEditor5Config()['alignment']['options'] as $configured_alignment) { if (!in_array($configured_alignment['name'], $enabled_alignments, TRUE)) { - $element_string = '<$text-container class=' . '"' . $configured_alignment["className"] . '"' . '>'; + $element_string = '<$text-container class="' . $configured_alignment["className"] . '">'; $subset = $subset->diff(HTMLRestrictions::fromString($element_string)); } } diff --git a/core/modules/ckeditor5/src/Plugin/CKEditor5PluginDefinition.php b/core/modules/ckeditor5/src/Plugin/CKEditor5PluginDefinition.php index 8ef1e8aea44..8723ae266ca 100644 --- a/core/modules/ckeditor5/src/Plugin/CKEditor5PluginDefinition.php +++ b/core/modules/ckeditor5/src/Plugin/CKEditor5PluginDefinition.php @@ -52,6 +52,36 @@ final class CKEditor5PluginDefinition extends PluginDefinition implements Plugin throw new \InvalidArgumentException(sprintf('Property %s with value %s does not exist on %s.', $property, $value, __CLASS__)); } } + + // In version CKEditor5 45.0.0, the icons were renamed, so if any + // drupalElementStyles are specifying icons, deprecate use of the old names + // and provide a mapping for backwards compatibility. + // @see https://ckeditor.com/docs/ckeditor5/latest/updating/guides/changelog.html#new-installation-methods-improvements-icons-replacement + // @see https://github.com/ckeditor/ckeditor5/blob/v44.3.0/packages/ckeditor5-core/src/index.ts + // @see https://github.com/ckeditor/ckeditor5/blob/v45.0.0/packages/ckeditor5-icons/src/index.ts + if (!isset($this->ckeditor5) || !isset($this->ckeditor5['config']['drupalElementStyles']) || !is_array($this->ckeditor5['config']['drupalElementStyles'])) { + return; + } + + foreach ($this->ckeditor5['config']['drupalElementStyles'] as $group_id => &$groups) { + if (!is_array($groups)) { + continue; + } + + foreach ($groups as &$style) { + if (is_array($style) && isset($style['icon']) && is_string($style['icon']) && !preg_match('/^(<svg)|(Icon)/', $style['icon'])) { + $deprecated_icon = $style['icon']; + $style['icon'] = match ($deprecated_icon) { + 'objectLeft' => 'IconObjectInlineLeft', + 'objectRight' => 'IconObjectInlineRight', + 'objectBlockLeft' => 'IconObjectLeft', + 'objectBlockRight' => 'IconObjectRight', + default => 'Icon' . ucfirst($style['icon']) + }; + @trigger_error(sprintf('The icon configuration value "%s" in drupalElementStyles group %s for CKEditor5 plugin %s is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Try using "%s" instead. See https://www.drupal.org/node/3528806', $deprecated_icon, $group_id, $this->id(), $style['icon']), E_USER_DEPRECATED); + } + } + } } /** diff --git a/core/modules/ckeditor5/tests/modules/ckeditor5_icon_deprecation_test/ckeditor5_icon_deprecation_test.ckeditor5.yml b/core/modules/ckeditor5/tests/modules/ckeditor5_icon_deprecation_test/ckeditor5_icon_deprecation_test.ckeditor5.yml new file mode 100644 index 00000000000..8ee35f2a3e9 --- /dev/null +++ b/core/modules/ckeditor5/tests/modules/ckeditor5_icon_deprecation_test/ckeditor5_icon_deprecation_test.ckeditor5.yml @@ -0,0 +1,46 @@ +# This plugin is for testing deprecation of CKEditor 5 icon names before version 45. +ckeditor5_icon_deprecation_test_plugin: + ckeditor5: + plugins: [] + config: + drupalElementStyles: + align: + # This is a valid icon name. + - name: 'IconObjectCenter' + title: 'Icon object center' + icon: IconObjectCenter + modelElements: ['drupalMedia'] + # The next four are deprecated icon names with specifically mapped to v45 icon names. + - name: 'objectBlockLeft' + title: 'Object block left' + icon: objectBlockLeft + modelElements: ['drupalMedia'] + - name: 'objectBlockRight' + title: 'Object block right' + icon: objectBlockRight + modelElements: [ 'drupalMedia' ] + - name: 'objectLeft' + title: 'Object left' + icon: objectLeft + modelElements: [ 'drupalMedia' ] + - name: 'objectRight' + title: 'Object right' + icon: objectRight + modelElements: [ 'drupalMedia' ] + svg: + # Icon set as SVG XML. + - name: 'svg' + title: 'SVG' + icon: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M2 3h16v1.5H2zm0 12h16v1.5H2z"/><path d="M18.003 7v5.5a1 1 0 0 1-1 1H8.996a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h8.007a1 1 0 0 1 1 1zm-1.506.5H9.5V12h6.997V7.5z"/></svg>' + modelElements: [ 'drupalMedia' ] + threeVerticalDots: + # This is a deprecated icon name mapped with general rule 'exampleName' -> 'IconExampleName'. + - name: 'threeVerticalDots' + title: 'Three vertical dots' + icon: threeVerticalDots + modelElements: [ 'drupalMedia' ] + + drupal: + label: Deprecated icons + elements: + - <drupal-media> diff --git a/core/modules/ckeditor5/tests/modules/ckeditor5_icon_deprecation_test/ckeditor5_icon_deprecation_test.info.yml b/core/modules/ckeditor5/tests/modules/ckeditor5_icon_deprecation_test/ckeditor5_icon_deprecation_test.info.yml new file mode 100644 index 00000000000..d9ebea174b6 --- /dev/null +++ b/core/modules/ckeditor5/tests/modules/ckeditor5_icon_deprecation_test/ckeditor5_icon_deprecation_test.info.yml @@ -0,0 +1,7 @@ +name: CKEditor icon deprecation test +type: module +description: "Provides test CKEditor5 plugin with deprecated Drupal element styles icon config" +package: Testing +version: VERSION +dependencies: + - ckeditor5:ckeditor5 diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/AdminUiTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/AdminUiTest.php index d380114dfe7..402aaf17158 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/AdminUiTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/AdminUiTest.php @@ -4,14 +4,15 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; -// cspell:ignore sourceediting xmlhttprequest +use PHPUnit\Framework\Attributes\Group; +// cspell:ignore sourceediting xmlhttprequest /** * Tests for CKEditor 5 in the admin UI. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class AdminUiTest extends CKEditor5TestBase { /** diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5AllowedTagsTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5AllowedTagsTest.php index ca9cb17ca62..d2187c7ce01 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5AllowedTagsTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5AllowedTagsTest.php @@ -7,16 +7,16 @@ namespace Drupal\Tests\ckeditor5\FunctionalJavascript; use Drupal\Core\Entity\Entity\EntityViewMode; use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Yaml\Yaml; // cspell:ignore esque imageUpload sourceediting Editing's - /** * Tests for CKEditor 5. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class CKEditor5AllowedTagsTest extends CKEditor5TestBase { /** diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5CodeSyntaxTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5CodeSyntaxTest.php index a0d16cfb2c3..ad9feb4d19b 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5CodeSyntaxTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5CodeSyntaxTest.php @@ -7,13 +7,14 @@ namespace Drupal\Tests\ckeditor5\FunctionalJavascript; use Behat\Mink\Element\NodeElement; use Drupal\editor\Entity\Editor; use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests code block configured languages are respected. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class CKEditor5CodeSyntaxTest extends CKEditor5TestBase { use CKEditor5TestTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5DialogTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5DialogTest.php index 734bc3b430c..599a6b68b29 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5DialogTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5DialogTest.php @@ -9,14 +9,15 @@ use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; /** * Tests for CKEditor 5 to ensure correct focus management in dialogs. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class CKEditor5DialogTest extends CKEditor5TestBase { use CKEditor5TestTrait; @@ -81,7 +82,7 @@ class CKEditor5DialogTest extends CKEditor5TestBase { // Make sure the input field can have focus and we can type into it. $input->setValue($link_url); // Save the new link. - $page->find('css', '.ck-balloon-panel .ck-button-save')->click(); + $page->find('xpath', "//button[span[text()='Insert']]")->click(); // Make sure something was added to the text. $this->assertNotEmpty($content_area->getText()); } diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5FragmentLinkTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5FragmentLinkTest.php index fc36e1799bd..c04c3349186 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5FragmentLinkTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5FragmentLinkTest.php @@ -14,13 +14,14 @@ use Drupal\node\Entity\NodeType; use Drupal\Tests\TestFileCreationTrait; use Drupal\user\Entity\User; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; /** * Tests that the fragment link points to CKEditor 5. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class CKEditor5FragmentLinkTest extends WebDriverTestBase { use TestFileCreationTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5HeightTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5HeightTest.php new file mode 100644 index 00000000000..12e54868a8a --- /dev/null +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5HeightTest.php @@ -0,0 +1,138 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\ckeditor5\FunctionalJavascript; + +use Drupal\editor\Entity\Editor; +use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests ckeditor height respects field rows config. + * + * @internal + */ +#[Group('ckeditor5')] +class CKEditor5HeightTest extends CKEditor5TestBase { + + use CKEditor5TestTrait; + + /** + * Tests editor height respects rows config. + */ + public function testCKEditor5Height(): void { + $this->addNewTextFormat(); + /** @var \Drupal\editor\Entity\Editor $editor */ + $editor = Editor::load('ckeditor5'); + $editor->setSettings([ + 'toolbar' => [ + 'items' => [ + 'sourceEditing', + ], + ], + 'plugins' => [ + 'ckeditor5_sourceEditing' => [ + 'allowed_tags' => [], + ], + ], + ])->save(); + $this->drupalGet('/node/add/page'); + $this->waitForEditor(); + + // We expect height to be 320, but test to ensure that it's greater + // than 300. We want to ensure that we don't hard code a very specific + // value because tests might break if styles change (line-height, etc). + // Note that the default height for CKEditor5 is 47px. + $this->assertGreaterThan(300, $this->getEditorHeight()); + // Check source editing height. + $this->pressEditorButton('Source'); + $assert = $this->assertSession(); + $this->assertNotNull($assert->waitForElementVisible('css', '.ck-source-editing-area')); + $this->assertGreaterThan(300, $this->getEditorHeight(TRUE)); + + // Test the max height of the editor is less that the window height. + $body = \str_repeat('<p>Llamas are cute.</p>', 100); + $node = $this->drupalCreateNode([ + 'body' => $body, + ]); + $this->drupalGet($node->toUrl('edit-form')); + $this->assertLessThan($this->getWindowHeight(), $this->getEditorHeight()); + + // Check source editing has a scroll bar. + $this->pressEditorButton('Source'); + $this->assertNotNull($assert->waitForElementVisible('css', '.ck-source-editing-area')); + $this->assertTrue($this->isSourceEditingScrollable()); + + // Double the editor row count. + \Drupal::service('entity_display.repository')->getFormDisplay('node', 'page') + ->setComponent('body', [ + 'type' => 'text_textarea_with_summary', + 'settings' => [ + 'rows' => 18, + ], + ]) + ->save(); + // Check the height of the editor again. + $this->drupalGet('/node/add/page'); + $this->waitForEditor(); + // We expect height to be 640, but test to ensure that it's greater + // than 600. We want to ensure that we don't hard code a very specific + // value because tests might break if styles change (line-height, etc). + // Note that the default height for CKEditor5 is 47px. + $this->assertGreaterThan(600, $this->getEditorHeight()); + } + + /** + * Gets the height of ckeditor. + */ + private function getEditorHeight(bool $sourceEditing = FALSE): int { + $selector = $sourceEditing ? '.ck-source-editing-area' : '.ck-editor__editable'; + $javascript = <<<JS + return document.querySelector('$selector').clientHeight; + JS; + return $this->getSession()->evaluateScript($javascript); + } + + /** + * Gets the window height. + */ + private function getWindowHeight(): int { + $javascript = <<<JS + return window.innerHeight; + JS; + return $this->getSession()->evaluateScript($javascript); + } + + /** + * Checks that the source editing element is scrollable. + */ + private function isSourceEditingScrollable(): bool { + $javascript = <<<JS + (function () { + const element = document.querySelector('.ck-source-editing-area textarea'); + const style = window.getComputedStyle(element); + if ( + element.scrollHeight > element.clientHeight && + style.overflow !== 'hidden' && + style['overflow-y'] !== 'hidden' && + style.overflow !== 'clip' && + style['overflow-y'] !== 'clip' + ) { + if ( + element === document.scrollingElement || + (style.overflow !== 'visible' && + style['overflow-y'] !== 'visible') + ) { + return true; + } + } + + return false; + })(); + JS; + $evaluateScript = $this->getSession()->evaluateScript($javascript); + return $evaluateScript; + } + +} diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5MarkupTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5MarkupTest.php index 3557f2df244..c673f1e43f4 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5MarkupTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5MarkupTest.php @@ -12,16 +12,16 @@ use Drupal\node\Entity\Node; use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; use Drupal\Tests\TestFileCreationTrait; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; // cspell:ignore esque māori sourceediting splitbutton upcasted - /** * Tests for CKEditor 5. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class CKEditor5MarkupTest extends CKEditor5TestBase { use TestFileCreationTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5OffCanvasTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5OffCanvasTest.php index f52ebb874c5..fd65022c5c2 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5OffCanvasTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5OffCanvasTest.php @@ -4,12 +4,14 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; +use PHPUnit\Framework\Attributes\Group; + /** * Tests for CKEditor 5 to ensure correct styling in off-canvas. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class CKEditor5OffCanvasTest extends CKEditor5TestBase { /** diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5ReadOnlyModeTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5ReadOnlyModeTest.php index c52ae83c911..fef1e9db8c2 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5ReadOnlyModeTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5ReadOnlyModeTest.php @@ -6,13 +6,14 @@ namespace Drupal\Tests\ckeditor5\FunctionalJavascript; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; +use PHPUnit\Framework\Attributes\Group; /** * Tests read-only mode for CKEditor 5. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class CKEditor5ReadOnlyModeTest extends CKEditor5TestBase { /** diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php index 10a32513fb2..45798b4d3c6 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5Test.php @@ -14,16 +14,16 @@ use Drupal\node\Entity\Node; use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; use Drupal\Tests\TestFileCreationTrait; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; // cspell:ignore esque māori sourceediting splitbutton upcasted - /** * Tests for CKEditor 5. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class CKEditor5Test extends CKEditor5TestBase { use TestFileCreationTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5ToolbarTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5ToolbarTest.php index 3cea9188509..5d6905ff2a0 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5ToolbarTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5ToolbarTest.php @@ -9,14 +9,15 @@ use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\user\Entity\User; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; /** * Tests for CKEditor 5 editor UI with Toolbar module. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class CKEditor5ToolbarTest extends WebDriverTestBase { /** diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/EmphasisTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/EmphasisTest.php index e5c772e93e9..d8dda21e28e 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/EmphasisTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/EmphasisTest.php @@ -9,6 +9,7 @@ use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; /** @@ -17,9 +18,9 @@ use Symfony\Component\Validator\ConstraintViolationInterface; * CKEditor's use of <i> is converted to <em> in Drupal, so additional coverage * is provided here to verify successful conversion. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class EmphasisTest extends WebDriverTestBase { use CKEditor5TestTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTest.php index 79c37f0c6e2..ea1b61b8416 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTest.php @@ -4,14 +4,19 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; -// cspell:ignore imageresize imageupload +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +// cspell:ignore imageresize imageupload /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image - * @group ckeditor5 - * @group #slow + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image. + * * @internal */ +#[CoversClass(Image::class)] +#[Group('ckeditor5')] +#[Group('#slow')] class ImageTest extends ImageTestTestBase { use ImageTestBaselineTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBase.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBase.php index 427a74a7054..cf9c14b2963 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBase.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBase.php @@ -4,16 +4,20 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; -use Drupal\Tests\TestFileCreationTrait; +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image; use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; +use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore imageresize - /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image - * @group ckeditor5 + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image. + * * @internal */ +#[CoversClass(Image::class)] +#[Group('ckeditor5')] abstract class ImageTestBase extends CKEditor5TestBase { use CKEditor5TestTrait; @@ -92,7 +96,7 @@ abstract class ImageTestBase extends CKEditor5TestBase { $dialog = $page->find('css', '.ck-dialog'); $src_input = $dialog->find('css', '.ck-image-insert-url input[type=text]'); $src_input->setValue($src); - $dialog->find('xpath', "//button[span[text()='Accept']]")->click(); + $dialog->find('xpath', "//button[span[text()='Insert']]")->click(); // Wait for the image to be uploaded and rendered by CKEditor 5. $this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '.ck-widget.image > img[src="' . $src . '"]')); } diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBaselineTrait.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBaselineTrait.php index 2e0d3e6e3ce..4cc94a97cde 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBaselineTrait.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBaselineTrait.php @@ -186,10 +186,10 @@ trait ImageTestBaselineTrait { $link_image_button->press(); // Assert structure of link form balloon. $balloon = $this->assertVisibleBalloon('.ck-link-form'); - $url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text'); - // Fill in link form balloon's <input> and hit "Save". + $url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text[inputmode=url]'); + // Fill in link form balloon's <input> and hit "Insert". $url_input->setValue('http://www.drupal.org/association'); - $balloon->pressButton('Save'); + $balloon->pressButton('Insert'); // Assert the "editingDowncast" HTML after making changes. First assert // the link exists, then assert the expected DOM structure in detail. diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestProviderTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestProviderTest.php index 82a5c503b76..932e0ad1a75 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestProviderTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestProviderTest.php @@ -4,12 +4,18 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image - * @group ckeditor5 - * @group #slow + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image. + * * @internal */ +#[CoversClass(Image::class)] +#[Group('ckeditor5')] +#[Group('#slow')] class ImageTestProviderTest extends ImageTestTestBase { use ImageTestProviderTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestProviderTrait.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestProviderTrait.php index 2588cce74b0..fc16afe6cdb 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestProviderTrait.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestProviderTrait.php @@ -6,6 +6,7 @@ namespace Drupal\Tests\ckeditor5\FunctionalJavascript; use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; +use PHPUnit\Framework\Attributes\DataProvider; // cspell:ignore imageresize @@ -18,9 +19,8 @@ trait ImageTestProviderTrait { * Tests that alt text is required for images. * * @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion - * - * @dataProvider providerAltTextRequired */ + #[DataProvider('providerAltTextRequired')] public function testAltTextRequired(bool $unrestricted): void { // Disable filter_html. if ($unrestricted) { @@ -122,9 +122,8 @@ trait ImageTestProviderTrait { /** * Tests alignment integration. - * - * @dataProvider providerAlignment */ + #[DataProvider('providerAlignment')] public function testAlignment(string $image_type): void { $assert_session = $this->assertSession(); $page = $this->getSession()->getPage(); @@ -198,9 +197,8 @@ trait ImageTestProviderTrait { * * @param string $width * The width input for the image. - * - * @dataProvider providerWidth */ + #[DataProvider('providerWidth')] public function testWidth(string $width): void { $page = $this->getSession()->getPage(); $assert_session = $this->assertSession(); @@ -264,9 +262,8 @@ trait ImageTestProviderTrait { * * @param bool $is_resize_enabled * Boolean flag to test enabled or disabled. - * - * @dataProvider providerResize */ + #[DataProvider('providerResize')] public function testResize(bool $is_resize_enabled): void { // Disable resize plugin because it is enabled by default. if (!$is_resize_enabled) { diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestTestBase.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestTestBase.php index 070ae0e90ca..a6230938d12 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestTestBase.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestTestBase.php @@ -13,12 +13,9 @@ use Symfony\Component\Validator\ConstraintViolationInterface; // cspell:ignore imageresize imageupload /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image - * @group ckeditor5 - * @group #slow * @internal */ -class ImageTestTestBase extends ImageTestBase { +abstract class ImageTestTestBase extends ImageTestBase { /** * The sample image File entity to embed. diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlProviderTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlProviderTest.php index 9ddcf4eec3e..6ce56094bb7 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlProviderTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlProviderTest.php @@ -4,12 +4,18 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image - * @group ckeditor5 - * @group #slow + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image. + * * @internal */ +#[CoversClass(Image::class)] +#[Group('ckeditor5')] +#[Group('#slow')] class ImageUrlProviderTest extends ImageUrlTestBase { use ImageTestProviderTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTest.php index 949b4d04ed2..5812b9c05bb 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTest.php @@ -4,12 +4,18 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image - * @group ckeditor5 - * @group #slow + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image. + * * @internal */ +#[CoversClass(Image::class)] +#[Group('ckeditor5')] +#[Group('#slow')] class ImageUrlTest extends ImageUrlTestBase { use ImageTestBaselineTrait; @@ -30,7 +36,7 @@ class ImageUrlTest extends ImageUrlTestBase { $dialog = $page->find('css', '.ck-dialog'); $src_input = $dialog->find('css', '.ck-image-insert-url input[type=text]'); $src_input->setValue($src); - $dialog->find('xpath', "//button[span[text()='Accept']]")->click(); + $dialog->find('xpath', "//button[span[text()='Insert']]")->click(); $this->assertNotEmpty($assert_session->waitForElementVisible('css', $image_selector)); $this->click($image_selector); diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTestBase.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTestBase.php index 5d0d6c50c82..1638a44cd28 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTestBase.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTestBase.php @@ -12,12 +12,9 @@ use Symfony\Component\Validator\ConstraintViolationInterface; // cspell:ignore imageresize /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Image - * @group ckeditor5 - * @group #slow * @internal */ -class ImageUrlTestBase extends ImageTestBase { +abstract class ImageUrlTestBase extends ImageTestBase { /** * {@inheritdoc} diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/JSTranslationTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/JSTranslationTest.php index fe74ff25997..09debb23b16 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/JSTranslationTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/JSTranslationTest.php @@ -6,15 +6,15 @@ namespace Drupal\Tests\ckeditor5\FunctionalJavascript; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\media\Traits\MediaTypeCreationTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore drupalmediatoolbar - /** * Tests for CKEditor 5 plugins using Drupal's translation system. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class JSTranslationTest extends CKEditor5TestBase { use MediaTypeCreationTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/LanguageTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/LanguageTest.php index 47ed0b6569b..f57ab070f89 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/LanguageTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/LanguageTest.php @@ -5,15 +5,16 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; use Drupal\language\Entity\ConfigurableLanguage; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore คำพูดบล็อก sourceediting - /** * Tests for CKEditor 5 UI translations. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class LanguageTest extends CKEditor5TestBase { /** @@ -33,9 +34,8 @@ class LanguageTest extends CKEditor5TestBase { * The CKEditor 5 plugin to enable. * @param string $toolbar_item_translation * The expected translation for CKEditor 5 plugin toolbar button. - * - * @dataProvider provider */ + #[DataProvider('provider')] public function test(string $langcode, string $toolbar_item_name, string $toolbar_item_translation): void { $page = $this->getSession()->getPage(); $assert_session = $this->assertSession(); diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLibraryTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLibraryTest.php index c95baf47e0c..87672972834 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLibraryTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLibraryTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\MediaLibrary; use Drupal\ckeditor5\Plugin\Editor\CKEditor5; use Drupal\editor\Entity\Editor; use Drupal\file\Entity\File; @@ -13,15 +14,18 @@ use Drupal\media\Entity\Media; use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; use Drupal\Tests\media\Traits\MediaTypeCreationTrait; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; // cspell:ignore arrakis complote détruire harkonnen - /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\MediaLibrary - * @group ckeditor5 + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\MediaLibrary. + * * @internal */ +#[CoversClass(MediaLibrary::class)] +#[Group('ckeditor5')] class MediaLibraryTest extends WebDriverTestBase { use MediaTypeCreationTrait; diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLinkabilityTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLinkabilityTest.php index f7580f8847a..c80cb5fd26d 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLinkabilityTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLinkabilityTest.php @@ -4,23 +4,28 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media; +use Drupal\ckeditor5\Plugin\Editor\CKEditor5; use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; -use Drupal\ckeditor5\Plugin\Editor\CKEditor5; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media - * @group ckeditor5 + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media. + * * @internal */ +#[CoversClass(Media::class)] +#[Group('ckeditor5')] class MediaLinkabilityTest extends MediaTestBase { /** * Ensures arbitrary attributes can be added on links wrapping media via GHS. - * - * @dataProvider providerLinkability */ + #[DataProvider('providerLinkability')] public function testLinkedMediaArbitraryHtml(bool $unrestricted): void { $assert_session = $this->assertSession(); @@ -84,9 +89,8 @@ class MediaLinkabilityTest extends MediaTestBase { * "dataDowncast" results. These are CKEditor 5 concepts. * * @see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/architecture/editing-engine.html#conversion - * - * @dataProvider providerLinkability */ + #[DataProvider('providerLinkability')] public function testLinkability(bool $unrestricted): void { // Disable filter_html. if ($unrestricted) { @@ -134,10 +138,10 @@ class MediaLinkabilityTest extends MediaTestBase { $link_media_button->press(); // Assert structure of link form balloon. $balloon = $this->assertVisibleBalloon('.ck-link-form'); - $url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text'); - // Fill in link form balloon's <input> and hit "Save". + $url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text[inputmode=url]'); + // Fill in link form balloon's <input> and hit "Insert". $url_input->setValue('http://linking-embedded-media.com'); - $balloon->pressButton('Save'); + $balloon->pressButton('Insert'); // Assert the "editingDowncast" HTML after making changes. Assert the link // exists, then assert the link exists. Then assert the expected DOM @@ -235,9 +239,8 @@ class MediaLinkabilityTest extends MediaTestBase { /** * Ensure that manual link decorators work with linkable media. - * - * @dataProvider providerLinkability */ + #[DataProvider('providerLinkability')] public function testLinkManualDecorator(bool $unrestricted): void { \Drupal::service('module_installer')->install(['ckeditor5_manual_decorator_test']); $this->resetAll(); @@ -272,13 +275,15 @@ class MediaLinkabilityTest extends MediaTestBase { $this->getBalloonButton('Link media')->click(); $balloon = $this->assertVisibleBalloon('.ck-link-form'); - $url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text'); + $url_input = $balloon->find('css', '.ck-labeled-field-view__input-wrapper .ck-input-text[inputmode=url]'); $url_input->setValue('http://linking-embedded-media.com'); + $balloon->pressButton('Insert'); + $this->getBalloonButton('Link properties')->click(); $this->getBalloonButton($decorator)->click(); - $balloon->pressButton('Save'); + $this->getBalloonButton('Back')->click(); $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.drupal-media a')); - $this->assertVisibleBalloon('.ck-link-actions'); + $this->assertVisibleBalloon('.ck-link-toolbar'); $xpath = new \DOMXPath($this->getEditorDataAsDom()); $this->assertNotEmpty($xpath->query("//a[@href='http://linking-embedded-media.com']$decorator_attributes")); @@ -296,7 +301,7 @@ class MediaLinkabilityTest extends MediaTestBase { $drupalmedia->click(); $this->assertVisibleBalloon('.ck-toolbar[aria-label="Drupal Media toolbar"]'); $this->getBalloonButton('Link media')->click(); - $this->assertVisibleBalloon('.ck-link-actions'); + $this->assertVisibleBalloon('.ck-link-toolbar'); $this->getBalloonButton('Unlink')->click(); $this->assertTrue($assert_session->waitForElementRemoved('css', '.drupal-media a')); diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaPreviewTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaPreviewTest.php index 80e6d94373c..bcf930530a1 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaPreviewTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaPreviewTest.php @@ -4,18 +4,23 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media; use Drupal\Core\Database\Database; use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\filter\Entity\FilterFormat; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore drupalmediaediting - /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media - * @group ckeditor5 - * @group #slow + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media. + * * @internal */ +#[CoversClass(Media::class)] +#[Group('ckeditor5')] +#[Group('#slow')] class MediaPreviewTest extends MediaTestBase { /** @@ -138,9 +143,8 @@ class MediaPreviewTest extends MediaTestBase { * Whether to test with media_embed filter enabled on the text format. * @param bool $can_use_format * Whether the logged in user is allowed to use the text format. - * - * @dataProvider previewAccessProvider */ + #[DataProvider('previewAccessProvider')] public function testEmbedPreviewAccess($media_embed_enabled, $can_use_format): void { // Reconfigure the host entity's text format to suit our needs. /** @var \Drupal\filter\FilterFormatInterface $format */ diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaTest.php index 114704afb7f..931a461b41d 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaTest.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; +use Drupal\ckeditor5\Plugin\Editor\CKEditor5; use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\Core\Entity\Entity\EntityViewMode; -use Drupal\ckeditor5\Plugin\Editor\CKEditor5; use Drupal\editor\Entity\Editor; use Drupal\field\Entity\FieldConfig; use Drupal\filter\Entity\FilterFormat; @@ -15,17 +15,21 @@ use Drupal\language\Entity\ContentLanguageSettings; use Drupal\media\Entity\Media; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; // cspell:ignore alternatif drupalelementstyle hurlant layercake tatou texte // cspell:ignore zartan - /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media - * @group ckeditor5 - * @group #slow + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media. + * * @internal */ +#[CoversClass(\Drupal\ckeditor5\Plugin\CKEditor5Plugin\Media::class)] +#[Group('ckeditor5')] +#[Group('#slow')] class MediaTest extends MediaTestBase { /** @@ -332,7 +336,7 @@ class MediaTest extends MediaTestBase { $this->assertNotEmpty($assert_session->waitForElement('css', '.drupal-media figcaption.ck-editor__nested-editable')); $this->pressEditorButton('Link'); $this->assertVisibleBalloon('.ck-link-form'); - $link_input = $page->find('css', '.ck-balloon-panel .ck-link-form input[type=text]'); + $link_input = $page->find('css', '.ck-balloon-panel .ck-link-form input[type=text][inputmode=url]'); $link_input->setValue('https://example.com'); $page->find('css', '.ck-balloon-panel .ck-link-form button[type=submit]')->click(); $this->assertNotEmpty($assert_session->waitForElement('css', '.drupal-media figcaption > a')); @@ -759,9 +763,8 @@ class MediaTest extends MediaTestBase { * Tests that view mode is reflected onto the CKEditor 5 Widget wrapper, that * the media style toolbar allows changing the view mode and that the changes * are reflected on the widget and downcast drupal-media tag. - * - * @dataProvider providerTestViewMode */ + #[DataProvider('providerTestViewMode')] public function testViewMode(bool $with_alignment): void { EntityViewMode::create([ 'id' => 'media.view_mode_3', diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/SourceEditingTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/SourceEditingTest.php index 31e6d07ca31..2b2a8484870 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/SourceEditingTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/SourceEditingTest.php @@ -5,23 +5,29 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; use Drupal\ckeditor5\HTMLRestrictions; +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing; +use Drupal\ckeditor5\Plugin\Editor\CKEditor5; use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; -use Drupal\ckeditor5\Plugin\Editor\CKEditor5; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; // cspell:ignore gramma sourceediting - /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing - * @covers \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig - * @group ckeditor5 + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing. + * * @internal + * @legacy-covers \Drupal\ckeditor5\Plugin\CKEditor5PluginManager::getCKEditor5PluginConfig */ +#[CoversClass(SourceEditing::class)] +#[Group('ckeditor5')] class SourceEditingTest extends SourceEditingTestBase { /** - * @covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing::buildConfigurationForm + * Tests source editing settings form. + * + * @legacy-covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\SourceEditing::buildConfigurationForm */ public function testSourceEditingSettingsForm(): void { $this->drupalLogin($this->drupalCreateUser(['administer filters'])); diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/StyleTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/StyleTest.php index e295090d7c1..5e6b90a5d8f 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/StyleTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/StyleTest.php @@ -4,25 +4,31 @@ declare(strict_types=1); namespace Drupal\Tests\ckeditor5\FunctionalJavascript; -// cspell:ignore sourceediting - +use Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style; use Drupal\ckeditor5\Plugin\Editor\CKEditor5; use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; +// cspell:ignore sourceediting +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; /** - * @coversDefaultClass \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style - * @group ckeditor5 + * Tests Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style. + * * @internal */ +#[CoversClass(Style::class)] +#[Group('ckeditor5')] class StyleTest extends CKEditor5TestBase { use CKEditor5TestTrait; /** - * @covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style::buildConfigurationForm + * Tests style settings form. + * + * @legacy-covers \Drupal\ckeditor5\Plugin\CKEditor5Plugin\Style::buildConfigurationForm */ public function testStyleSettingsForm(): void { $this->drupalLogin($this->drupalCreateUser(['administer filters'])); @@ -658,7 +664,7 @@ JS; // 6. the `reliable` class has been added to the `<a>` // 7. The `deep-dive` class has been added to the `<div>` // 8. The `caution` class has been added to the `<caption>` - $this->assertSame('<h2 class="red-heading">Upgrades</h2><p>Drupal has historically been difficult to upgrade from one major version to the next.</p><p class="highlighted interesting">This changed with Drupal 8.</p><blockquote class="famous"><p>Updating from Drupal 8\'s latest version to Drupal 9.0.0 should be as easy as updating between minor versions of Drupal 8.</p></blockquote><p>— <a class="reliable" href="https://dri.es/making-drupal-upgrades-easy-forever">Dries</a></p><div class="deep-dive"><ul class="items"><li>Update Drupal core using Composer</li><li>Update Drupal core manually</li><li>Update Drupal core using Drush</li></ul><ol class="steps"><li>Back up your files and database</li><li>Put your site into maintenance mode</li><li>Update the code and apply changes</li><li>Deactivate maintenance mode</li></ol><table class="data-analysis"><caption class="caution">Drupal upgrades are now easy, with a few caveats.</caption><tbody><tr><td>First</td><td>Second</td></tr><tr><td>Data value 1</td><td>Data value 2</td></tr></tbody></table></div>', $this->getEditorDataAsHtmlString()); + $this->assertSame('<h2 class="red-heading">Upgrades</h2><p>Drupal has historically been difficult to upgrade from one major version to the next.</p><p class="highlighted interesting">This changed with Drupal 8.</p><blockquote class="famous"><p>Updating from Drupal 8\'s latest version to Drupal 9.0.0 should be as easy as updating between minor versions of Drupal 8.</p></blockquote><p>— <a class="reliable" href="https://dri.es/making-drupal-upgrades-easy-forever">Dries</a></p><div class="deep-dive"><ul class="items"><li>Update Drupal core using Composer</li><li>Update Drupal core manually</li><li>Update Drupal core using Drush</li></ul><ol class="steps"><li>Back up your files and database</li><li>Put your site into maintenance mode</li><li>Update the code and apply changes</li><li>Deactivate maintenance mode</li></ol><table class="table data-analysis"><caption class="caution">Drupal upgrades are now easy, with a few caveats.</caption><tbody><tr><td>First</td><td>Second</td></tr><tr><td>Data value 1</td><td>Data value 2</td></tr></tbody></table></div>', $this->getEditorDataAsHtmlString()); } } diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/TableTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/TableTest.php index 1d3dadbe4c3..0dc9e847bdd 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/TableTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/TableTest.php @@ -9,14 +9,15 @@ use Drupal\editor\Entity\Editor; use Drupal\filter\Entity\FilterFormat; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\ConstraintViolationInterface; /** * For testing the table plugin. * - * @group ckeditor5 * @internal */ +#[Group('ckeditor5')] class TableTest extends WebDriverTestBase { use CKEditor5TestTrait; diff --git a/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php b/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php index 84e26e78388..731e777749e 100644 --- a/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php +++ b/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php @@ -18,6 +18,7 @@ use Drupal\filter\Entity\FilterFormat; use Drupal\Tests\SchemaCheckTestTrait; use Drupal\TestTools\Random; use org\bovigo\vfs\vfsStream; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\ParameterBag\FrozenParameterBag; use Symfony\Component\Yaml\Yaml; @@ -2053,4 +2054,26 @@ PHP, ]; } + /** + * Tests deprecation and backwards compatibility of icon names. + */ + #[IgnoreDeprecations] + public function testDeprecatedIcons(): void { + $this->expectDeprecation('The icon configuration value "objectBlockLeft" in drupalElementStyles group align for CKEditor5 plugin ckeditor5_icon_deprecation_test_plugin is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Try using "IconObjectLeft" instead. See https://www.drupal.org/node/3528806'); + $this->expectDeprecation('The icon configuration value "objectBlockRight" in drupalElementStyles group align for CKEditor5 plugin ckeditor5_icon_deprecation_test_plugin is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Try using "IconObjectRight" instead. See https://www.drupal.org/node/3528806'); + $this->expectDeprecation('The icon configuration value "objectLeft" in drupalElementStyles group align for CKEditor5 plugin ckeditor5_icon_deprecation_test_plugin is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Try using "IconObjectInlineLeft" instead. See https://www.drupal.org/node/3528806'); + $this->expectDeprecation('The icon configuration value "objectRight" in drupalElementStyles group align for CKEditor5 plugin ckeditor5_icon_deprecation_test_plugin is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Try using "IconObjectInlineRight" instead. See https://www.drupal.org/node/3528806'); + $this->expectDeprecation('The icon configuration value "threeVerticalDots" in drupalElementStyles group threeVerticalDots for CKEditor5 plugin ckeditor5_icon_deprecation_test_plugin is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Try using "IconThreeVerticalDots" instead. See https://www.drupal.org/node/3528806'); + \Drupal::service('module_installer')->install(['ckeditor5_icon_deprecation_test']); + $definitions = \Drupal::service('plugin.manager.ckeditor5.plugin')->getDefinitions(); + $config = $definitions['ckeditor5_icon_deprecation_test_plugin']->toArray()['ckeditor5']['config']['drupalElementStyles']; + $this->assertSame('IconObjectCenter', $config['align'][0]['icon']); + $this->assertSame('IconObjectLeft', $config['align'][1]['icon']); + $this->assertSame('IconObjectRight', $config['align'][2]['icon']); + $this->assertSame('IconObjectInlineLeft', $config['align'][3]['icon']); + $this->assertSame('IconObjectInlineRight', $config['align'][4]['icon']); + $this->assertStringContainsString('<svg viewBox="0 0 20 20"', $config['svg'][0]['icon']); + $this->assertSame('IconThreeVerticalDots', $config['threeVerticalDots'][0]['icon']); + } + } diff --git a/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php b/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php index 8c87a78752e..75735a0cc51 100644 --- a/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php +++ b/core/modules/ckeditor5/tests/src/Kernel/ValidatorsTest.php @@ -965,7 +965,7 @@ class ValidatorsTest extends KernelTestBase { 'status' => TRUE, 'weight' => 0, 'settings' => [ - 'allowed_html' => "<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type='1 A I'> <li> <dl> <dt> <dd> <h2 id='jump-*'> <h3 id> <h4 id> <h5 id> <h6 id>" . "<p> <br>", + 'allowed_html' => "<a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type='1 A I'> <li> <dl> <dt> <dd> <h2 id='jump-*'> <h3 id> <h4 id> <h5 id> <h6 id> <p> <br>", 'filter_html_help' => TRUE, 'filter_html_nofollow' => TRUE, ], diff --git a/core/modules/ckeditor5/tests/src/Nightwatch/Tests/ckEditor5EditorHeightTest.js b/core/modules/ckeditor5/tests/src/Nightwatch/Tests/ckEditor5EditorHeightTest.js deleted file mode 100644 index a1b76110eb7..00000000000 --- a/core/modules/ckeditor5/tests/src/Nightwatch/Tests/ckEditor5EditorHeightTest.js +++ /dev/null @@ -1,241 +0,0 @@ -// cspell:ignore sourceediting - -module.exports = { - '@tags': ['core', 'ckeditor5'], - before(browser) { - browser - .drupalInstall({ installProfile: 'testing' }) - .drupalInstallModule('ckeditor5', true) - .drupalInstallModule('field_ui') - .drupalInstallModule('node', true); - - // Set fixed (desktop-ish) size to ensure a maximum viewport. - browser.window.resize(1920, 1080); - }, - after(browser) { - browser.drupalUninstall(); - }, - 'Ensure CKEditor respects field widget row value': (browser) => { - browser.drupalLoginAsAdmin(() => { - browser - // Create new input format. - .drupalRelativeURL('/admin/config/content/formats/add') - .waitForElementVisible('[data-drupal-selector="edit-name"]') - .updateValue('[data-drupal-selector="edit-name"]', 'test') - .waitForElementVisible('#edit-name-machine-name-suffix') - .click( - '[data-drupal-selector="edit-editor-editor"] option[value=ckeditor5]', - ) - // Wait for CKEditor 5 settings to be visible. - .waitForElementVisible( - '[data-drupal-selector="edit-editor-settings-toolbar"]', - ) - .click('.ckeditor5-toolbar-button-sourceEditing') // Select the Source Editing button. - // Hit the down arrow key to move it to the toolbar. - .perform(function () { - return this.actions().sendKeys(browser.Keys.ARROW_DOWN); - }) - // Wait for new source editing vertical tab to be present before continuing. - .waitForElementVisible( - '[href*=edit-editor-settings-plugins-ckeditor5-sourceediting]', - ) - .submitForm('input[type="submit"]') - .waitForElementVisible('[data-drupal-messages]') - .assert.textContains('[data-drupal-messages]', 'Added text format') - // Create new content type. - .drupalRelativeURL('/admin/structure/types/add') - .waitForElementVisible('[data-drupal-selector="edit-name"]') - .updateValue('[data-drupal-selector="edit-name"]', 'test') - .waitForElementVisible('#edit-name-machine-name-suffix') // Wait for machine name to update. - .submitForm('input[type="submit"]') - .waitForElementVisible('[data-drupal-messages]') - .assert.textContains( - '[data-drupal-messages]', - 'The content type test has been added', - ) - // Navigate to the create content page and measure height of the editor. - .drupalRelativeURL('/node/add/test') - .waitForElementVisible('.ck-editor__editable') - .execute( - // eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow - function () { - const height = document.querySelector( - '.ck-editor__editable', - ).clientHeight; - - // We expect height to be 320, but test to ensure that it's greater - // than 300. We want to ensure that we don't hard code a very specific - // value because tests might break if styles change (line-height, etc). - // Note that the default height for CKEditor5 is 47px. - return height > 300; - }, - [], - (result) => { - browser.assert.ok( - result.value, - 'Editor height is set to 9 rows (default).', - ); - }, - ) - .click('.ck-source-editing-button') - .waitForElementVisible('.ck-source-editing-area') - .execute( - // eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow - function () { - const height = document.querySelector( - '.ck-source-editing-area', - ).clientHeight; - - // We expect height to be 320, but test to ensure that it's greater - // than 300. We want to ensure that we don't hard code a very specific - // value because tests might break if styles change (line-height, etc). - // Note that the default height for CKEditor5 is 47px. - return height > 300; - }, - [], - (result) => { - browser.assert.ok( - result.value, - 'Source editing height is set to 9 rows (default).', - ); - }, - ) - - // Navigate to the create content page and measure max-height of the editor. - .drupalRelativeURL('/node/add/test') - .execute( - // eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow - function () { - window.Drupal.CKEditor5Instances.forEach((instance) => { - instance.setData('<p>Llamas are cute.</p>'.repeat(100)); - }); - - const height = document.querySelector( - '.ck-editor__editable', - ).clientHeight; - - return height < window.innerHeight; - }, - [], - (result) => { - browser.assert.ok( - result.value, - 'Editor area should never exceed full viewport.', - ); - }, - ) - // Source Editor textarea should have vertical scrollbar when needed. - .click('.ck-source-editing-button') - .waitForElementVisible('.ck-source-editing-area') - .execute( - // eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow - function () { - function isScrollableY(element) { - const style = window.getComputedStyle(element); - - if ( - element.scrollHeight > element.clientHeight && - style.overflow !== 'hidden' && - style['overflow-y'] !== 'hidden' && - style.overflow !== 'clip' && - style['overflow-y'] !== 'clip' - ) { - if ( - element === document.scrollingElement || - (style.overflow !== 'visible' && - style['overflow-y'] !== 'visible') - ) { - return true; - } - } - - return false; - } - - return isScrollableY( - document.querySelector('.ck-source-editing-area textarea'), - ); - }, - [], - (result) => { - browser.assert.strictEqual( - result.value, - true, - 'Source Editor textarea should have vertical scrollbar when needed.', - ); - }, - ) - - // Double the editor row count. - .drupalRelativeURL('/admin/structure/types/manage/test/form-display') - .waitForElementVisible( - '[data-drupal-selector="edit-fields-body-settings-edit"]', - ) - .click('[data-drupal-selector="edit-fields-body-settings-edit"]') - .waitForElementVisible( - '[data-drupal-selector="edit-fields-body-settings-edit-form-settings-rows"]', - ) - .updateValue( - '[data-drupal-selector="edit-fields-body-settings-edit-form-settings-rows"]', - '18', - ) - // Save field settings. - .click( - '[data-drupal-selector="edit-fields-body-settings-edit-form-actions-save-settings"]', - ) - .waitForElementVisible( - '[data-drupal-selector="edit-fields-body"] .field-plugin-summary', - ) - .click('[data-drupal-selector="edit-submit"]') - .waitForElementVisible('[data-drupal-messages]') - .assert.textContains( - '[data-drupal-messages]', - 'Your settings have been saved', - ) - - // Navigate to the create content page and measure height of the editor. - .drupalRelativeURL('/node/add/test') - .execute( - // eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow - function () { - const height = document.querySelector( - '.ck-editor__editable', - ).clientHeight; - - // We expect height to be 640, but test to ensure that it's greater - // than 600. We want to ensure that we don't hard code a very specific - // value because tests might break if styles change (line-height, etc). - // Note that the default height for CKEditor5 is 47px. - return height > 600; - }, - [], - (result) => { - browser.assert.ok(result.value, 'Editor height is set to 18 rows.'); - }, - ) - .click('.ck-source-editing-button') - .waitForElementVisible('.ck-source-editing-area') - .execute( - // eslint-disable-next-line func-names, prefer-arrow-callback, no-shadow - function () { - const height = document.querySelector( - '.ck-source-editing-area', - ).clientHeight; - - // We expect height to be 640, but test to ensure that it's greater - // than 600. We want to ensure that we don't hard code a very specific - // value because tests might break if styles change (line-height, etc). - // Note that the default height for CKEditor5 is 47px. - return height > 600; - }, - [], - (result) => { - browser.assert.ok( - result.value, - 'Source editing height is set to 18 rows (default).', - ); - }, - ); - }); - }, -}; diff --git a/core/modules/ckeditor5/tests/src/Unit/CKEditor5ImageControllerTest.php b/core/modules/ckeditor5/tests/src/Unit/CKEditor5ImageControllerTest.php new file mode 100644 index 00000000000..bb69c28c6fb --- /dev/null +++ b/core/modules/ckeditor5/tests/src/Unit/CKEditor5ImageControllerTest.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\ckeditor5\Unit; + +use Drupal\ckeditor5\Controller\CKEditor5ImageController; +use Drupal\ckeditor5\Plugin\CKEditor5PluginManagerInterface; +use Drupal\Core\Entity\EntityConstraintViolationList; +use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\EntityTypeRepositoryInterface; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Lock\LockBackendInterface; +use Drupal\editor\EditorInterface; +use Drupal\file\Entity\File; +use Drupal\file\FileInterface; +use Drupal\file\Upload\FileUploadHandlerInterface; +use Drupal\Tests\UnitTestCase; +use Prophecy\Argument; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\HttpException; + +/** + * Tests CKEditor5ImageController. + * + * @group ckeditor5 + * @coversDefaultClass \Drupal\ckeditor5\Controller\CKEditor5ImageController + */ +final class CKEditor5ImageControllerTest extends UnitTestCase { + + /** + * Tests that upload fails correctly when the file is too large. + */ + public function testInvalidFile(): void { + $file_system = $this->prophesize(FileSystemInterface::class); + $file_system->move(Argument::any())->shouldNotBeCalled(); + $directory = 'public://'; + $file_system->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY)->willReturn(TRUE); + $file_system->getDestinationFilename(Argument::cetera())->willReturn('/tmp/foo.txt'); + $lock = $this->prophesize(LockBackendInterface::class); + $lock->acquire(Argument::any())->willReturn(TRUE); + $container = $this->prophesize(ContainerInterface::class); + $file_storage = $this->prophesize(EntityStorageInterface::class); + $file = $this->prophesize(FileInterface::class); + $violations = $this->prophesize(EntityConstraintViolationList::class); + $violations->count()->willReturn(0); + $file->validate()->willReturn($violations->reveal()); + $file_storage->create(Argument::any())->willReturn($file->reveal()); + $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + $entity_type_manager->getStorage('file')->willReturn($file_storage->reveal()); + $container->get('entity_type.manager')->willReturn($entity_type_manager->reveal()); + $entity_type_repository = $this->prophesize(EntityTypeRepositoryInterface::class); + $entity_type_repository->getEntityTypeFromClass(File::class)->willReturn('file'); + $container->get('entity_type.repository')->willReturn($entity_type_repository->reveal()); + \Drupal::setContainer($container->reveal()); + $controller = new CKEditor5ImageController( + $file_system->reveal(), + $this->prophesize(FileUploadHandlerInterface::class)->reveal(), + $lock->reveal(), + $this->prophesize(CKEditor5PluginManagerInterface::class)->reveal(), + ); + // We can't use vfsstream here because of how Symfony request works. + $file_uri = tempnam(sys_get_temp_dir(), 'tmp'); + $fp = fopen($file_uri, 'w'); + fwrite($fp, 'foo'); + fclose($fp); + $request = Request::create('/', files: [ + 'upload' => [ + 'name' => 'foo.txt', + 'type' => 'text/plain', + 'size' => 42, + 'tmp_name' => $file_uri, + 'error' => \UPLOAD_ERR_FORM_SIZE, + ], + ]); + $editor = $this->prophesize(EditorInterface::class); + $request->attributes->set('editor', $editor->reveal()); + $this->expectException(HttpException::class); + $this->expectExceptionMessage('The file "foo.txt" exceeds the upload limit defined in your form.'); + $controller->upload($request); + } + +} 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/templates/field--comment.html.twig b/core/modules/comment/templates/field--comment.html.twig index 879f4d57ae4..1ea746db029 100644 --- a/core/modules/comment/templates/field--comment.html.twig +++ b/core/modules/comment/templates/field--comment.html.twig @@ -22,7 +22,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() * @see comment_preprocess_field() */ #} 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/comment/tests/src/Kernel/Plugin/migrate/source/CommentTypeRequirementsTest.php b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/CommentTypeRequirementsTest.php index 379e51803b0..c04c478f3a2 100644 --- a/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/CommentTypeRequirementsTest.php +++ b/core/modules/comment/tests/src/Kernel/Plugin/migrate/source/CommentTypeRequirementsTest.php @@ -11,6 +11,7 @@ use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase; * Tests check requirements for comment type source plugin. * * @group comment + * @group #slow */ class CommentTypeRequirementsTest extends MigrateDrupal7TestBase { diff --git a/core/modules/config/tests/config_override_test/config/install/block.block.call_to_action.yml b/core/modules/config/tests/config_override_test/config/install/block.block.call_to_action.yml index 2fe777a1da3..8ac15c8f1bc 100644 --- a/core/modules/config/tests/config_override_test/config/install/block.block.call_to_action.yml +++ b/core/modules/config/tests/config_override_test/config/install/block.block.call_to_action.yml @@ -16,8 +16,6 @@ settings: label: 'Shop for cheap now!' label_display: visible provider: block_content - status: true - info: '' view_mode: full visibility: request_path: 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/tests/src/Functional/ConfigExportUITest.php b/core/modules/config/tests/src/Functional/ConfigExportUITest.php index 8cf7e858b73..d54e4d3c815 100644 --- a/core/modules/config/tests/src/Functional/ConfigExportUITest.php +++ b/core/modules/config/tests/src/Functional/ConfigExportUITest.php @@ -84,7 +84,7 @@ class ConfigExportUITest extends BrowserTestBase { // Ensure the test configuration override is in effect but was not exported. $this->assertSame('Foo', \Drupal::config('system.maintenance')->get('message')); $archiver->extract($temp_directory, ['system.maintenance.yml']); - $file_contents = file_get_contents($temp_directory . '/' . 'system.maintenance.yml'); + $file_contents = file_get_contents($temp_directory . '/system.maintenance.yml'); $exported = Yaml::decode($file_contents); $this->assertNotSame('Foo', $exported['message']); diff --git a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php index 50195354701..2a40a48f47e 100644 --- a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php +++ b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\config\Functional; use Drupal\Core\Config\StorageComparer; use Drupal\Core\Entity\ContentEntityTypeInterface; use Drupal\Core\Extension\ExtensionLifecycle; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Tests\SchemaCheckTestTrait; use Drupal\Tests\system\Functional\Module\ModuleTestBase; @@ -109,6 +110,9 @@ class ConfigImportAllTest extends ModuleTestBase { $all_modules = \Drupal::service('extension.list.module')->getList(); $database_module = \Drupal::service('database')->getProvider(); $expected_modules = ['path_alias', 'system', 'user', $database_module]; + // If the database module has dependencies, they are expected too. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get($database_module); + $database_module_dependencies = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; // Ensure that only core required modules and the install profile can not be // uninstalled. @@ -127,8 +131,11 @@ class ConfigImportAllTest extends ModuleTestBase { // Can not uninstall config and use admin/config/development/configuration! unset($modules_to_uninstall['config']); - // Can not uninstall the database module. + // Can not uninstall the database module and its dependencies. unset($modules_to_uninstall[$database_module]); + foreach ($database_module_dependencies as $dependency) { + unset($modules_to_uninstall[$dependency]); + } $this->assertTrue(isset($modules_to_uninstall['comment']), 'The comment module will be disabled'); $this->assertTrue(isset($modules_to_uninstall['file']), 'The File module will be disabled'); diff --git a/core/modules/config/tests/src/FunctionalJavascript/ConfigEntityTest.php b/core/modules/config/tests/src/FunctionalJavascript/ConfigEntityTest.php index 612a654bcf5..42f671b9311 100644 --- a/core/modules/config/tests/src/FunctionalJavascript/ConfigEntityTest.php +++ b/core/modules/config/tests/src/FunctionalJavascript/ConfigEntityTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\config\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the Config operations through the UI. - * - * @group config */ +#[Group('config')] class ConfigEntityTest extends WebDriverTestBase { /** diff --git a/core/modules/config/tests/src/FunctionalJavascript/ConfigExportTest.php b/core/modules/config/tests/src/FunctionalJavascript/ConfigExportTest.php index 2489d31ae95..31042be6c35 100644 --- a/core/modules/config/tests/src/FunctionalJavascript/ConfigExportTest.php +++ b/core/modules/config/tests/src/FunctionalJavascript/ConfigExportTest.php @@ -4,16 +4,18 @@ declare(strict_types=1); namespace Drupal\Tests\config\FunctionalJavascript; -use Drupal\block_content\Entity\BlockContent; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\Tests\block_content\Traits\BlockContentCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests the config export form. - * - * @group config */ +#[Group('config')] class ConfigExportTest extends WebDriverTestBase { + use BlockContentCreationTrait; + /** * {@inheritdoc} */ @@ -58,26 +60,6 @@ class ConfigExportTest extends WebDriverTestBase { } /** - * Creates test blocks. - * - * @param string $title - * Title of the block. - * - * @return \Drupal\block_content\Entity\BlockContent - * The created block content entity. - * - * @throws \Drupal\Core\Entity\EntityStorageException - */ - protected function createBlockContent($title) { - $block_content = BlockContent::create([ - 'info' => $title, - 'type' => 'basic', - ]); - $block_content->save(); - return $block_content; - } - - /** * Tests Ajax form functionality on the config export page. */ public function testAjaxOnExportPage(): void { diff --git a/core/modules/config/tests/src/FunctionalJavascript/ConfigImportUIAjaxTest.php b/core/modules/config/tests/src/FunctionalJavascript/ConfigImportUIAjaxTest.php index 61787d01db0..16c613c9fd4 100644 --- a/core/modules/config/tests/src/FunctionalJavascript/ConfigImportUIAjaxTest.php +++ b/core/modules/config/tests/src/FunctionalJavascript/ConfigImportUIAjaxTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\config\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the user interface for importing configuration. - * - * @group config */ +#[Group('config')] class ConfigImportUIAjaxTest extends WebDriverTestBase { /** 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/config_translation/src/ConfigMapperManager.php b/core/modules/config_translation/src/ConfigMapperManager.php index 6ce0054a3d8..6edbdf3561e 100644 --- a/core/modules/config_translation/src/ConfigMapperManager.php +++ b/core/modules/config_translation/src/ConfigMapperManager.php @@ -79,7 +79,7 @@ class ConfigMapperManager extends DefaultPluginManager implements ConfigMapperMa $this->alterInfo('config_translation_info'); // Config translation only uses an info hook discovery, cache by language. - $cache_key = 'config_translation_info_plugins' . ':' . $language_manager->getCurrentLanguage()->getId(); + $cache_key = 'config_translation_info_plugins:' . $language_manager->getCurrentLanguage()->getId(); $this->setCacheBackend($cache_backend, $cache_key); } diff --git a/core/modules/config_translation/tests/src/FunctionalJavascript/ConfigTranslationUiTest.php b/core/modules/config_translation/tests/src/FunctionalJavascript/ConfigTranslationUiTest.php index 5d8b08bc54d..2a26a7696f0 100644 --- a/core/modules/config_translation/tests/src/FunctionalJavascript/ConfigTranslationUiTest.php +++ b/core/modules/config_translation/tests/src/FunctionalJavascript/ConfigTranslationUiTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\config_translation\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\language\Entity\ConfigurableLanguage; +use PHPUnit\Framework\Attributes\Group; /** * Translate settings and entities to various languages. - * - * @group config_translation */ +#[Group('config_translation')] class ConfigTranslationUiTest extends WebDriverTestBase { /** 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/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php index 4e8e77ff7da..5a130bf160c 100644 --- a/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php +++ b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php @@ -9,6 +9,7 @@ use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldWidget\OptionsSelectWidget; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\Session\AccountInterface; use Drupal\content_moderation\ModerationInformation; use Drupal\content_moderation\StateTransitionValidationInterface; @@ -66,6 +67,8 @@ class ModerationStateWidget extends OptionsSelectWidget { * Field settings. * @param array $third_party_settings * Third party settings. + * @param \Drupal\Core\Render\ElementInfoManagerInterface $elementInfoManager + * The element info manager. * @param \Drupal\Core\Session\AccountInterface $current_user * Current user service. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager @@ -75,8 +78,8 @@ class ModerationStateWidget extends OptionsSelectWidget { * @param \Drupal\content_moderation\StateTransitionValidationInterface $validator * Moderation state transition validation service. */ - public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, ModerationInformation $moderation_information, StateTransitionValidationInterface $validator) { - parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ElementInfoManagerInterface $elementInfoManager, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, ModerationInformation $moderation_information, StateTransitionValidationInterface $validator) { + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $elementInfoManager); $this->entityTypeManager = $entity_type_manager; $this->currentUser = $current_user; $this->moderationInformation = $moderation_information; @@ -93,6 +96,7 @@ class ModerationStateWidget extends OptionsSelectWidget { $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings'], + $container->get('plugin.manager.element_info'), $container->get('current_user'), $container->get('entity_type.manager'), $container->get('content_moderation.moderation_information'), diff --git a/core/modules/content_moderation/tests/src/Functional/WorkspaceContentModerationIntegrationTest.php b/core/modules/content_moderation/tests/src/Functional/WorkspaceContentModerationIntegrationTest.php index 4b4fbe2c790..2462173f970 100644 --- a/core/modules/content_moderation/tests/src/Functional/WorkspaceContentModerationIntegrationTest.php +++ b/core/modules/content_moderation/tests/src/Functional/WorkspaceContentModerationIntegrationTest.php @@ -33,7 +33,7 @@ class WorkspaceContentModerationIntegrationTest extends ModerationStateTestBase protected function getAdministratorPermissions(): array { return array_merge($this->permissions, [ 'bypass node access', - 'view any workspace', + 'administer workspaces', ]); } @@ -50,6 +50,7 @@ class WorkspaceContentModerationIntegrationTest extends ModerationStateTestBase $this->createContentTypeFromUi('Article', 'article', TRUE); $this->setupWorkspaceSwitcherBlock(); + $this->createWorkspaceThroughUi('Stage', 'stage'); } /** diff --git a/core/modules/content_translation/src/Access/ContentTranslationDeleteAccess.php b/core/modules/content_translation/src/Access/ContentTranslationDeleteAccess.php index 5f1933714bc..476eaaaf72f 100644 --- a/core/modules/content_translation/src/Access/ContentTranslationDeleteAccess.php +++ b/core/modules/content_translation/src/Access/ContentTranslationDeleteAccess.php @@ -11,7 +11,6 @@ use Drupal\Core\Routing\Access\AccessInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Session\AccountInterface; use Drupal\language\Entity\ContentLanguageSettings; -use Drupal\workflows\Entity\Workflow; /** * Access check for entity translation deletion. @@ -83,13 +82,9 @@ class ContentTranslationDeleteAccess implements AccessInterface { $entity_type_id = $entity->getEntityTypeId(); $result->addCacheableDependency($entity); - // Add the cache dependencies used by - // ContentTranslationManager::isPendingRevisionSupportEnabled(). - if (\Drupal::moduleHandler()->moduleExists('content_moderation')) { - foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) { - $result->addCacheableDependency($workflow); - } - } + // The information about workflows is stored in entity bundle info, depend + // on that cache tag. + $result->addCacheTags(['entity_bundles']); if (!ContentTranslationManager::isPendingRevisionSupportEnabled($entity_type_id, $entity->bundle())) { return $result; } diff --git a/core/modules/content_translation/src/ContentTranslationManager.php b/core/modules/content_translation/src/ContentTranslationManager.php index 56ac55cf7e7..0718a4cbf9d 100644 --- a/core/modules/content_translation/src/ContentTranslationManager.php +++ b/core/modules/content_translation/src/ContentTranslationManager.php @@ -3,7 +3,6 @@ namespace Drupal\content_translation; use Drupal\Core\Entity\EntityInterface; -use Drupal\workflows\Entity\Workflow; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -168,24 +167,13 @@ class ContentTranslationManager implements ContentTranslationManagerInterface, B return FALSE; } - foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) { - /** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */ - $plugin = $workflow->getTypePlugin(); - $entity_type_ids = array_flip($plugin->getEntityTypes()); - if (isset($entity_type_ids[$entity_type_id])) { - if (!isset($bundle_id)) { - return TRUE; - } - else { - $bundle_ids = array_flip($plugin->getBundlesForEntityType($entity_type_id)); - if (isset($bundle_ids[$bundle_id])) { - return TRUE; - } - } - } + $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id); + if ($bundle_id) { + return \Drupal::service('content_moderation.moderation_information')->shouldModerateEntitiesOfBundle($entity_type, $bundle_id); + } + else { + return \Drupal::service('content_moderation.moderation_information')->canModerateEntitiesOfEntityType($entity_type); } - - return FALSE; } } diff --git a/core/modules/content_translation/src/Hook/ContentTranslationHooks.php b/core/modules/content_translation/src/Hook/ContentTranslationHooks.php index c681b07c059..9842c7085fd 100644 --- a/core/modules/content_translation/src/Hook/ContentTranslationHooks.php +++ b/core/modules/content_translation/src/Hook/ContentTranslationHooks.php @@ -10,7 +10,6 @@ use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; -use Drupal\content_translation\ContentTranslationManager; use Drupal\content_translation\BundleTranslationSettingsInterface; use Drupal\language\ContentLanguageSettingsInterface; use Drupal\Core\Language\LanguageInterface; @@ -18,6 +17,7 @@ use Drupal\Core\Url; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Hook\Order\Order; +use Drupal\workflows\Entity\Workflow; /** * Hook implementations for content_translation. @@ -231,11 +231,27 @@ class ContentTranslationHooks { $bundle_info['translatable'] = $content_translation_manager->isEnabled($entity_type_id, $bundle); if ($bundle_info['translatable'] && $content_translation_manager instanceof BundleTranslationSettingsInterface) { $settings = $content_translation_manager->getBundleTranslationSettings($entity_type_id, $bundle); - // If pending revision support is enabled for this bundle, we need to - // hide untranslatable field widgets, otherwise changes in pending - // revisions might be overridden by changes in later default - // revisions. - $bundle_info['untranslatable_fields.default_translation_affected'] = !empty($settings['untranslatable_fields_hide']) || ContentTranslationManager::isPendingRevisionSupportEnabled($entity_type_id, $bundle); + $bundle_info['untranslatable_fields.default_translation_affected'] = !empty($settings['untranslatable_fields_hide']); + } + } + } + + // Always hide untranslatable field widgets if pending revision support is + // enabled otherwise changes in pending + // revisions might be overridden by changes in later default revisions. + // This can't use + // Drupal\content_translation\ContentTranslationManager::isPendingRevisionSupportEnabled() + // since that depends on entity bundle information to be completely built. + if (\Drupal::moduleHandler()->moduleExists('content_moderation')) { + foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) { + /** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */ + $plugin = $workflow->getTypePlugin(); + foreach ($plugin->getEntityTypes() as $entity_type_id) { + foreach ($plugin->getBundlesForEntityType($entity_type_id) as $bundle_id) { + if (isset($bundles[$entity_type_id][$bundle_id])) { + $bundles[$entity_type_id][$bundle_id]['untranslatable_fields.default_translation_affected'] = TRUE; + } + } } } } diff --git a/core/modules/content_translation/tests/src/FunctionalJavascript/ContentTranslationConfigUITest.php b/core/modules/content_translation/tests/src/FunctionalJavascript/ContentTranslationConfigUITest.php index 4f4b7da3709..420e32b61a0 100644 --- a/core/modules/content_translation/tests/src/FunctionalJavascript/ContentTranslationConfigUITest.php +++ b/core/modules/content_translation/tests/src/FunctionalJavascript/ContentTranslationConfigUITest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\content_translation\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests that the content translation configuration javascript does't fail. - * - * @group content_translation */ +#[Group('content_translation')] class ContentTranslationConfigUITest extends WebDriverTestBase { /** diff --git a/core/modules/content_translation/tests/src/FunctionalJavascript/ContentTranslationContextualLinksTest.php b/core/modules/content_translation/tests/src/FunctionalJavascript/ContentTranslationContextualLinksTest.php index 8e11084b1f6..943f24def3c 100644 --- a/core/modules/content_translation/tests/src/FunctionalJavascript/ContentTranslationContextualLinksTest.php +++ b/core/modules/content_translation/tests/src/FunctionalJavascript/ContentTranslationContextualLinksTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\content_translation\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\language\Entity\ConfigurableLanguage; +use PHPUnit\Framework\Attributes\Group; /** * Tests that contextual links are available for content translation. - * - * @group content_translation */ +#[Group('content_translation')] class ContentTranslationContextualLinksTest extends WebDriverTestBase { /** diff --git a/core/modules/contextual/contextual.libraries.yml b/core/modules/contextual/contextual.libraries.yml index bfc1c996c98..0e80f3aed42 100644 --- a/core/modules/contextual/contextual.libraries.yml +++ b/core/modules/contextual/contextual.libraries.yml @@ -2,15 +2,7 @@ drupal.contextual-links: version: VERSION 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 +14,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..5a852e28fba 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)), + options.title = title; + const contextualModelView = new Drupal.contextual.ContextualModelView( + $contextual, + $region, + 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.', - }), - ); - + 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,19 +243,273 @@ * 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')); + } + }, + }), - /** - * 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, - }); + /** + * Models the state of a contextual link's trigger, list & region. + */ + 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(); + } + } + }, + }; /** * A trigger is an interactive element often bound to a click handler. @@ -311,12 +534,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/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/ContextualLinksTest.php b/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php index 69a8855637d..dd1d6f1ae92 100644 --- a/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php +++ b/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\contextual\FunctionalJavascript; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\user\Entity\Role; +use PHPUnit\Framework\Attributes\Group; /** * Tests the UI for correct contextual links. - * - * @group contextual */ +#[Group('contextual')] class ContextualLinksTest extends WebDriverTestBase { use ContextualLinkClickTrait; diff --git a/core/modules/contextual/tests/src/FunctionalJavascript/DuplicateContextualLinksTest.php b/core/modules/contextual/tests/src/FunctionalJavascript/DuplicateContextualLinksTest.php index 8549a9c06ff..a3f6e1d7fbc 100644 --- a/core/modules/contextual/tests/src/FunctionalJavascript/DuplicateContextualLinksTest.php +++ b/core/modules/contextual/tests/src/FunctionalJavascript/DuplicateContextualLinksTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\contextual\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the UI for correct contextual links. - * - * @group contextual */ +#[Group('contextual')] class DuplicateContextualLinksTest extends WebDriverTestBase { /** diff --git a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php index 75e56b5f76b..e0a7391d7f8 100644 --- a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php +++ b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php @@ -5,13 +5,13 @@ declare(strict_types=1); namespace Drupal\Tests\contextual\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests edit mode. - * - * @group contextual - * @group #slow */ +#[Group('contextual')] +#[Group('#slow')] class EditModeTest extends WebDriverTestBase { /** @@ -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/datetime.module b/core/modules/datetime/datetime.module index b5ff7ffa23c..55a65f84dae 100644 --- a/core/modules/datetime/datetime.module +++ b/core/modules/datetime/datetime.module @@ -31,10 +31,10 @@ use Drupal\field\FieldStorageConfigInterface; * * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use * \Drupal::service('datetime.views_helper') - * ->fieldViewsDataHelper($field_storage, $data, $column_name); instead. + * ->buildViewsData($field_storage, $data, $column_name); instead. * @see https://www.drupal.org/node/3489502 */ function datetime_type_field_views_data_helper(FieldStorageConfigInterface $field_storage, array $data, $column_name) { - @trigger_error('datetime_type_field_views_data_helper() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use \Drupal::service(\'datetime.views_helper\')->fieldViewsDataHelper($field_storage, $data, $column_name). See https://www.drupal.org/node/3489502', E_USER_DEPRECATED); - return \Drupal::service('datetime.views_helper')->fieldViewsDataHelper($field_storage, $data, $column_name); + @trigger_error('datetime_type_field_views_data_helper() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use \Drupal::service(\'datetime.views_helper\')->buildViewsData($field_storage, $data, $column_name). See https://www.drupal.org/node/3489502', E_USER_DEPRECATED); + return \Drupal::service('datetime.views_helper')->buildViewsData($field_storage, $data, $column_name); } diff --git a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php index 9055d44982b..4fc259a760a 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php +++ b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php @@ -7,6 +7,7 @@ use Drupal\Core\Field\Attribute\FieldWidget; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -31,8 +32,8 @@ class DateTimeDefaultWidget extends DateTimeWidgetBase { /** * {@inheritdoc} */ - public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityStorageInterface $date_storage) { - parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ElementInfoManagerInterface $elementInfoManager, EntityStorageInterface $date_storage) { + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $elementInfoManager); $this->dateStorage = $date_storage; } @@ -47,7 +48,8 @@ class DateTimeDefaultWidget extends DateTimeWidgetBase { $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings'], - $container->get('entity_type.manager')->getStorage('date_format') + $container->get('plugin.manager.element_info'), + $container->get('entity_type.manager')->getStorage('date_format'), ); } 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/datetime_range/src/Plugin/Field/FieldWidget/DateRangeDefaultWidget.php b/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeDefaultWidget.php index 04c6f4eaf1c..b2f90d662e9 100644 --- a/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeDefaultWidget.php +++ b/core/modules/datetime_range/src/Plugin/Field/FieldWidget/DateRangeDefaultWidget.php @@ -7,6 +7,7 @@ use Drupal\Core\Field\Attribute\FieldWidget; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\datetime_range\Plugin\Field\FieldType\DateRangeItem; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -31,8 +32,8 @@ class DateRangeDefaultWidget extends DateRangeWidgetBase { /** * {@inheritdoc} */ - public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityStorageInterface $date_storage) { - parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ElementInfoManagerInterface $elementInfoManager, EntityStorageInterface $date_storage) { + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $elementInfoManager); $this->dateStorage = $date_storage; } @@ -47,6 +48,7 @@ class DateRangeDefaultWidget extends DateRangeWidgetBase { $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings'], + $container->get('plugin.manager.element_info'), $container->get('entity_type.manager')->getStorage('date_format') ); } diff --git a/core/modules/datetime_range/tests/src/FunctionalJavascript/DateRangeFieldTest.php b/core/modules/datetime_range/tests/src/FunctionalJavascript/DateRangeFieldTest.php index 207fe6a74d7..355b2c97098 100644 --- a/core/modules/datetime_range/tests/src/FunctionalJavascript/DateRangeFieldTest.php +++ b/core/modules/datetime_range/tests/src/FunctionalJavascript/DateRangeFieldTest.php @@ -9,12 +9,12 @@ use Drupal\datetime_range\Plugin\Field\FieldType\DateRangeItem; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests Daterange field. - * - * @group datetime */ +#[Group('datetime')] class DateRangeFieldTest extends WebDriverTestBase { /** 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/editor/tests/src/FunctionalJavascript/EditorAdminTest.php b/core/modules/editor/tests/src/FunctionalJavascript/EditorAdminTest.php index 375a75030a9..c1dfc0732c1 100644 --- a/core/modules/editor/tests/src/FunctionalJavascript/EditorAdminTest.php +++ b/core/modules/editor/tests/src/FunctionalJavascript/EditorAdminTest.php @@ -5,12 +5,13 @@ declare(strict_types=1); namespace Drupal\Tests\editor\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore sulaco - /** - * @group editor + * Tests Editor Admin. */ +#[Group('editor')] class EditorAdminTest extends WebDriverTestBase { /** diff --git a/core/modules/field/field.module b/core/modules/field/field.module index 1819df65669..ee5db361526 100644 --- a/core/modules/field/field.module +++ b/core/modules/field/field.module @@ -16,10 +16,6 @@ use Drupal\Core\Form\FormStateInterface; require_once __DIR__ . '/field.purge.inc'; /** - * @} End of "defgroup field". - */ - -/** * Assembles a partial entity structure with initial IDs. * * @param object $ids diff --git a/core/modules/field/src/Hook/FieldHooks.php b/core/modules/field/src/Hook/FieldHooks.php index 274482f9ada..e94e084ea3e 100644 --- a/core/modules/field/src/Hook/FieldHooks.php +++ b/core/modules/field/src/Hook/FieldHooks.php @@ -257,6 +257,10 @@ class FieldHooks { } /** + * @} End of "defgroup field". + */ + + /** * Implements hook_config_import_steps_alter(). */ #[Hook('config_import_steps_alter')] diff --git a/core/modules/field/tests/modules/field_third_party_test/src/Hook/FieldThirdPartyTestHooks.php b/core/modules/field/tests/modules/field_third_party_test/src/Hook/FieldThirdPartyTestHooks.php index 36957502013..dd9a4167ba5 100644 --- a/core/modules/field/tests/modules/field_third_party_test/src/Hook/FieldThirdPartyTestHooks.php +++ b/core/modules/field/tests/modules/field_third_party_test/src/Hook/FieldThirdPartyTestHooks.php @@ -9,6 +9,9 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\WidgetInterface; use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Render\Element\Number; +use Drupal\Core\Render\Element\Textfield; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; /** @@ -18,17 +21,17 @@ class FieldThirdPartyTestHooks { use StringTranslationTrait; + public function __construct(protected ElementInfoManagerInterface $elementInfoManager) {} + /** * Implements hook_field_widget_third_party_settings_form(). */ #[Hook('field_widget_third_party_settings_form')] public function fieldWidgetThirdPartySettingsForm(WidgetInterface $plugin, FieldDefinitionInterface $field_definition, $form_mode, $form, FormStateInterface $form_state): array { - $element['field_test_widget_third_party_settings_form'] = [ - '#type' => 'textfield', - '#title' => $this->t('3rd party widget settings form'), - '#default_value' => $plugin->getThirdPartySetting('field_third_party_test', 'field_test_widget_third_party_settings_form'), - ]; - return $element; + $textfield = $this->elementInfoManager->fromClass(Textfield::class); + $textfield->title = $this->t('3rd party widget settings form'); + $textfield->default_value = $plugin->getThirdPartySetting('field_third_party_test', 'field_test_widget_third_party_settings_form'); + return $textfield->toRenderable('field_test_widget_third_party_settings_form'); } /** @@ -36,12 +39,10 @@ class FieldThirdPartyTestHooks { */ #[Hook('field_widget_third_party_settings_form')] public function fieldWidgetThirdPartySettingsFormAdditionalImplementation(WidgetInterface $plugin, FieldDefinitionInterface $field_definition, $form_mode, $form, FormStateInterface $form_state): array { - $element['second_field_widget_third_party_settings_form'] = [ - '#type' => 'number', - '#title' => $this->t('Second 3rd party widget settings form'), - '#default_value' => $plugin->getThirdPartySetting('field_third_party_test', 'second_field_widget_third_party_settings_form'), - ]; - return $element; + $number = $this->elementInfoManager->fromClass(Number::class); + $number->title = $this->t('Second 3rd party widget settings form'); + $number->default_value = $plugin->getThirdPartySetting('field_third_party_test', 'second_field_widget_third_party_settings_form'); + return $number->toRenderable('second_field_widget_third_party_settings_form'); } /** @@ -57,12 +58,10 @@ class FieldThirdPartyTestHooks { */ #[Hook('field_formatter_third_party_settings_form')] public function fieldFormatterThirdPartySettingsForm(FormatterInterface $plugin, FieldDefinitionInterface $field_definition, $view_mode, $form, FormStateInterface $form_state): array { - $element['field_test_field_formatter_third_party_settings_form'] = [ - '#type' => 'textfield', - '#title' => $this->t('3rd party formatter settings form'), - '#default_value' => $plugin->getThirdPartySetting('field_third_party_test', 'field_test_field_formatter_third_party_settings_form'), - ]; - return $element; + $textfield = $this->elementInfoManager->fromClass(Textfield::class); + $textfield->title = $this->t('3rd party formatter settings form'); + $textfield->default_value = $plugin->getThirdPartySetting('field_third_party_test', 'field_test_field_formatter_third_party_settings_form'); + return $textfield->toRenderable('field_test_field_formatter_third_party_settings_form'); } /** @@ -70,12 +69,10 @@ class FieldThirdPartyTestHooks { */ #[Hook('field_formatter_third_party_settings_form')] public function fieldFormatterThirdPartySettingsFormAdditionalImplementation(FormatterInterface $plugin, FieldDefinitionInterface $field_definition, $view_mode, $form, FormStateInterface $form_state): array { - $element['second_field_formatter_third_party_settings_form'] = [ - '#type' => 'number', - '#title' => $this->t('Second 3rd party formatter settings form'), - '#default_value' => $plugin->getThirdPartySetting('field_third_party_test', 'second_field_formatter_third_party_settings_form'), - ]; - return $element; + $number = $this->elementInfoManager->fromClass(Number::class); + $number->title = $this->t('Second 3rd party formatter settings form'); + $number->default_value = $plugin->getThirdPartySetting('field_third_party_test', 'second_field_formatter_third_party_settings_form'); + return $number->toRenderable('second_field_formatter_third_party_settings_form'); } /** diff --git a/core/modules/field/tests/src/Functional/EntityReference/EntityReferenceAdminTest.php b/core/modules/field/tests/src/Functional/EntityReference/EntityReferenceAdminTest.php index d8496e30e99..407fdd794a4 100644 --- a/core/modules/field/tests/src/Functional/EntityReference/EntityReferenceAdminTest.php +++ b/core/modules/field/tests/src/Functional/EntityReference/EntityReferenceAdminTest.php @@ -192,7 +192,7 @@ class EntityReferenceAdminTest extends BrowserTestBase { $this->assertSession()->pageTextContains('Multiple content items match this reference;'); $this->assertSession()->pageTextContains($node1->getTitle() . ' (' . $node1->id() . ')'); $this->assertSession()->pageTextContains($node2->getTitle() . ' (' . $node2->id() . ')'); - $this->assertSession()->pageTextContains('Specify the one you want by appending the id in parentheses, like "' . $node2->getTitle() . ' (' . $node2->id() . ')' . '".'); + $this->assertSession()->pageTextContains('Specify the one you want by appending the id in parentheses, like "' . $node2->getTitle() . ' (' . $node2->id() . ')".'); $edit = [ 'title[0][value]' => 'Test', 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/Boolean/BooleanFormatterSettingsTest.php b/core/modules/field/tests/src/FunctionalJavascript/Boolean/BooleanFormatterSettingsTest.php index d85495c8e84..46597555851 100644 --- a/core/modules/field/tests/src/FunctionalJavascript/Boolean/BooleanFormatterSettingsTest.php +++ b/core/modules/field/tests/src/FunctionalJavascript/Boolean/BooleanFormatterSettingsTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\field\FunctionalJavascript\Boolean; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the Boolean field formatter settings. - * - * @group field */ +#[Group('field')] class BooleanFormatterSettingsTest extends WebDriverTestBase { /** diff --git a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php index 129f28576d6..688392289ae 100644 --- a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php +++ b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php @@ -5,19 +5,18 @@ 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\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; use Drupal\Tests\field_ui\Traits\FieldUiTestTrait; -use Drupal\field\Entity\FieldConfig; -use Drupal\field\Entity\FieldStorageConfig; +use PHPUnit\Framework\Attributes\Group; /** * Tests for the administrative UI. - * - * @group entity_reference */ +#[Group('entity_reference')] class EntityReferenceAdminTest extends WebDriverTestBase { use FieldUiTestTrait; @@ -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/field/tests/src/FunctionalJavascript/MultipleValueWidgetTest.php b/core/modules/field/tests/src/FunctionalJavascript/MultipleValueWidgetTest.php index 442cbf40f1b..4128d510e6b 100644 --- a/core/modules/field/tests/src/FunctionalJavascript/MultipleValueWidgetTest.php +++ b/core/modules/field/tests/src/FunctionalJavascript/MultipleValueWidgetTest.php @@ -9,12 +9,12 @@ use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests widget form for a multiple value field. - * - * @group field */ +#[Group('field')] class MultipleValueWidgetTest extends WebDriverTestBase { /** diff --git a/core/modules/field/tests/src/FunctionalJavascript/Number/NumberFieldTest.php b/core/modules/field/tests/src/FunctionalJavascript/Number/NumberFieldTest.php index 9a9f95420e1..85997fa3dac 100644 --- a/core/modules/field/tests/src/FunctionalJavascript/Number/NumberFieldTest.php +++ b/core/modules/field/tests/src/FunctionalJavascript/Number/NumberFieldTest.php @@ -8,12 +8,12 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\node\Entity\Node; +use PHPUnit\Framework\Attributes\Group; /** * Tests the numeric field widget. - * - * @group field */ +#[Group('field')] class NumberFieldTest extends WebDriverTestBase { /** diff --git a/core/modules/field/tests/src/Kernel/FieldCrudTest.php b/core/modules/field/tests/src/Kernel/FieldCrudTest.php index 278f8dbc87c..bf1e0cf5c57 100644 --- a/core/modules/field/tests/src/Kernel/FieldCrudTest.php +++ b/core/modules/field/tests/src/Kernel/FieldCrudTest.php @@ -313,7 +313,7 @@ class FieldCrudTest extends FieldKernelTestBase { $this->assertCount(0, $fields, 'A deleted field is marked for deletion.'); // Try to load the field normally and make sure it does not show up. - $field = FieldConfig::load('entity_test.' . '.' . $this->fieldDefinition['bundle'] . '.' . $this->fieldDefinition['field_name']); + $field = FieldConfig::load('entity_test.' . $this->fieldDefinition['bundle'] . '.' . $this->fieldDefinition['field_name']); $this->assertTrue(empty($field), 'Field was deleted'); // Make sure the other field is not deleted. diff --git a/core/modules/field/tests/src/Kernel/FieldStorageCrudTest.php b/core/modules/field/tests/src/Kernel/FieldStorageCrudTest.php index f845abeba45..849dd240212 100644 --- a/core/modules/field/tests/src/Kernel/FieldStorageCrudTest.php +++ b/core/modules/field/tests/src/Kernel/FieldStorageCrudTest.php @@ -357,7 +357,7 @@ class FieldStorageCrudTest extends FieldKernelTestBase { $this->assertEmpty($field_storage, 'Field storage was deleted'); // Try to load the field normally and make sure it does not show up. - $field = FieldConfig::load('entity_test.' . '.' . $field_definition['bundle'] . '.' . $field_definition['field_name']); + $field = FieldConfig::load('entity_test.' . $field_definition['bundle'] . '.' . $field_definition['field_name']); $this->assertEmpty($field, 'Field was deleted'); // Make sure the other field and its storage are not deleted. diff --git a/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldInstanceLabelDescriptionTest.php b/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldInstanceLabelDescriptionTest.php index ca81ab17602..20ea2c954b2 100644 --- a/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldInstanceLabelDescriptionTest.php +++ b/core/modules/field/tests/src/Kernel/Migrate/d6/MigrateFieldInstanceLabelDescriptionTest.php @@ -13,6 +13,7 @@ use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase; * Tests migration of field label and description translations. * * @group migrate_drupal_6 + * @group #slow */ class MigrateFieldInstanceLabelDescriptionTest extends MigrateDrupal6TestBase implements MigrateDumpAlterInterface { diff --git a/core/modules/field_layout/tests/src/FunctionalJavascript/FieldLayoutTest.php b/core/modules/field_layout/tests/src/FunctionalJavascript/FieldLayoutTest.php index d6ef689ad5a..8330f2a2acf 100644 --- a/core/modules/field_layout/tests/src/FunctionalJavascript/FieldLayoutTest.php +++ b/core/modules/field_layout/tests/src/FunctionalJavascript/FieldLayoutTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\field_layout\FunctionalJavascript; use Drupal\entity_test\Entity\EntityTest; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests using field layout for entity displays. - * - * @group field_layout */ +#[Group('field_layout')] class FieldLayoutTest extends WebDriverTestBase { /** diff --git a/core/modules/field_ui/css/field_ui_add_field.module.css b/core/modules/field_ui/css/field_ui_add_field.module.css index d12e6cc8a30..5b84207b5f0 100644 --- a/core/modules/field_ui/css/field_ui_add_field.module.css +++ b/core/modules/field_ui/css/field_ui_add_field.module.css @@ -16,7 +16,7 @@ --input-fg-color: var(--color-gray); --color-blue: #003ecc; --color-red: #dc2323; - --details-box-shadow: 0 2px 0.25rem rgba(0, 0, 0, 0.1); + --details-box-shadow: 0 2px 0.25rem rgb(0, 0, 0, 0.1); } .field-ui-new-storage-wrapper { diff --git a/core/modules/field_ui/css/field_ui_add_field.module.pcss.css b/core/modules/field_ui/css/field_ui_add_field.module.pcss.css index b136e0f4b3c..fc27ca1a253 100644 --- a/core/modules/field_ui/css/field_ui_add_field.module.pcss.css +++ b/core/modules/field_ui/css/field_ui_add_field.module.pcss.css @@ -9,7 +9,7 @@ --input-fg-color: var(--color-gray); --color-blue: #003ecc; --color-red: #dc2323; - --details-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + --details-box-shadow: 0 2px 4px rgb(0, 0, 0, 0.1); } .field-ui-new-storage-wrapper { diff --git a/core/modules/field_ui/field_ui.module b/core/modules/field_ui/field_ui.module index 8904dd925e5..67c049fa505 100644 --- a/core/modules/field_ui/field_ui.module +++ b/core/modules/field_ui/field_ui.module @@ -5,6 +5,7 @@ */ use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Theme\ThemePreprocess; use Drupal\field_ui\FieldUI; /** @@ -18,7 +19,7 @@ use Drupal\field_ui\FieldUI; * rendered as a table. */ function template_preprocess_field_ui_table(&$variables): void { - template_preprocess_table($variables); + \Drupal::service(ThemePreprocess::class)->preprocessTable($variables); } /** diff --git a/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php b/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php index bd18b66a07c..9259f341c95 100644 --- a/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php +++ b/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php @@ -127,13 +127,13 @@ class EntityFormDisplayEditForm extends EntityDisplayFormBase { $this->moduleHandler->invokeAllWith( 'field_widget_third_party_settings_form', function (callable $hook, string $module) use (&$settings_form, $plugin, $field_definition, &$form, $form_state) { - $settings_form[$module] = ($settings_form[$module] ?? []) + $hook( + $settings_form[$module] = ($settings_form[$module] ?? []) + ($hook( $plugin, $field_definition, $this->entity->getMode(), $form, $form_state - ); + ) ?? []); } ); return $settings_form; diff --git a/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php b/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php index 305f8039d70..a188af5d92d 100644 --- a/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php +++ b/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php @@ -162,13 +162,13 @@ class EntityViewDisplayEditForm extends EntityDisplayFormBase { $this->moduleHandler->invokeAllWith( 'field_formatter_third_party_settings_form', function (callable $hook, string $module) use (&$settings_form, &$plugin, &$field_definition, &$form, &$form_state) { - $settings_form[$module] = ($settings_form[$module] ?? []) + $hook( + $settings_form[$module] = ($settings_form[$module] ?? []) + ($hook( $plugin, $field_definition, $this->entity->getMode(), $form, $form_state, - ); + )) ?? []; } ); return $settings_form; diff --git a/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTestBase.php b/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTestBase.php index 360e1f8ffa6..c8c99edf4f8 100644 --- a/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTestBase.php +++ b/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTestBase.php @@ -15,7 +15,7 @@ use Drupal\Tests\field_ui\Traits\FieldUiTestTrait; /** * Tests the Field UI "Manage fields" screen. */ -class ManageFieldsFunctionalTestBase extends BrowserTestBase { +abstract class ManageFieldsFunctionalTestBase extends BrowserTestBase { use EntityReferenceFieldCreationTrait; use FieldUiTestTrait; diff --git a/core/modules/field_ui/tests/src/FunctionalJavascript/DefaultValueWidgetTest.php b/core/modules/field_ui/tests/src/FunctionalJavascript/DefaultValueWidgetTest.php index ade6ff5e8b0..1a5c809b2cf 100644 --- a/core/modules/field_ui/tests/src/FunctionalJavascript/DefaultValueWidgetTest.php +++ b/core/modules/field_ui/tests/src/FunctionalJavascript/DefaultValueWidgetTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\field_ui\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests the default value widget in Field UI. - * - * @group field_ui */ +#[Group('field_ui')] class DefaultValueWidgetTest extends WebDriverTestBase { use TaxonomyTestTrait; diff --git a/core/modules/field_ui/tests/src/FunctionalJavascript/DisplayModeBundleSelectionTest.php b/core/modules/field_ui/tests/src/FunctionalJavascript/DisplayModeBundleSelectionTest.php index 7908418d3fb..dc170c5ae35 100644 --- a/core/modules/field_ui/tests/src/FunctionalJavascript/DisplayModeBundleSelectionTest.php +++ b/core/modules/field_ui/tests/src/FunctionalJavascript/DisplayModeBundleSelectionTest.php @@ -6,12 +6,13 @@ namespace Drupal\Tests\field_ui\FunctionalJavascript; use Drupal\Core\Entity\Entity\EntityFormMode; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * Tests the bundle selection for view & form display modes. - * - * @group field_ui */ +#[Group('field_ui')] class DisplayModeBundleSelectionTest extends WebDriverTestBase { /** @@ -66,9 +67,8 @@ class DisplayModeBundleSelectionTest extends WebDriverTestBase { * Display mode path. * @param string $custom_mode * Custom mode to test. - * - * @dataProvider providerBundleSelection */ + #[DataProvider('providerBundleSelection')] public function testBundleSelection($display_mode, $path, $custom_mode): void { $page = $this->getSession()->getPage(); $assert_session = $this->assertSession(); diff --git a/core/modules/field_ui/tests/src/FunctionalJavascript/EntityDisplayTest.php b/core/modules/field_ui/tests/src/FunctionalJavascript/EntityDisplayTest.php index fa6cee74261..640cb9e2842 100644 --- a/core/modules/field_ui/tests/src/FunctionalJavascript/EntityDisplayTest.php +++ b/core/modules/field_ui/tests/src/FunctionalJavascript/EntityDisplayTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\field_ui\FunctionalJavascript; use Drupal\entity_test\Entity\EntityTest; use Drupal\entity_test\EntityTestHelper; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the UI for entity displays. - * - * @group field_ui */ +#[Group('field_ui')] class EntityDisplayTest extends WebDriverTestBase { /** diff --git a/core/modules/field_ui/tests/src/FunctionalJavascript/ManageDisplayTest.php b/core/modules/field_ui/tests/src/FunctionalJavascript/ManageDisplayTest.php index 4b6162abfc6..c5a13e49bd0 100644 --- a/core/modules/field_ui/tests/src/FunctionalJavascript/ManageDisplayTest.php +++ b/core/modules/field_ui/tests/src/FunctionalJavascript/ManageDisplayTest.php @@ -8,14 +8,13 @@ use Behat\Mink\Element\NodeElement; use Drupal\Core\Entity\Entity\EntityFormDisplay; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore onewidgetfield - /** * Tests the Field UI "Manage display" and "Manage form display" screens. - * - * @group field_ui */ +#[Group('field_ui')] class ManageDisplayTest extends WebDriverTestBase { use FieldUiJSTestTrait; diff --git a/core/modules/field_ui/tests/src/FunctionalJavascript/ManageFieldsTest.php b/core/modules/field_ui/tests/src/FunctionalJavascript/ManageFieldsTest.php index 8ca2dbb9d83..ed8e1e4c2d7 100644 --- a/core/modules/field_ui/tests/src/FunctionalJavascript/ManageFieldsTest.php +++ b/core/modules/field_ui/tests/src/FunctionalJavascript/ManageFieldsTest.php @@ -8,14 +8,13 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore horserad - /** * Tests the Field UI "Manage Fields" screens. - * - * @group field_ui */ +#[Group('field_ui')] class ManageFieldsTest extends WebDriverTestBase { use FieldUiJSTestTrait; diff --git a/core/modules/file/file.module b/core/modules/file/file.module index 5dd741f0c43..295c35998e4 100644 --- a/core/modules/file/file.module +++ b/core/modules/file/file.module @@ -499,7 +499,12 @@ function template_preprocess_file_widget_multiple(&$variables): void { foreach (Element::children($element) as $key) { $widgets[] = &$element[$key]; } - usort($widgets, '_field_multiple_value_form_sort_helper'); + usort($widgets, function ($a, $b) { + // Sorts using ['_weight']['#value']. + $a_weight = (is_array($a) && isset($a['_weight']['#value']) ? $a['_weight']['#value'] : 0); + $b_weight = (is_array($b) && isset($b['_weight']['#value']) ? $b['_weight']['#value'] : 0); + return $a_weight - $b_weight; + }); $rows = []; foreach ($widgets as &$widget) { diff --git a/core/modules/file/src/Plugin/Field/FieldType/FileItem.php b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php index f09740667e3..2e9be38dacb 100644 --- a/core/modules/file/src/Plugin/Field/FieldType/FileItem.php +++ b/core/modules/file/src/Plugin/Field/FieldType/FileItem.php @@ -360,8 +360,11 @@ class FileItem extends EntityReferenceItem { $dirname = static::doGetUploadLocation($settings); \Drupal::service('file_system')->prepareDirectory($dirname, FileSystemInterface::CREATE_DIRECTORY); + // Ensure directory ends with a slash. + $dirname .= str_ends_with($dirname, '/') ? '' : '/'; + // Generate a file entity. - $destination = $dirname . '/' . $random->name(10, TRUE) . '.txt'; + $destination = $dirname . $random->name(10) . '.txt'; $data = $random->paragraphs(3); /** @var \Drupal\file\FileRepositoryInterface $file_repository */ $file_repository = \Drupal::service('file.repository'); diff --git a/core/modules/file/src/Plugin/Field/FieldWidget/FileWidget.php b/core/modules/file/src/Plugin/Field/FieldWidget/FileWidget.php index a38e64ab53c..bb4268ff389 100644 --- a/core/modules/file/src/Plugin/Field/FieldWidget/FileWidget.php +++ b/core/modules/file/src/Plugin/Field/FieldWidget/FileWidget.php @@ -29,16 +29,10 @@ use Symfony\Component\Validator\ConstraintViolationListInterface; class FileWidget extends WidgetBase { /** - * The element info manager. - */ - protected ElementInfoManagerInterface $elementInfo; - - /** * {@inheritdoc} */ public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ElementInfoManagerInterface $element_info) { - parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); - $this->elementInfo = $element_info; + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $element_info); } /** @@ -238,7 +232,7 @@ class FileWidget extends WidgetBase { // Essentially we use the managed_file type, extended with some // enhancements. - $element_info = $this->elementInfo->getInfo('managed_file'); + $element_info = $this->elementInfoManager->getInfo('managed_file'); $element += [ '#type' => 'managed_file', '#upload_location' => $items[$delta]->getUploadLocation(), diff --git a/core/modules/file/tests/file_test/src/Form/FileRequiredTestForm.php b/core/modules/file/tests/file_test/src/Form/FileRequiredTestForm.php index 1491510fd64..4bdcf5455f9 100644 --- a/core/modules/file/tests/file_test/src/Form/FileRequiredTestForm.php +++ b/core/modules/file/tests/file_test/src/Form/FileRequiredTestForm.php @@ -14,14 +14,14 @@ class FileRequiredTestForm extends FileTestForm { /** * {@inheritdoc} */ - public function getFormId() { + public function getFormId(): string { return '_file_required_test_form'; } /** * {@inheritdoc} */ - public function buildForm(array $form, FormStateInterface $form_state) { + public function buildForm(array $form, FormStateInterface $form_state): array { $form = parent::buildForm($form, $form_state); $form['file_test_upload']['#required'] = TRUE; return $form; diff --git a/core/modules/file/tests/file_test/src/Form/FileTestForm.php b/core/modules/file/tests/file_test/src/Form/FileTestForm.php index d021a538f44..902c675cdcf 100644 --- a/core/modules/file/tests/file_test/src/Form/FileTestForm.php +++ b/core/modules/file/tests/file_test/src/Form/FileTestForm.php @@ -6,28 +6,28 @@ namespace Drupal\file_test\Form; use Drupal\Core\File\FileExists; use Drupal\Core\File\FileSystemInterface; -use Drupal\Core\Form\FormInterface; +use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; /** * File test form class. */ -class FileTestForm implements FormInterface { +class FileTestForm extends FormBase { use FileTestFormTrait; use StringTranslationTrait; /** * {@inheritdoc} */ - public function getFormId() { + public function getFormId(): string { return '_file_test_form'; } /** * {@inheritdoc} */ - public function buildForm(array $form, FormStateInterface $form_state) { + public function buildForm(array $form, FormStateInterface $form_state): array { $form = $this->baseForm($form, $form_state); @@ -42,12 +42,7 @@ class FileTestForm implements FormInterface { /** * {@inheritdoc} */ - public function validateForm(array &$form, FormStateInterface $form_state) {} - - /** - * {@inheritdoc} - */ - public function submitForm(array &$form, FormStateInterface $form_state) { + public function submitForm(array &$form, FormStateInterface $form_state): void { // Process the upload and perform validation. Note: we're using the // form value for the $replace parameter. if (!$form_state->isValueEmpty('file_subdir')) { diff --git a/core/modules/file/tests/src/Functional/SaveUploadFormTest.php b/core/modules/file/tests/src/Functional/SaveUploadFormTest.php index 7830090636c..3db3b0b06d6 100644 --- a/core/modules/file/tests/src/Functional/SaveUploadFormTest.php +++ b/core/modules/file/tests/src/Functional/SaveUploadFormTest.php @@ -245,7 +245,7 @@ class SaveUploadFormTest extends FileManagedTestBase { $this->drupalGet('file-test/save_upload_from_form_test'); $this->submitForm($edit, 'Submit'); $this->assertSession()->statusCodeEquals(200); - $this->assertSession()->responseContains('For security reasons, your upload has been renamed to <em class="placeholder">' . $this->phpFile->filename . '_.txt' . '</em>'); + $this->assertSession()->responseContains('For security reasons, your upload has been renamed to <em class="placeholder">' . $this->phpFile->filename . '_.txt</em>'); $this->assertSession()->pageTextContains('File MIME type is text/plain.'); $this->assertSession()->pageTextContains("You WIN!"); diff --git a/core/modules/file/tests/src/Functional/SaveUploadTest.php b/core/modules/file/tests/src/Functional/SaveUploadTest.php index 66cfe08cad3..cb5bf4f09b0 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; @@ -278,7 +278,7 @@ class SaveUploadTest extends FileManagedTestBase { $this->drupalGet('file-test/upload'); $this->submitForm($edit, 'Submit'); $this->assertSession()->statusCodeEquals(200); - $this->assertSession()->responseContains('For security reasons, your upload has been renamed to <em class="placeholder">' . $this->phpFile->filename . '_.txt' . '</em>'); + $this->assertSession()->responseContains('For security reasons, your upload has been renamed to <em class="placeholder">' . $this->phpFile->filename . '_.txt</em>'); $this->assertSession()->pageTextContains('File name is php-2.php_.txt.'); $this->assertSession()->pageTextContains('File MIME type is text/plain.'); $this->assertSession()->pageTextContains("You WIN!"); @@ -303,7 +303,7 @@ class SaveUploadTest extends FileManagedTestBase { $this->drupalGet('file-test/upload'); $this->submitForm($edit, 'Submit'); $this->assertSession()->statusCodeEquals(200); - $this->assertSession()->responseContains('For security reasons, your upload has been renamed to <em class="placeholder">' . $this->phpFile->filename . '_.txt' . '</em>'); + $this->assertSession()->responseContains('For security reasons, your upload has been renamed to <em class="placeholder">' . $this->phpFile->filename . '_.txt</em>'); $this->assertSession()->pageTextContains('File name is php-2.php_.txt.'); $this->assertSession()->pageTextContains('File MIME type is text/plain.'); $this->assertSession()->pageTextContains("You WIN!"); @@ -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/file/tests/src/FunctionalJavascript/AjaxFileManagedMultipleTest.php b/core/modules/file/tests/src/FunctionalJavascript/AjaxFileManagedMultipleTest.php index e7fb4e3e701..a0f9f727dfe 100644 --- a/core/modules/file/tests/src/FunctionalJavascript/AjaxFileManagedMultipleTest.php +++ b/core/modules/file/tests/src/FunctionalJavascript/AjaxFileManagedMultipleTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\file\FunctionalJavascript; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests ajax upload to managed files. - * - * @group file */ +#[Group('file')] class AjaxFileManagedMultipleTest extends WebDriverTestBase { use TestFileCreationTrait { diff --git a/core/modules/file/tests/src/FunctionalJavascript/FileFieldValidateTest.php b/core/modules/file/tests/src/FunctionalJavascript/FileFieldValidateTest.php index 724b80db8e3..983e828effc 100644 --- a/core/modules/file/tests/src/FunctionalJavascript/FileFieldValidateTest.php +++ b/core/modules/file/tests/src/FunctionalJavascript/FileFieldValidateTest.php @@ -7,15 +7,15 @@ namespace Drupal\Tests\file\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\file\Functional\FileFieldCreationTrait; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests file field validation functions. * * Values validated include the file type, max file size, max size per node, * and whether the field is required. - * - * @group file */ +#[Group('file')] class FileFieldValidateTest extends WebDriverTestBase { use FileFieldCreationTrait; diff --git a/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetClaroThemeTest.php b/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetClaroThemeTest.php index f8c6e315d22..08640330942 100644 --- a/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetClaroThemeTest.php +++ b/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetClaroThemeTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\file\FunctionalJavascript; use Drupal\Core\Url; +use PHPUnit\Framework\Attributes\Group; /** * Tests the widget visibility settings for the Claro theme. @@ -13,9 +14,8 @@ use Drupal\Core\Url; * the changes added in _claro_preprocess_file_and_image_widget(). * * @see _claro_preprocess_file_and_image_widget() - * - * @group file */ +#[Group('file')] class FileFieldWidgetClaroThemeTest extends FileFieldWidgetTest { /** diff --git a/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetTest.php b/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetTest.php index ac5f8fc750e..a545cb9ddaf 100644 --- a/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetTest.php +++ b/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetTest.php @@ -9,12 +9,12 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiTestTrait; use Drupal\Tests\file\Functional\FileFieldCreationTrait; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests the file field widget, single and multi-valued, using AJAX upload. - * - * @group file */ +#[Group('file')] class FileFieldWidgetTest extends WebDriverTestBase { use FieldUiTestTrait; diff --git a/core/modules/file/tests/src/FunctionalJavascript/FileManagedFileElementTest.php b/core/modules/file/tests/src/FunctionalJavascript/FileManagedFileElementTest.php index 8924703c8fd..7a0119cd6fc 100644 --- a/core/modules/file/tests/src/FunctionalJavascript/FileManagedFileElementTest.php +++ b/core/modules/file/tests/src/FunctionalJavascript/FileManagedFileElementTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\file\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the 'managed_file' element type. - * - * @group file */ +#[Group('file')] class FileManagedFileElementTest extends WebDriverTestBase { /** diff --git a/core/modules/file/tests/src/FunctionalJavascript/MaximumFileSizeExceededUploadTest.php b/core/modules/file/tests/src/FunctionalJavascript/MaximumFileSizeExceededUploadTest.php index 41bbae0b789..a32014c8492 100644 --- a/core/modules/file/tests/src/FunctionalJavascript/MaximumFileSizeExceededUploadTest.php +++ b/core/modules/file/tests/src/FunctionalJavascript/MaximumFileSizeExceededUploadTest.php @@ -6,14 +6,14 @@ namespace Drupal\Tests\file\FunctionalJavascript; use Drupal\Component\Utility\Bytes; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; -use Drupal\Tests\TestFileCreationTrait; use Drupal\Tests\file\Functional\FileFieldCreationTrait; +use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests uploading a file that exceeds the maximum file size. - * - * @group file */ +#[Group('file')] class MaximumFileSizeExceededUploadTest extends WebDriverTestBase { use FileFieldCreationTrait; diff --git a/core/modules/file/tests/src/Kernel/FileItemTest.php b/core/modules/file/tests/src/Kernel/FileItemTest.php index 09a28b68f0f..c01cf28a115 100644 --- a/core/modules/file/tests/src/Kernel/FileItemTest.php +++ b/core/modules/file/tests/src/Kernel/FileItemTest.php @@ -6,12 +6,14 @@ namespace Drupal\Tests\file\Kernel; use Drupal\Core\Field\FieldItemInterface; use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\entity_test\Entity\EntityTest; use Drupal\field\Entity\FieldConfig; use Drupal\Tests\field\Kernel\FieldKernelTestBase; use Drupal\field\Entity\FieldStorageConfig; use Drupal\file\Entity\File; +use Drupal\file\Plugin\Field\FieldType\FileItem; use Drupal\user\Entity\Role; /** @@ -155,6 +157,48 @@ class FileItemTest extends FieldKernelTestBase { \Drupal::service('renderer')->renderRoot($output); $this->assertTrue(!empty($entity->file_test->entity)); $this->assertEquals($uri, $entity->file_test->entity->getFileUri()); + + // Test file URIs with empty and custom directories. + $this->validateFileUriForDirectory( + '', 'public://' + ); + $this->validateFileUriForDirectory( + 'custom_directory/subdir', 'public://custom_directory/subdir/' + ); + } + + /** + * Tests file URIs generated for a given file directory. + * + * @param string $file_directory + * The file directory to test (e.g., empty or 'custom_directory/subdir'). + * @param string $expected_start + * The expected starting string of the file URI (e.g., 'public://'). + */ + private function validateFileUriForDirectory(string $file_directory, string $expected_start): void { + // Mock the field definition with the specified file directory. + $definition = $this->createMock(FieldDefinitionInterface::class); + $definition->expects($this->any()) + ->method('getSettings') + ->willReturn([ + 'file_extensions' => 'txt', + 'file_directory' => $file_directory, + 'uri_scheme' => 'public', + 'display_default' => TRUE, + ]); + + // Generate a sample file value. + $value = FileItem::generateSampleValue($definition); + $this->assertNotEmpty($value); + + // Load the file entity and get its URI. + $fid = $value['target_id']; + $file = File::load($fid); + $fileUri = $file->getFileUri(); + + // Verify the file URI starts with the expected protocol and structure. + $this->assertStringStartsWith($expected_start, $fileUri); + $this->assertMatchesRegularExpression('#^' . preg_quote($expected_start, '#') . '[^/]+#', $fileUri); } } diff --git a/core/modules/filter/src/Element/TextFormat.php b/core/modules/filter/src/Element/TextFormat.php index 38ca133e3cd..6b1fc25c3ff 100644 --- a/core/modules/filter/src/Element/TextFormat.php +++ b/core/modules/filter/src/Element/TextFormat.php @@ -12,11 +12,15 @@ use Drupal\Core\Url; * Provides a text format render element. * * Properties: - * - #base_type: The form element #type to use for the 'value' element. + * + * @property $base_type + * The form element #type to use for the 'value' element. * 'textarea' by default. - * - #format: (optional) The text format ID to preselect. If omitted, the + * @property $format + * (optional) The text format ID to preselect. If omitted, the * default format for the current user will be used. - * - #allowed_formats: (optional) An array of text format IDs that are available + * @property $allowed_formats + * (optional) An array of text format IDs that are available * for this element. If omitted, all text formats that the current user has * access to will be allowed. * diff --git a/core/modules/filter/src/Plugin/migrate/process/FilterID.php b/core/modules/filter/src/Plugin/migrate/process/FilterID.php index f88c1fa830b..4981a4b4264 100644 --- a/core/modules/filter/src/Plugin/migrate/process/FilterID.php +++ b/core/modules/filter/src/Plugin/migrate/process/FilterID.php @@ -114,438 +114,216 @@ class FilterID extends StaticMap implements ContainerFactoryPluginInterface { * @see \Drupal\filter\Plugin\FilterInterface::getType() */ protected static function getSourceFilterType($filter_id) { - switch ($filter_id) { - // Drupal 7 core filters. - // - https://git.drupalcode.org/project/drupal/blob/7.69/modules/filter/filter.module#L1229 - // - https://git.drupalcode.org/project/drupal/blob/7.69/modules/php/php.module#L139 - case 'filter_html': - return FilterInterface::TYPE_HTML_RESTRICTOR; - - case 'filter_url': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - - case 'filter_autop': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - - case 'filter_htmlcorrector': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - - case 'filter_html_escape': - return FilterInterface::TYPE_HTML_RESTRICTOR; - - case 'php_code': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - + // Drupal 7 core filters. + // - https://git.drupalcode.org/project/drupal/blob/7.69/modules/filter/filter.module#L1229 + // - https://git.drupalcode.org/project/drupal/blob/7.69/modules/php/php.module#L139 + return match ($filter_id) { + 'filter_html' => FilterInterface::TYPE_HTML_RESTRICTOR, + 'filter_url' => FilterInterface::TYPE_MARKUP_LANGUAGE, + 'filter_autop' => FilterInterface::TYPE_MARKUP_LANGUAGE, + 'filter_htmlcorrector' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, + 'filter_html_escape' => FilterInterface::TYPE_HTML_RESTRICTOR, + 'php_code' => FilterInterface::TYPE_MARKUP_LANGUAGE, // Drupal 7 contrib filters. // https://www.drupal.org/project/abbrfilter - case 'abbrfilter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'abbrfilter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/ace_editor - case 'ace_editor': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'ace_editor' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/adsense - case 'adsense': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'adsense' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/api - case 'api_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'api_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/api_tokens - case 'api_tokens': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'api_tokens' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/autofloat - case 'filter_autofloat': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_autofloat' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/bbcode - case 'bbcode': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - + 'bbcode' => FilterInterface::TYPE_MARKUP_LANGUAGE, // https://www.drupal.org/project/biblio - case 'biblio_filter_reference': - case 'biblio_filter_inline_reference': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'biblio_filter_reference', 'biblio_filter_inline_reference' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/caption - case 'caption': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'caption' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/caption_filter - case 'caption_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'caption_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/cincopa - case 'filter_cincopa': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_cincopa' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/ckeditor_blocks - case 'ckeditor_blocks': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'ckeditor_blocks' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/ckeditor_filter - case 'ckeditor_filter': - return FilterInterface::TYPE_HTML_RESTRICTOR; - + 'ckeditor_filter' => FilterInterface::TYPE_HTML_RESTRICTOR, // https://www.drupal.org/project/ckeditor_link - case 'ckeditor_link_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'ckeditor_link_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/ckeditor_swf - case 'ckeditor_swf_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'ckeditor_swf_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/codefilter - case 'codefilter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'codefilter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/collapse_text - case 'collapse_text_filter': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'collapse_text_filter' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/columns_filter - case 'columns_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'columns_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/commonmark - case 'commonmark': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - + 'commonmark' => FilterInterface::TYPE_MARKUP_LANGUAGE, // https://www.drupal.org/project/commons_hashtags - case 'filter_hashtags': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_hashtags' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/deepzoom - case 'deepzoom': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'deepzoom' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/editor - case 'editor_align': - case 'editor_caption': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'editor_align', 'editor_caption' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/elf - case 'elf': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'elf' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/emogrifier - case 'filter_emogrifier': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_emogrifier' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/emptyparagraphkiller - case 'emptyparagraphkiller': - return FilterInterface::TYPE_HTML_RESTRICTOR; - + 'emptyparagraphkiller' => FilterInterface::TYPE_HTML_RESTRICTOR, // https://www.drupal.org/project/entity_embed - case 'entity_embed': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - - case 'filter_align': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'entity_embed' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, + 'filter_align' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/ext_link_page - case 'ext_link_page': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'ext_link_page' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/filter_html_image_secure - case 'filter_html_image_secure': - return FilterInterface::TYPE_HTML_RESTRICTOR; - + 'filter_html_image_secure' => FilterInterface::TYPE_HTML_RESTRICTOR, // https://www.drupal.org/project/filter_transliteration - case 'filter_transliteration': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_transliteration' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/flickr - case 'flickr_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'flickr_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/float_filter - case 'float_filter': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'float_filter' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/footnotes - case 'filter_footnotes': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_footnotes' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/forena - case 'forena_report': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'forena_report' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/g2 - case 'filter_g2': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_g2' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/geo_filter - case 'geo_filter_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'geo_filter_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/google_analytics_counter - case 'filter_google_analytics_counter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_google_analytics_counter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/google_analytics_referrer - case 'filter_google_analytics_referrer': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_google_analytics_referrer' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/gotwo - case 'gotwo_link': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'gotwo_link' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/h5p - case 'h5p_content': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'h5p_content' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/highlightjs - case 'highlight_js': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'highlight_js' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/htmLawed - case 'htmLawed': - return FilterInterface::TYPE_HTML_RESTRICTOR; - + 'htmLawed' => FilterInterface::TYPE_HTML_RESTRICTOR, // https://www.drupal.org/project/htmlpurifier - case 'htmlpurifier_basic': - case 'htmlpurifier_advanced': - return FilterInterface::TYPE_HTML_RESTRICTOR; - + 'htmlpurifier_basic', 'htmlpurifier_advanced' => FilterInterface::TYPE_HTML_RESTRICTOR, // https://www.drupal.org/project/htmltidy - case 'htmltidy': - return FilterInterface::TYPE_HTML_RESTRICTOR; - + 'htmltidy' => FilterInterface::TYPE_HTML_RESTRICTOR, // https://www.drupal.org/project/icon - case 'icon_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'icon_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/iframe_filter - case 'iframe': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'iframe' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/image_resize_filter - case 'image_resize_filter': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'image_resize_filter' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/insert_view - case 'insert_view': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'insert_view' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/intlinks - case 'intlinks title': - case 'intlinks hide bad': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'intlinks title', 'intlinks hide bad' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/jquery_ui_filter - case 'accordion': - case 'dialog': - case 'tabs': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - + 'accordion', 'dialog', 'tabs' => FilterInterface::TYPE_MARKUP_LANGUAGE, // https://www.drupal.org/project/language_sections - case 'language_sections': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - + 'language_sections' => FilterInterface::TYPE_MARKUP_LANGUAGE, // https://www.drupal.org/project/lazy - case 'lazy_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'lazy_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/lazyloader_filter - case 'lazyloader_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'lazyloader_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/link_node - case 'filter_link_node': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_link_node' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/linktitle - case 'linktitle': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'linktitle' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/markdown - case 'filter_markdown': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - + 'filter_markdown' => FilterInterface::TYPE_MARKUP_LANGUAGE, // https://www.drupal.org/project/media_wysiwyg - case 'media_filter': - case 'media_filter_paragraph_fix': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'media_filter', 'media_filter_paragraph_fix' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/mentions - case 'filter_mentions': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_mentions' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/menu_filter - case 'menu_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'menu_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/mobile_codes - case 'mobile_codes': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'mobile_codes' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/multicolumn - case 'multicolumn': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'multicolumn' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/multilink - case 'multilink_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'multilink_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/mytube - case 'mytube': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'mytube' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/node_embed - case 'node_embed': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'node_embed' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/node_field_embed - case 'node_field_embed': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'node_field_embed' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/noindex_external_links - case 'external_links': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'external_links' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/noreferrer - case 'noreferrer': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'noreferrer' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/oembed - case 'oembed': - case 'oembed_legacy': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'oembed', 'oembed_legacy' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/office_html - case 'office_html_strip': - return FilterInterface::TYPE_HTML_RESTRICTOR; - - case 'office_html_convert': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'office_html_strip' => FilterInterface::TYPE_HTML_RESTRICTOR, + 'office_html_convert' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/openlayers_filters - case 'openlayers': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'openlayers' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/opengraph_filter - case 'opengraph_filter': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'opengraph_filter' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/pathologic - case 'pathologic': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'pathologic' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/popup - case 'popup_tags': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'popup_tags' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/prettify - case 'prettify': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'prettify' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/rel_to_abs - case 'rel_to_abs': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'rel_to_abs' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/rollover_filter - case 'rollover_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'rollover_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/sanitizable - case 'sanitizable': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'sanitizable' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/smart_paging - case 'smart_paging_filter': - case 'smart_paging_filter_autop': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'smart_paging_filter', 'smart_paging_filter_autop' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/spamspan - case 'spamspan': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'spamspan' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/scald - case 'mee_scald_widgets': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'mee_scald_widgets' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/script_filter - case 'script_filter': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'script_filter' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/shortcode - case 'shortcode': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - - case 'shortcode_text_corrector': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'shortcode' => FilterInterface::TYPE_MARKUP_LANGUAGE, + 'shortcode_text_corrector' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/smiley - case 'smiley': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'smiley' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/svg_embed - case 'filter_svg_embed': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_svg_embed' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/spoiler - case 'spoiler': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'spoiler' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/tableofcontents - case 'filter_toc': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_toc' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/tables - case 'filter_tables': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_tables' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/target_filter_url - case 'target_filter_url': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'target_filter_url' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/textile - case 'textile': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - + 'textile' => FilterInterface::TYPE_MARKUP_LANGUAGE, // https://www.drupal.org/project/theme_filter - case 'theme_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'theme_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/token_filter - case 'filter_tokens': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'filter_tokens' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/transliteration - case 'transliteration': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'transliteration' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/typogrify - case 'typogrify': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'typogrify' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/uuid_link - case 'uuid_link_filter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'uuid_link_filter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/wysiwyg - case 'wysiwyg': - case 'wysiwyg_template_cleanup': - return FilterInterface::TYPE_HTML_RESTRICTOR; - + 'wysiwyg', 'wysiwyg_template_cleanup' => FilterInterface::TYPE_HTML_RESTRICTOR, // https://www.drupal.org/project/word_link - case 'word_link': - return FilterInterface::TYPE_TRANSFORM_REVERSIBLE; - + 'word_link' => FilterInterface::TYPE_TRANSFORM_REVERSIBLE, // https://www.drupal.org/project/wordfilter - case 'wordfilter': - return FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE; - + 'wordfilter' => FilterInterface::TYPE_TRANSFORM_IRREVERSIBLE, // https://www.drupal.org/project/xbbcode - case 'xbbcode': - return FilterInterface::TYPE_MARKUP_LANGUAGE; - } - - return NULL; + 'xbbcode' => FilterInterface::TYPE_MARKUP_LANGUAGE, + default => NULL, + }; } } diff --git a/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPlaceholders.php b/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPlaceholders.php index e1ddf552187..862239599b0 100644 --- a/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPlaceholders.php +++ b/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPlaceholders.php @@ -30,7 +30,7 @@ class FilterTestPlaceholders extends FilterBase implements TrustedCallbackInterf $result = new FilterProcessResult($text); $placeholder_with_argument = $result->createPlaceholder('\Drupal\filter_test\Plugin\Filter\FilterTestPlaceholders::renderDynamicThing', ['llama']); $placeholder_without_arguments = $result->createPlaceholder('\Drupal\filter_test\Plugin\Filter\FilterTestPlaceholders::renderStaticThing', []); - $result->setProcessedText($text . '<p>' . $placeholder_with_argument . '</p>' . '<p>' . $placeholder_without_arguments . '</p>'); + $result->setProcessedText($text . '<p>' . $placeholder_with_argument . '</p><p>' . $placeholder_without_arguments . '</p>'); return $result; } 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/ImageEffectBase.php b/core/modules/image/src/ImageEffectBase.php index 58be370c1e6..745976133be 100644 --- a/core/modules/image/src/ImageEffectBase.php +++ b/core/modules/image/src/ImageEffectBase.php @@ -3,7 +3,7 @@ namespace Drupal\image; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\Plugin\PluginBase; +use Drupal\Core\Plugin\ConfigurablePluginBase; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * @see \Drupal\image\ImageEffectManager * @see plugin_api */ -abstract class ImageEffectBase extends PluginBase implements ImageEffectInterface, ContainerFactoryPluginInterface { +abstract class ImageEffectBase extends ConfigurablePluginBase implements ImageEffectInterface, ContainerFactoryPluginInterface { /** * The image effect ID. @@ -46,7 +46,6 @@ abstract class ImageEffectBase extends PluginBase implements ImageEffectInterfac public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger) { parent::__construct($configuration, $plugin_id, $plugin_definition); - $this->setConfiguration($configuration); $this->logger = $logger; } @@ -154,13 +153,6 @@ abstract class ImageEffectBase extends PluginBase implements ImageEffectInterfac /** * {@inheritdoc} */ - public function defaultConfiguration() { - return []; - } - - /** - * {@inheritdoc} - */ public function calculateDependencies() { return []; } diff --git a/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php b/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php index 7ed6b0d3371..72937d4e79a 100644 --- a/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php +++ b/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php @@ -389,7 +389,9 @@ class ImageItem extends FileItem { $image->setFileName($file_system->basename($path)); $destination_dir = static::doGetUploadLocation($settings); $file_system->prepareDirectory($destination_dir, FileSystemInterface::CREATE_DIRECTORY); - $destination = $destination_dir . '/' . basename($path); + // Ensure directory ends with a slash. + $destination_dir .= str_ends_with($destination_dir, '/') ? '' : '/'; + $destination = $destination_dir . basename($path); $file = \Drupal::service('file.repository')->move($image, $destination); $images[$extension][$min_resolution][$max_resolution][$file->id()] = $file; } 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/FunctionalJavascript/ImageAdminStylesTest.php b/core/modules/image/tests/src/FunctionalJavascript/ImageAdminStylesTest.php index 2da7f3edd9b..a417b1727ca 100644 --- a/core/modules/image/tests/src/FunctionalJavascript/ImageAdminStylesTest.php +++ b/core/modules/image/tests/src/FunctionalJavascript/ImageAdminStylesTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\image\FunctionalJavascript; use Drupal\image\Entity\ImageStyle; +use PHPUnit\Framework\Attributes\Group; /** * Tests creation, deletion, and editing of image styles and effects. - * - * @group image */ +#[Group('image')] class ImageAdminStylesTest extends ImageFieldTestBase { /** diff --git a/core/modules/image/tests/src/FunctionalJavascript/ImageFieldValidateTest.php b/core/modules/image/tests/src/FunctionalJavascript/ImageFieldValidateTest.php index 9c9839b15c6..4e4f0021e3e 100644 --- a/core/modules/image/tests/src/FunctionalJavascript/ImageFieldValidateTest.php +++ b/core/modules/image/tests/src/FunctionalJavascript/ImageFieldValidateTest.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Drupal\Tests\image\FunctionalJavascript; -use Drupal\field\Entity\FieldStorageConfig; use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use PHPUnit\Framework\Attributes\Group; /** * Tests validation functions such as min/max dimensions. - * - * @group image */ +#[Group('image')] class ImageFieldValidateTest extends ImageFieldTestBase { /** diff --git a/core/modules/image/tests/src/FunctionalJavascript/ImageFieldWidgetMultipleTest.php b/core/modules/image/tests/src/FunctionalJavascript/ImageFieldWidgetMultipleTest.php index ac253b33b39..8c1a637e93b 100644 --- a/core/modules/image/tests/src/FunctionalJavascript/ImageFieldWidgetMultipleTest.php +++ b/core/modules/image/tests/src/FunctionalJavascript/ImageFieldWidgetMultipleTest.php @@ -9,12 +9,12 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\node\Entity\Node; use Drupal\Tests\image\Kernel\ImageFieldCreationTrait; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests the image field widget support multiple upload correctly. - * - * @group image */ +#[Group('image')] class ImageFieldWidgetMultipleTest extends WebDriverTestBase { use ImageFieldCreationTrait; 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/image/tests/src/Kernel/ImageItemTest.php b/core/modules/image/tests/src/Kernel/ImageItemTest.php index 0598b25e9db..55f2503686d 100644 --- a/core/modules/image/tests/src/Kernel/ImageItemTest.php +++ b/core/modules/image/tests/src/Kernel/ImageItemTest.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\ContentEntityForm; use Drupal\Core\Entity\Entity\EntityFormDisplay; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; @@ -19,6 +20,7 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\file\Entity\File; use Drupal\Tests\field\Kernel\FieldKernelTestBase; +use Drupal\image\Plugin\Field\FieldType\ImageItem; use Drupal\user\Entity\Role; /** @@ -208,6 +210,14 @@ class ImageItemTest extends FieldKernelTestBase { } /** + * Tests image URIs for empty and custom directories. + */ + public function testImageUriDirectories(): void { + $this->validateImageUriForDirectory('', 'public://'); + $this->validateImageUriForDirectory('custom_directory/subdir', 'public://custom_directory/subdir/'); + } + + /** * Tests display_default. */ public function testDisplayDefaultValue(): void { @@ -225,4 +235,36 @@ class ImageItemTest extends FieldKernelTestBase { self::assertEquals(1, $form_state->getValue(['image_test', 0, 'display'])); } + /** + * Validates the image file URI generated for a given file directory. + * + * @param string $file_directory + * The file directory to test (e.g., empty or 'custom_directory/subdir'). + * @param string $expected_start + * The expected starting string of the file URI (e.g., 'public://'). + */ + private function validateImageUriForDirectory(string $file_directory, string $expected_start): void { + // Mock the field definition with the specified file directory. + $definition = $this->createMock(FieldDefinitionInterface::class); + $definition->expects($this->any()) + ->method('getSettings') + ->willReturn([ + 'file_extensions' => 'jpg', + 'file_directory' => $file_directory, + 'uri_scheme' => 'public', + ]); + // Generate sample value and check the URI format. + $value = ImageItem::generateSampleValue($definition); + $this->assertNotEmpty($value); + + // Load the file entity and get its URI. + $fid = $value['target_id']; + $file = File::load($fid); + $fileUri = $file->getFileUri(); + + // Verify the file URI starts with the expected protocol and structure. + $this->assertStringStartsWith($expected_start, $fileUri); + $this->assertMatchesRegularExpression('#^' . preg_quote($expected_start, '#') . '[^/]+#', $fileUri); + } + } diff --git a/core/modules/image/tests/src/Unit/ImageStyleTest.php b/core/modules/image/tests/src/Unit/ImageStyleTest.php index d247642b3cd..8b877753fef 100644 --- a/core/modules/image/tests/src/Unit/ImageStyleTest.php +++ b/core/modules/image/tests/src/Unit/ImageStyleTest.php @@ -174,8 +174,8 @@ class ImageStyleTest extends UnitTestCase { // Assert the extension has been added to the URI before creating the token. $this->assertEquals($image_style->getPathToken('public://test.jpeg.png'), $image_style->getPathToken('public://test.jpeg')); - $this->assertEquals(substr(Crypt::hmacBase64($image_style->id() . ':' . 'public://test.jpeg.png', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); - $this->assertNotEquals(substr(Crypt::hmacBase64($image_style->id() . ':' . 'public://test.jpeg', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); + $this->assertEquals(substr(Crypt::hmacBase64($image_style->id() . ':public://test.jpeg.png', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); + $this->assertNotEquals(substr(Crypt::hmacBase64($image_style->id() . ':public://test.jpeg', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); // Image style that doesn't change the extension. $image_effect_id = $this->randomMachineName(); @@ -195,8 +195,8 @@ class ImageStyleTest extends UnitTestCase { ->willReturn($hash_salt); // Assert no extension has been added to the uri before creating the token. $this->assertNotEquals($image_style->getPathToken('public://test.jpeg.png'), $image_style->getPathToken('public://test.jpeg')); - $this->assertNotEquals(substr(Crypt::hmacBase64($image_style->id() . ':' . 'public://test.jpeg.png', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); - $this->assertEquals(substr(Crypt::hmacBase64($image_style->id() . ':' . 'public://test.jpeg', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); + $this->assertNotEquals(substr(Crypt::hmacBase64($image_style->id() . ':public://test.jpeg.png', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); + $this->assertEquals(substr(Crypt::hmacBase64($image_style->id() . ':public://test.jpeg', $private_key . $hash_salt), 0, 8), $image_style->getPathToken('public://test.jpeg')); } /** diff --git a/core/modules/inline_form_errors/tests/src/FunctionalJavascript/FormErrorHandlerCKEditor5Test.php b/core/modules/inline_form_errors/tests/src/FunctionalJavascript/FormErrorHandlerCKEditor5Test.php index 340423c71be..063356bc161 100644 --- a/core/modules/inline_form_errors/tests/src/FunctionalJavascript/FormErrorHandlerCKEditor5Test.php +++ b/core/modules/inline_form_errors/tests/src/FunctionalJavascript/FormErrorHandlerCKEditor5Test.php @@ -12,12 +12,12 @@ use Drupal\filter\Entity\FilterFormat; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\node\Entity\NodeType; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; /** * Tests the inline errors fragment link to a CKEditor5-enabled textarea. - * - * @group ckeditor5 */ +#[Group('ckeditor5')] class FormErrorHandlerCKEditor5Test extends WebDriverTestBase { /** diff --git a/core/modules/jsonapi/jsonapi.api.php b/core/modules/jsonapi/jsonapi.api.php index 5b2f2002d25..ca8c5ae993f 100644 --- a/core/modules/jsonapi/jsonapi.api.php +++ b/core/modules/jsonapi/jsonapi.api.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Access\AccessResult; +use Drupal\jsonapi\JsonApiFilter; /** * @defgroup jsonapi_architecture JSON:API Architecture @@ -263,10 +264,10 @@ use Drupal\Core\Access\AccessResult; * viewable. * - AccessResult::neutral() if the implementation has no opinion. * The supported subsets for which an access result may be returned are: - * - JSONAPI_FILTER_AMONG_ALL: all entities of the given type. - * - JSONAPI_FILTER_AMONG_PUBLISHED: all published entities of the given type. - * - JSONAPI_FILTER_AMONG_ENABLED: all enabled entities of the given type. - * - JSONAPI_FILTER_AMONG_OWN: all entities of the given type owned by the + * - JsonApiFilter::AMONG_ALL: all entities of the given type. + * - JsonApiFilter::AMONG_PUBLISHED: all published entities of the given type. + * - JsonApiFilter::AMONG_ENABLED: all enabled entities of the given type. + * - JsonApiFilter::AMONG_OWN: all entities of the given type owned by the * user for whom access is being checked. * See the documentation of the above constants for more information about * each subset. @@ -278,7 +279,7 @@ function hook_jsonapi_entity_filter_access(EntityTypeInterface $entity_type, Acc // by all entities of that type to users with that permission. if ($admin_permission = $entity_type->getAdminPermission()) { return ([ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission), ]); } } @@ -305,9 +306,9 @@ function hook_jsonapi_entity_filter_access(EntityTypeInterface $entity_type, Acc */ function hook_jsonapi_ENTITY_TYPE_filter_access(EntityTypeInterface $entity_type, AccountInterface $account): array { return ([ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer llamas'), - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view all published llamas'), - JSONAPI_FILTER_AMONG_OWN => AccessResult::allowedIfHasPermissions($account, ['view own published llamas', 'view own unpublished llamas'], 'AND'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer llamas'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view all published llamas'), + JsonApiFilter::AMONG_OWN => AccessResult::allowedIfHasPermissions($account, ['view own published llamas', 'view own unpublished llamas'], 'AND'), ]); } diff --git a/core/modules/jsonapi/jsonapi.module b/core/modules/jsonapi/jsonapi.module index 69414af650b..c512575305a 100644 --- a/core/modules/jsonapi/jsonapi.module +++ b/core/modules/jsonapi/jsonapi.module @@ -11,6 +11,10 @@ * regardless of whether they are published or enabled, and regardless of * their owner. * + * @deprecated in drupal:11.3.0 and is removed from drupal:13.0.0. Use + * \Drupal\jsonapi\JsonApiFilter::AMONG_ALL instead. + * + * @see https://www.drupal.org/node/3495601 * @see hook_jsonapi_entity_filter_access() * @see hook_jsonapi_ENTITY_TYPE_filter_access() */ @@ -25,6 +29,10 @@ const JSONAPI_FILTER_AMONG_ALL = 'filter_among_all'; * This is used when an entity type has a "published" entity key and there's a * query condition for the value of that equaling 1. * + * @deprecated in drupal:11.3.0 and is removed from drupal:13.0.0. Use + * \Drupal\jsonapi\JsonApiFilter::AMONG_PUBLISHED instead. + * + * @see https://www.drupal.org/node/3495601 * @see hook_jsonapi_entity_filter_access() * @see hook_jsonapi_ENTITY_TYPE_filter_access() */ @@ -42,6 +50,10 @@ const JSONAPI_FILTER_AMONG_PUBLISHED = 'filter_among_published'; * For the User entity type, which does not have a "status" entity key, the * "status" field is used. * + * @deprecated in drupal:11.3.0 and is removed from drupal:13.0.0. Use + * \Drupal\jsonapi\JsonApiFilter::AMONG_ENABLED instead. + * + * @see https://www.drupal.org/node/3495601 * @see hook_jsonapi_entity_filter_access() * @see hook_jsonapi_ENTITY_TYPE_filter_access() */ @@ -64,6 +76,10 @@ const JSONAPI_FILTER_AMONG_ENABLED = 'filter_among_enabled'; * - The entity type has an "owner" entity key. * - There's a filter/query condition for the value equal to the user's ID. * + * @deprecated in drupal:11.3.0 and is removed from drupal:13.0.0. Use + * \Drupal\jsonapi\JsonApiFilter::AMONG_OWN instead. + * + * @see https://www.drupal.org/node/3495601 * @see hook_jsonapi_entity_filter_access() * @see hook_jsonapi_ENTITY_TYPE_filter_access() */ diff --git a/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php b/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php index d59dca4ec03..2888fbbec77 100644 --- a/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php +++ b/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php @@ -12,6 +12,7 @@ use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\DataReferenceDefinitionInterface; +use Drupal\jsonapi\JsonApiFilter; use Drupal\jsonapi\Query\EntityCondition; use Drupal\jsonapi\Query\EntityConditionGroup; use Drupal\jsonapi\Query\Filter; @@ -323,12 +324,12 @@ class TemporaryQueryGuard { } /** - * Gets an access condition for the allowed JSONAPI_FILTER_AMONG_* subsets. + * Gets an access condition for the allowed JsonApiFilter::AMONG_* subsets. * - * If access is allowed for the JSONAPI_FILTER_AMONG_ALL subset, then no + * If access is allowed for the JsonApiFilter::AMONG_ALL subset, then no * conditions are returned. Otherwise, if access is allowed for - * JSONAPI_FILTER_AMONG_PUBLISHED, JSONAPI_FILTER_AMONG_ENABLED, or - * JSONAPI_FILTER_AMONG_OWN, then a condition group is returned for the union + * JsonApiFilter::AMONG_PUBLISHED, JsonApiFilter::AMONG_ENABLED, or + * JsonApiFilter::AMONG_OWN, then a condition group is returned for the union * of allowed subsets. If no subsets are allowed, then static::alwaysFalse() * is returned. * @@ -344,12 +345,12 @@ class TemporaryQueryGuard { * secure an entity query. */ protected static function getAccessConditionForKnownSubsets(EntityTypeInterface $entity_type, AccountInterface $account, CacheableMetadata $cacheability) { - // Get the combined access results for each JSONAPI_FILTER_AMONG_* subset. + // Get the combined access results for each JsonApiFilter::AMONG_* subset. $access_results = static::getAccessResultsFromEntityFilterHook($entity_type, $account); // No conditions are needed if access is allowed for all entities. - $cacheability->addCacheableDependency($access_results[JSONAPI_FILTER_AMONG_ALL]); - if ($access_results[JSONAPI_FILTER_AMONG_ALL]->isAllowed()) { + $cacheability->addCacheableDependency($access_results[JsonApiFilter::AMONG_ALL]); + if ($access_results[JsonApiFilter::AMONG_ALL]->isAllowed()) { return NULL; } @@ -363,7 +364,7 @@ class TemporaryQueryGuard { // The "published" subset. $published_field_name = $entity_type->getKey('published'); if ($published_field_name) { - $access_result = $access_results[JSONAPI_FILTER_AMONG_PUBLISHED]; + $access_result = $access_results[JsonApiFilter::AMONG_PUBLISHED]; $cacheability->addCacheableDependency($access_result); if ($access_result->isAllowed()) { $conditions[] = new EntityCondition($published_field_name, 1); @@ -375,7 +376,7 @@ class TemporaryQueryGuard { // @todo Remove ternary when the 'status' key is added to the User entity type. $status_field_name = $entity_type->id() === 'user' ? 'status' : $entity_type->getKey('status'); if ($status_field_name) { - $access_result = $access_results[JSONAPI_FILTER_AMONG_ENABLED]; + $access_result = $access_results[JsonApiFilter::AMONG_ENABLED]; $cacheability->addCacheableDependency($access_result); if ($access_result->isAllowed()) { $conditions[] = new EntityCondition($status_field_name, 1); @@ -387,7 +388,7 @@ class TemporaryQueryGuard { // @todo Remove ternary when the 'uid' key is added to the User entity type. $owner_field_name = $entity_type->id() === 'user' ? 'uid' : $entity_type->getKey('owner'); if ($owner_field_name) { - $access_result = $access_results[JSONAPI_FILTER_AMONG_OWN]; + $access_result = $access_results[JsonApiFilter::AMONG_OWN]; $cacheability->addCacheableDependency($access_result); if ($access_result->isAllowed()) { $cacheability->addCacheContexts(['user']); @@ -415,7 +416,7 @@ class TemporaryQueryGuard { } /** - * Gets the combined access result for each JSONAPI_FILTER_AMONG_* subset. + * Gets the combined access result for each JsonApiFilter::AMONG_* subset. * * This invokes hook_jsonapi_entity_filter_access() and * hook_jsonapi_ENTITY_TYPE_filter_access() and combines the results from all @@ -433,10 +434,10 @@ class TemporaryQueryGuard { protected static function getAccessResultsFromEntityFilterHook(EntityTypeInterface $entity_type, AccountInterface $account) { /** @var \Drupal\Core\Access\AccessResultInterface[] $combined_access_results */ $combined_access_results = [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::neutral(), - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::neutral(), - JSONAPI_FILTER_AMONG_ENABLED => AccessResult::neutral(), - JSONAPI_FILTER_AMONG_OWN => AccessResult::neutral(), + JsonApiFilter::AMONG_ALL => AccessResult::neutral(), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::neutral(), + JsonApiFilter::AMONG_ENABLED => AccessResult::neutral(), + JsonApiFilter::AMONG_OWN => AccessResult::neutral(), ]; // Invoke hook_jsonapi_entity_filter_access() and diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php index 6416d6cb27a..53822cdf1f1 100644 --- a/core/modules/jsonapi/src/Controller/EntityResource.php +++ b/core/modules/jsonapi/src/Controller/EntityResource.php @@ -672,9 +672,10 @@ class EntityResource { return $this->getRelationship($resource_type, $entity, $related, $request, $status); } - $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName(); foreach ($new_resource_identifiers as $new_resource_identifier) { - $new_field_value = [$main_property_name => $this->getEntityFromResourceIdentifier($new_resource_identifier)->id()]; + // We assume all entity reference fields have an 'entity' computed + // property that can be used to assign the needed values. + $new_field_value = ['entity' => $this->getEntityFromResourceIdentifier($new_resource_identifier)]; // Remove `arity` from the received extra properties, otherwise this // will fail field validation. $new_field_value += array_diff_key($new_resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY])); @@ -760,9 +761,10 @@ class EntityResource { * The field definition of the entity field to be updated. */ protected function doPatchMultipleRelationship(EntityInterface $entity, array $resource_identifiers, FieldDefinitionInterface $field_definition) { - $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName(); - $entity->{$field_definition->getName()} = array_map(function (ResourceIdentifier $resource_identifier) use ($main_property_name) { - $field_properties = [$main_property_name => $this->getEntityFromResourceIdentifier($resource_identifier)->id()]; + $entity->{$field_definition->getName()} = array_map(function (ResourceIdentifier $resource_identifier) { + // We assume all entity reference fields have an 'entity' computed + // property that can be used to assign the needed values. + $field_properties = ['entity' => $this->getEntityFromResourceIdentifier($resource_identifier)]; // Remove `arity` from the received extra properties, otherwise this // will fail field validation. $field_properties += array_diff_key($resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY])); diff --git a/core/modules/jsonapi/src/Hook/JsonapiHooks.php b/core/modules/jsonapi/src/Hook/JsonapiHooks.php index 7db5b77b0e8..d5f6d2540fc 100644 --- a/core/modules/jsonapi/src/Hook/JsonapiHooks.php +++ b/core/modules/jsonapi/src/Hook/JsonapiHooks.php @@ -7,6 +7,7 @@ use Drupal\Core\Session\AccountInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\jsonapi\JsonApiFilter; use Drupal\jsonapi\Routing\Routes; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Hook\Attribute\Hook; @@ -107,7 +108,7 @@ class JsonapiHooks { // AccessResult::forbidden() from its implementation of this hook. if ($admin_permission = $entity_type->getAdminPermission()) { return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission), ]; } return []; @@ -122,8 +123,8 @@ class JsonapiHooks { // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for // (isReusable()), so this does not have to. return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access block library'), - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowed(), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access block library'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowed(), ]; } @@ -136,8 +137,8 @@ class JsonapiHooks { // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for // (access to the commented entity), so this does not have to. return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer comments'), - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access comments'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer comments'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access comments'), ]; } @@ -148,7 +149,7 @@ class JsonapiHooks { public function jsonapiEntityTestFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\entity_test\EntityTestAccessControlHandler::checkAccess() return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view test entity'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view test entity'), ]; } @@ -161,7 +162,7 @@ class JsonapiHooks { // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for // (public OR owner), so this does not have to. return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access content'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access content'), ]; } @@ -172,7 +173,7 @@ class JsonapiHooks { public function jsonapiMediaFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\media\MediaAccessControlHandler::checkAccess() return [ - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view media'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view media'), ]; } @@ -184,30 +185,30 @@ class JsonapiHooks { // @see \Drupal\node\NodeAccessControlHandler::access() if ($account->hasPermission('bypass node access')) { return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowed()->cachePerPermissions(), + JsonApiFilter::AMONG_ALL => AccessResult::allowed()->cachePerPermissions(), ]; } if (!$account->hasPermission('access content')) { $forbidden = AccessResult::forbidden("The 'access content' permission is required.")->cachePerPermissions(); return [ - JSONAPI_FILTER_AMONG_ALL => $forbidden, - JSONAPI_FILTER_AMONG_OWN => $forbidden, - JSONAPI_FILTER_AMONG_PUBLISHED => $forbidden, + JsonApiFilter::AMONG_ALL => $forbidden, + JsonApiFilter::AMONG_OWN => $forbidden, + JsonApiFilter::AMONG_PUBLISHED => $forbidden, // For legacy reasons, the Node entity type has a "status" key, so // forbid this subset as well, even though it has no semantic meaning. - JSONAPI_FILTER_AMONG_ENABLED => $forbidden, + JsonApiFilter::AMONG_ENABLED => $forbidden, ]; } return [ - // @see \Drupal\node\NodeAccessControlHandler::checkAccess() - JSONAPI_FILTER_AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own unpublished content'), - // @see \Drupal\node\NodeGrantDatabaseStorage::access() - // Note that: - // - This is just for the default grant. Other node access conditions - // are added via the 'node_access' query tag. - // - Permissions were checked earlier in this function, so we must - // vary the cache by them. - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowed()->cachePerPermissions(), + // @see \Drupal\node\NodeAccessControlHandler::checkAccess() + JsonApiFilter::AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own unpublished content'), + // @see \Drupal\node\NodeGrantDatabaseStorage::access() + // Note that: + // - This is just for the default grant. Other node access conditions + // are added via the 'node_access' query tag. + // - Permissions were checked earlier in this function, so we must + // vary the cache by them. + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowed()->cachePerPermissions(), ]; } @@ -221,7 +222,7 @@ class JsonapiHooks { // "shortcut_set = $shortcut_set_storage->getDisplayedToUser($current_user)" // so this does not have to. return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer shortcuts')->orIf(AccessResult::allowedIfHasPermissions($account, [ + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer shortcuts')->orIf(AccessResult::allowedIfHasPermissions($account, [ 'access shortcuts', 'customize shortcut links', ])), @@ -235,8 +236,8 @@ class JsonapiHooks { public function jsonapiTaxonomyTermFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\taxonomy\TermAccessControlHandler::checkAccess() return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer taxonomy'), - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access content'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer taxonomy'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access content'), ]; } @@ -249,8 +250,8 @@ class JsonapiHooks { // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for // (!isAnonymous()), so this does not have to. return [ - JSONAPI_FILTER_AMONG_OWN => AccessResult::allowed(), - JSONAPI_FILTER_AMONG_ENABLED => AccessResult::allowedIfHasPermission($account, 'access user profiles'), + JsonApiFilter::AMONG_OWN => AccessResult::allowed(), + JsonApiFilter::AMONG_ENABLED => AccessResult::allowedIfHasPermission($account, 'access user profiles'), ]; } @@ -261,8 +262,8 @@ class JsonapiHooks { public function jsonapiWorkspaceFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\workspaces\WorkspaceAccessControlHandler::checkAccess() return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view any workspace'), - JSONAPI_FILTER_AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own workspace'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view any workspace'), + JsonApiFilter::AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own workspace'), ]; } 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/jsonapi/src/JsonApiFilter.php b/core/modules/jsonapi/src/JsonApiFilter.php new file mode 100644 index 00000000000..c9ac90be7af --- /dev/null +++ b/core/modules/jsonapi/src/JsonApiFilter.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\jsonapi; + +/** + * JsonApi filter options. + */ +final class JsonApiFilter { + + /** + * Array key for denoting type-based filtering access. + * + * Array key for denoting access to filter among all entities of a given type, + * regardless of whether they are published or enabled, and regardless of + * their owner. + * + * @see hook_jsonapi_entity_filter_access() + * @see hook_jsonapi_ENTITY_TYPE_filter_access() + */ + const AMONG_ALL = 'filter_among_all'; + + /** + * Array key for denoting type-based published-only filtering access. + * + * Array key for denoting access to filter among all published entities of a + * given type, regardless of their owner. + * + * This is used when an entity type has a "published" entity key and there's a + * query condition for the value of that equaling 1. + * + * @see hook_jsonapi_entity_filter_access() + * @see hook_jsonapi_ENTITY_TYPE_filter_access() + */ + const AMONG_PUBLISHED = 'filter_among_published'; + + /** + * Array key for denoting type-based enabled-only filtering access. + * + * Array key for denoting access to filter among all enabled entities of a + * given type, regardless of their owner. + * + * This is used when an entity type has a "status" entity key and there's a + * query condition for the value of that equaling 1. + * + * For the User entity type, which does not have a "status" entity key, the + * "status" field is used. + * + * @see hook_jsonapi_entity_filter_access() + * @see hook_jsonapi_ENTITY_TYPE_filter_access() + */ + const AMONG_ENABLED = 'filter_among_enabled'; + + /** + * Array key for denoting type-based owned-only filtering access. + * + * Array key for denoting access to filter among all entities of a given type, + * regardless of whether they are published or enabled, so long as they are + * owned by the user for whom access is being checked. + * + * When filtering among User entities, this is used when access is being + * checked for an authenticated user and there's a query condition + * limiting the result set to just that user's entity object. + * + * When filtering among entities of another type, this is used when all of the + * following conditions are met: + * - Access is being checked for an authenticated user. + * - The entity type has an "owner" entity key. + * - There's a filter/query condition for the value equal to the user's ID. + * + * @see hook_jsonapi_entity_filter_access() + * @see hook_jsonapi_ENTITY_TYPE_filter_access() + */ + const AMONG_OWN = 'filter_among_own'; + +} diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/config/schema/jsonapi_test_field_type.schema.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/config/schema/jsonapi_test_field_type.schema.yml new file mode 100644 index 00000000000..9d082c7649c --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/config/schema/jsonapi_test_field_type.schema.yml @@ -0,0 +1,11 @@ +field.storage_settings.jsonapi_test_field_type_entity_reference_uuid: + type: field.storage_settings.entity_reference + label: 'Entity reference field storage settings' + +field.field_settings.jsonapi_test_field_type_entity_reference_uuid: + type: field.field_settings.entity_reference + label: 'Entity reference field settings' + +field.value.jsonapi_test_field_type_entity_reference_uuid: + type: field.value.entity_reference + label: 'Default value' diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Plugin/Field/FieldType/EntityReferenceUuidItem.php b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Plugin/Field/FieldType/EntityReferenceUuidItem.php new file mode 100644 index 00000000000..f9aa3e77bbe --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Plugin/Field/FieldType/EntityReferenceUuidItem.php @@ -0,0 +1,241 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\jsonapi_test_field_type\Plugin\Field\FieldType; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\TypedData\EntityDataDefinition; +use Drupal\Core\Field\Attribute\FieldType; +use Drupal\Core\Field\EntityReferenceFieldItemList; +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\DataReferenceDefinition; +use Drupal\Core\TypedData\DataReferenceTargetDefinition; + +/** + * Defines the 'entity_reference_uuid' entity field type. + * + * Supported settings (below the definition's 'settings' key) are: + * - target_type: The entity type to reference. Required. + * + * @property string $target_uuid + */ +#[FieldType( + id: 'jsonapi_test_field_type_entity_reference_uuid', + label: new TranslatableMarkup('Entity reference UUID'), + description: new TranslatableMarkup('An entity field containing an entity reference by UUID.'), + category: 'reference', + default_widget: 'entity_reference_autocomplete', + default_formatter: 'entity_reference_label', + list_class: EntityReferenceFieldItemList::class, +)] +class EntityReferenceUuidItem extends EntityReferenceItem { + + /** + * {@inheritdoc} + */ + public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) { + $settings = $field_definition->getSettings(); + $target_type_info = \Drupal::entityTypeManager()->getDefinition($settings['target_type']); + + $properties = parent::propertyDefinitions($field_definition); + + $target_uuid_definition = DataReferenceTargetDefinition::create('string') + ->setLabel(new TranslatableMarkup('@label UUID', ['@label' => $target_type_info->getLabel()])); + + $target_uuid_definition->setRequired(TRUE); + $properties['target_uuid'] = $target_uuid_definition; + + $properties['entity'] = DataReferenceDefinition::create('entity') + ->setLabel($target_type_info->getLabel()) + ->setDescription(new TranslatableMarkup('The referenced entity by UUID')) + // The entity object is computed out of the entity ID. + ->setComputed(TRUE) + ->setReadOnly(FALSE) + ->setTargetDefinition(EntityDataDefinition::create($settings['target_type'])) + // We can add a constraint for the target entity type. The list of + // referenceable bundles is a field setting, so the corresponding + // constraint is added dynamically in ::getConstraints(). + ->addConstraint('EntityType', $settings['target_type']); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function mainPropertyName() { + return 'target_uuid'; + } + + /** + * {@inheritdoc} + */ + public static function schema(FieldStorageDefinitionInterface $field_definition) { + $columns = [ + 'target_uuid' => [ + 'description' => 'The UUID of the target entity.', + 'type' => 'varchar_ascii', + 'length' => 128, + ], + ]; + + return [ + 'columns' => $columns, + 'indexes' => [ + 'target_uuid' => ['target_uuid'], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function setValue($values, $notify = TRUE): void { + if (isset($values) && !is_array($values)) { + // If either a scalar or an object was passed as the value for the item, + // assign it to the 'entity' or 'target_uuid' depending on values type. + if (is_object($values)) { + $this->set('entity', $values, $notify); + } + else { + $this->set('target_uuid', $values, $notify); + } + } + else { + parent::setValue($values, FALSE); + // Support setting the field item with only one property, but make sure + // values stay in sync if only property is passed. + // NULL is a valid value, so we use array_key_exists(). + if (is_array($values) && array_key_exists('target_uuid', $values) && !isset($values['entity'])) { + $this->onChange('target_uuid', FALSE); + } + elseif (is_array($values) && !array_key_exists('target_uuid', $values) && isset($values['entity'])) { + $this->onChange('entity', FALSE); + } + elseif (is_array($values) && array_key_exists('target_uuid', $values) && isset($values['entity'])) { + // If both properties are passed, verify the passed values match. The + // only exception we allow is when we have a new entity: in this case + // its actual id and target_uuid will be different, due to the new + // entity marker. + $entity_uuid = $this->get('entity')->get('uuid'); + // If the entity has been saved and we're trying to set both the + // target_uuid and the entity values with a non-null target UUID, then + // the value for target_uuid should match the UUID of the entity value. + if (!$this->entity->isNew() && $values['target_uuid'] !== NULL && ($entity_uuid !== $values['target_uuid'])) { + throw new \InvalidArgumentException('The target UUID and entity passed to the entity reference item do not match.'); + } + } + // Notify the parent if necessary. + if ($notify && $this->parent) { + $this->parent->onChange($this->getName()); + } + } + + } + + /** + * {@inheritdoc} + */ + public function onChange($property_name, $notify = TRUE): void { + // Make sure that the target UUID and the target property stay in sync. + if ($property_name === 'entity') { + $property = $this->get('entity'); + if ($target_uuid = $property->isTargetNew() ? NULL : $property->getValue()->uuid()) { + $this->writePropertyValue('target_uuid', $target_uuid); + } + } + elseif ($property_name === 'target_uuid') { + $property = $this->get('entity'); + $entity_type = $property->getDataDefinition()->getConstraint('EntityType'); + $entities = \Drupal::entityTypeManager()->getStorage($entity_type)->loadByProperties(['uuid' => $this->get('target_uuid')->getValue()]); + if ($entity = array_shift($entities)) { + assert($entity instanceof EntityInterface); + $this->writePropertyValue('target_uuid', $entity->uuid()); + $this->writePropertyValue('entity', $entity); + } + } + parent::onChange($property_name, $notify); + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + // Avoid loading the entity by first checking the 'target_uuid'. + if ($this->target_uuid !== NULL) { + return FALSE; + } + if ($this->entity && $this->entity instanceof EntityInterface) { + return FALSE; + } + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function preSave(): void { + if ($this->hasNewEntity()) { + // Save the entity if it has not already been saved by some other code. + if ($this->entity->isNew()) { + $this->entity->save(); + } + // Make sure the parent knows we are updating this property so it can + // react properly. + $this->target_uuid = $this->entity->uuid(); + } + if (!$this->isEmpty() && $this->target_uuid === NULL) { + $this->target_uuid = $this->entity->uuid(); + } + } + + /** + * {@inheritdoc} + */ + public static function generateSampleValue(FieldDefinitionInterface $field_definition): array { + $manager = \Drupal::service('plugin.manager.entity_reference_selection'); + + // Instead of calling $manager->getSelectionHandler($field_definition) + // replicate the behavior to be able to override the sorting settings. + $options = [ + 'target_type' => $field_definition->getFieldStorageDefinition()->getSetting('target_type'), + 'handler' => $field_definition->getSetting('handler'), + 'handler_settings' => $field_definition->getSetting('handler_settings') ?: [], + 'entity' => NULL, + ]; + + $entity_type = \Drupal::entityTypeManager()->getDefinition($options['target_type']); + $options['handler_settings']['sort'] = [ + 'field' => $entity_type->getKey('uuid'), + 'direction' => 'DESC', + ]; + $selection_handler = $manager->getInstance($options); + + // Select a random number of references between the last 50 referenceable + // entities created. + if ($referenceable = $selection_handler->getReferenceableEntities(NULL, 'CONTAINS', 50)) { + $group = array_rand($referenceable); + return ['target_uuid' => array_rand($referenceable[$group])]; + } + return []; + } + + /** + * Determines whether the item holds an unsaved entity. + * + * This is notably used for "autocreate" widgets, and more generally to + * support referencing freshly created entities (they will get saved + * automatically as the hosting entity gets saved). + * + * @return bool + * TRUE if the item holds an unsaved entity. + */ + public function hasNewEntity() { + return !$this->isEmpty() && $this->target_uuid === NULL && $this->entity->isNew(); + } + +} diff --git a/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php index 22f8f7f57d1..7539670e155 100644 --- a/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php +++ b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php @@ -265,7 +265,7 @@ class FileUploadTest extends ResourceTestBase { ->set('field_rest_file_test', ['target_id' => $existing_file->id()]) ->save(); - $uri = Url::fromUri('base:' . '/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test'); + $uri = Url::fromUri('base:/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test'); // DX: 405 when read-only mode is enabled. $response = $this->fileRequest($uri, $this->testFileData); diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRelationshipTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRelationshipTest.php new file mode 100644 index 00000000000..110e1a6840b --- /dev/null +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRelationshipTest.php @@ -0,0 +1,157 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\jsonapi\Functional; + +use Drupal\Core\Url; +use Drupal\entity_test\EntityTestHelper; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use GuzzleHttp\RequestOptions; + +/** + * JSON:API resource tests. + * + * @group jsonapi + * + * @internal + */ +class JsonApiRelationshipTest extends JsonApiFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'basic_auth', + 'entity_test', + 'jsonapi_test_field_type', + ]; + + /** + * The entity type ID. + */ + protected string $entityTypeId = 'entity_test'; + + /** + * The entity bundle. + */ + protected string $bundle = 'entity_test'; + + /** + * The field name. + */ + protected string $fieldName = 'field_child'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + EntityTestHelper::createBundle($this->bundle, 'Parent', $this->entityTypeId); + + FieldStorageConfig::create([ + 'field_name' => $this->fieldName, + 'type' => 'jsonapi_test_field_type_entity_reference_uuid', + 'entity_type' => $this->entityTypeId, + 'cardinality' => 1, + 'settings' => [ + 'target_type' => $this->entityTypeId, + ], + ])->save(); + FieldConfig::create([ + 'field_name' => $this->fieldName, + 'entity_type' => $this->entityTypeId, + 'bundle' => $this->bundle, + 'label' => $this->randomString(), + 'settings' => [ + 'handler' => 'default', + 'handler_settings' => [], + ], + ])->save(); + + \Drupal::service('router.builder')->rebuild(); + } + + /** + * Test relationships without target_id as main property. + * + * @see https://www.drupal.org/project/drupal/issues/3476224 + */ + public function testPatchHandleUUIDPropertyReferenceFieldIssue3127883(): void { + $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); + $user = $this->drupalCreateUser([ + 'administer entity_test content', + 'view test entity', + ]); + + // Create parent and child entities. + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $parentEntity = $storage + ->create([ + 'type' => $this->bundle, + ]); + $parentEntity->save(); + $childUuid = $this->container->get('uuid')->generate(); + $childEntity = $storage + ->create([ + 'type' => $this->bundle, + 'uuid' => $childUuid, + ]); + $childEntity->save(); + $uuid = $childEntity->uuid(); + $this->assertEquals($childUuid, $uuid); + + // 1. Successful PATCH to the related endpoint. + $url = Url::fromUri(sprintf('internal:/jsonapi/%s/%s/%s/relationships/%s', $this->entityTypeId, $this->bundle, $parentEntity->uuid(), $this->fieldName)); + $request_options = [ + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/vnd.api+json', + 'Accept' => 'application/vnd.api+json', + ], + RequestOptions::AUTH => [$user->getAccountName(), $user->pass_raw], + RequestOptions::JSON => [ + 'data' => [ + 'id' => $childUuid, + 'type' => sprintf('%s--%s', $this->entityTypeId, $this->bundle), + ], + ], + ]; + $response = $this->request('PATCH', $url, $request_options); + $this->assertSame(204, $response->getStatusCode(), (string) $response->getBody()); + $parentEntity = $storage->loadUnchanged($parentEntity->id()); + $this->assertEquals($childEntity->uuid(), $parentEntity->get($this->fieldName)->target_uuid); + + // Reset the relationship. + $parentEntity->set($this->fieldName, NULL) + ->save(); + $parentEntity = $storage->loadUnchanged($parentEntity->id()); + $this->assertTrue($parentEntity->get($this->fieldName)->isEmpty()); + + // 2. Successful PATCH to individual endpoint. + $url = Url::fromUri(sprintf('internal:/jsonapi/%s/%s/%s', $this->entityTypeId, $this->bundle, $parentEntity->uuid())); + $request_options[RequestOptions::JSON] = [ + 'data' => [ + 'id' => $parentEntity->uuid(), + 'type' => sprintf('%s--%s', $this->entityTypeId, $this->bundle), + 'relationships' => [ + $this->fieldName => [ + 'data' => [ + [ + 'id' => $childUuid, + 'type' => sprintf('%s--%s', $this->entityTypeId, $this->bundle), + ], + ], + ], + ], + ], + ]; + $response = $this->request('PATCH', $url, $request_options); + $this->assertSame(200, $response->getStatusCode(), (string) $response->getBody()); + $parentEntity = $storage->loadUnchanged($parentEntity->id()); + $this->assertEquals($childEntity->uuid(), $parentEntity->get($this->fieldName)->target_uuid); + } + +} diff --git a/core/modules/jsonapi/tests/src/Functional/WorkspaceTest.php b/core/modules/jsonapi/tests/src/Functional/WorkspaceTest.php index 97eec557d22..48cbc20067a 100644 --- a/core/modules/jsonapi/tests/src/Functional/WorkspaceTest.php +++ b/core/modules/jsonapi/tests/src/Functional/WorkspaceTest.php @@ -15,6 +15,7 @@ use Drupal\workspaces\Entity\Workspace; * JSON:API integration test for the "Workspace" content entity type. * * @group jsonapi + * @group workspaces */ class WorkspaceTest extends ResourceTestBase { @@ -142,7 +143,7 @@ class WorkspaceTest extends ResourceTestBase { 'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339), 'label' => 'Campaign', 'drupal_internal__id' => 'campaign', - 'drupal_internal__revision_id' => 2, + 'drupal_internal__revision_id' => 1, ], 'relationships' => [ 'parent' => [ diff --git a/core/modules/jsonapi/tests/src/FunctionalJavascript/JsonApiPerformanceTest.php b/core/modules/jsonapi/tests/src/FunctionalJavascript/JsonApiPerformanceTest.php index b1b0934b5da..6c69e60f404 100644 --- a/core/modules/jsonapi/tests/src/FunctionalJavascript/JsonApiPerformanceTest.php +++ b/core/modules/jsonapi/tests/src/FunctionalJavascript/JsonApiPerformanceTest.php @@ -6,14 +6,15 @@ namespace Drupal\Tests\jsonapi\FunctionalJavascript; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\PerformanceTestBase; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; /** * Tests performance for JSON:API routes. - * - * @group Common - * @group #slow - * @requires extension apcu */ +#[Group('Common')] +#[Group('#slow')] +#[RequiresPhpExtension('apcu')] class JsonApiPerformanceTest extends PerformanceTestBase { /** 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/css/layout-builder.pcss.css b/core/modules/layout_builder/css/layout-builder.pcss.css index 7ddb9543e00..06b73b42992 100644 --- a/core/modules/layout_builder/css/layout-builder.pcss.css +++ b/core/modules/layout_builder/css/layout-builder.pcss.css @@ -91,7 +91,7 @@ pointer-events: none; } - @nest .layout-builder--content-preview-disabled & { + .layout-builder--content-preview-disabled & { margin: 0; border-bottom: 2px dashed #979797; } diff --git a/core/modules/layout_builder/css/off-canvas.css b/core/modules/layout_builder/css/off-canvas.css index 1caa028602f..0b90c63ce61 100644 --- a/core/modules/layout_builder/css/off-canvas.css +++ b/core/modules/layout_builder/css/off-canvas.css @@ -95,7 +95,7 @@ #drupal-off-canvas-wrapper .inline-block-create-button:hover, #drupal-off-canvas-wrapper .inline-block-list__item:hover { - background-color: rgba(255, 255, 255, 0.05); + background-color: rgb(255, 255, 255, 0.05); } #drupal-off-canvas-wrapper .inline-block-create-button:focus, diff --git a/core/modules/layout_builder/css/off-canvas.pcss.css b/core/modules/layout_builder/css/off-canvas.pcss.css index a63de90a0dd..55083338bde 100644 --- a/core/modules/layout_builder/css/off-canvas.pcss.css +++ b/core/modules/layout_builder/css/off-canvas.pcss.css @@ -80,7 +80,7 @@ color: var(--off-canvas-text-color); &:hover { - background-color: rgba(255, 255, 255, 0.05); + background-color: rgb(255, 255, 255, 0.05); } &:focus { diff --git a/core/modules/layout_builder/src/Element/LayoutBuilder.php b/core/modules/layout_builder/src/Element/LayoutBuilder.php index d673d24d1d3..2b128f93188 100644 --- a/core/modules/layout_builder/src/Element/LayoutBuilder.php +++ b/core/modules/layout_builder/src/Element/LayoutBuilder.php @@ -11,6 +11,7 @@ use Drupal\Core\Plugin\PluginFormInterface; use Drupal\Core\Render\Attribute\RenderElement; use Drupal\Core\Render\Element; use Drupal\Core\Render\Element\RenderElementBase; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\Security\Attribute\TrustedCallback; use Drupal\Core\Url; use Drupal\layout_builder\Context\LayoutBuilderContextTrait; @@ -52,9 +53,11 @@ class LayoutBuilder extends RenderElementBase implements ContainerFactoryPluginI * The plugin implementation definition. * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher service. + * @param \Drupal\Core\Render\ElementInfoManagerInterface|null $elementInfoManager + * The element info manager. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EventDispatcherInterface $event_dispatcher) { - parent::__construct($configuration, $plugin_id, $plugin_definition); + public function __construct(array $configuration, $plugin_id, $plugin_definition, EventDispatcherInterface $event_dispatcher, ?ElementInfoManagerInterface $elementInfoManager = NULL) { + parent::__construct($configuration, $plugin_id, $plugin_definition, $elementInfoManager); $this->eventDispatcher = $event_dispatcher; } @@ -66,7 +69,8 @@ class LayoutBuilder extends RenderElementBase implements ContainerFactoryPluginI $configuration, $plugin_id, $plugin_definition, - $container->get('event_dispatcher') + $container->get('event_dispatcher'), + $container->get('plugin.manager.element_info') ); } diff --git a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php index 786fa3d786a..162928bb091 100644 --- a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php +++ b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php @@ -2,8 +2,8 @@ namespace Drupal\layout_builder\EventSubscriber; -use Drupal\block_content\Access\RefinableDependentAccessInterface; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\RefinableDependentAccessInterface; use Drupal\Core\Block\BlockPluginInterface; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Render\Element; diff --git a/core/modules/layout_builder/src/InlineBlockEntityOperations.php b/core/modules/layout_builder/src/InlineBlockEntityOperations.php index 16bda99a45c..7a832ad284c 100644 --- a/core/modules/layout_builder/src/InlineBlockEntityOperations.php +++ b/core/modules/layout_builder/src/InlineBlockEntityOperations.php @@ -134,8 +134,6 @@ class InlineBlockEntityOperations implements ContainerInjectionInterface { * The parent entity. */ public function handleEntityDelete(EntityInterface $entity) { - // @todo In https://www.drupal.org/node/3008943 call - // \Drupal\layout_builder\LayoutEntityHelperTrait::isLayoutCompatibleEntity(). $this->usage->removeByLayoutEntity($entity); } diff --git a/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php b/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php index 2c66d43b820..14686b9f532 100644 --- a/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php +++ b/core/modules/layout_builder/src/Plugin/Block/InlineBlock.php @@ -2,10 +2,10 @@ namespace Drupal\layout_builder\Plugin\Block; -use Drupal\block_content\Access\RefinableDependentAccessInterface; -use Drupal\block_content\Access\RefinableDependentAccessTrait; use Drupal\Component\Utility\NestedArray; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\RefinableDependentAccessInterface; +use Drupal\Core\Access\RefinableDependentAccessTrait; use Drupal\Core\Block\Attribute\Block; use Drupal\Core\Block\BlockBase; use Drupal\Core\Entity\Entity\EntityFormDisplay; diff --git a/core/modules/layout_builder/tests/modules/layout_builder_block_content_dependency_test/layout_builder_block_content_dependency_test.info.yml b/core/modules/layout_builder/tests/modules/layout_builder_block_content_dependency_test/layout_builder_block_content_dependency_test.info.yml new file mode 100644 index 00000000000..691e00a80c5 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_block_content_dependency_test/layout_builder_block_content_dependency_test.info.yml @@ -0,0 +1,5 @@ +name: 'Layout Builder BLock Content dependency test' +type: module +description: 'Support module for testing Layout Builder''s implicit dependencies on Block Content' +package: Testing +version: VERSION diff --git a/core/modules/layout_builder/tests/modules/layout_builder_block_content_dependency_test/layout_builder_block_content_dependency_test.module b/core/modules/layout_builder/tests/modules/layout_builder_block_content_dependency_test/layout_builder_block_content_dependency_test.module new file mode 100644 index 00000000000..93bb03de404 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_block_content_dependency_test/layout_builder_block_content_dependency_test.module @@ -0,0 +1,19 @@ +<?php + +/** + * @file + * Provides hook implementations for testing Layout Builder with Block Content. + */ + +declare(strict_types=1); + +/** + * Implements hook_modules_installed(). + */ +function layout_builder_block_content_dependency_test_modules_installed(array $modules, bool $is_syncing): void { + // @see Drupal\Tests\layout_builder\Kernel\LayoutBuilderBlockContentDependencyTest + if (in_array('layout_builder', $modules)) { + \Drupal::service('plugin.manager.block')->getDefinitions(); + \Drupal::service('module_installer')->install(['block_content']); + } +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module b/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module deleted file mode 100644 index d7dda399be3..00000000000 --- a/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module +++ /dev/null @@ -1,51 +0,0 @@ -<?php - -/** - * @file - * Provides hook implementations for Layout Builder tests. - */ - -declare(strict_types=1); - -use Drupal\Core\Entity\Display\EntityViewDisplayInterface; -use Drupal\Core\Entity\EntityInterface; - -/** - * Implements hook_ENTITY_TYPE_view(). - */ -function layout_builder_test_node_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode): void { - if ($display->getComponent('layout_builder_test')) { - $build['layout_builder_test'] = [ - '#markup' => 'Extra, Extra read all about it.', - ]; - } - if ($display->getComponent('layout_builder_test_2')) { - $build['layout_builder_test_2'] = [ - '#markup' => 'Extra Field 2 is hidden by default.', - ]; - } -} - -/** - * Implements hook_preprocess_HOOK() for one-column layout template. - */ -function layout_builder_test_preprocess_layout__onecol(&$vars): void { - if (!empty($vars['content']['#entity'])) { - $vars['content']['content'][\Drupal::service('uuid')->generate()] = [ - '#type' => 'markup', - '#markup' => sprintf('Yes, I can access the %s', $vars['content']['#entity']->label()), - ]; - } -} - -/** - * Implements hook_preprocess_HOOK() for two-column layout template. - */ -function layout_builder_test_preprocess_layout__twocol_section(&$vars): void { - if (!empty($vars['content']['#entity'])) { - $vars['content']['first'][\Drupal::service('uuid')->generate()] = [ - '#type' => 'markup', - '#markup' => sprintf('Yes, I can access the entity %s in two column', $vars['content']['#entity']->label()), - ]; - } -} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestEntityHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestEntityHooks.php new file mode 100644 index 00000000000..820630e2a4d --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestEntityHooks.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Entity\Display\EntityFormDisplayInterface; +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Entity hook implementations for layout_builder_test. + */ +class LayoutBuilderTestEntityHooks { + + /** + * Implements hook_ENTITY_TYPE_view(). + */ + #[Hook('node_view')] + public function nodeView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode): void { + if ($display->getComponent('layout_builder_test')) { + $build['layout_builder_test'] = [ + '#markup' => 'Extra, Extra read all about it.', + ]; + } + if ($display->getComponent('layout_builder_test_2')) { + $build['layout_builder_test_2'] = [ + '#markup' => 'Extra Field 2 is hidden by default.', + ]; + } + } + + /** + * Implements hook_entity_extra_field_info(). + */ + #[Hook('entity_extra_field_info')] + public function entityExtraFieldInfo(): array { + $extra['node']['bundle_with_section_field']['display']['layout_builder_test'] = [ + 'label' => 'Extra label', + 'description' => 'Extra description', + 'weight' => 0, + ]; + $extra['node']['bundle_with_section_field']['display']['layout_builder_test_2'] = [ + 'label' => 'Extra Field 2', + 'description' => 'Extra Field 2 description', + 'weight' => 0, + 'visible' => FALSE, + ]; + return $extra; + } + + /** + * Implements hook_entity_form_display_alter(). + */ + #[Hook('entity_form_display_alter', module: 'layout_builder')] + public function layoutBuilderEntityFormDisplayAlter(EntityFormDisplayInterface $form_display, array $context): void { + if ($context['form_mode'] === 'layout_builder') { + $form_display->setComponent('status', ['type' => 'boolean_checkbox', 'settings' => ['display_label' => TRUE]]); + } + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestFormHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestFormHooks.php new file mode 100644 index 00000000000..8298a97d515 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestFormHooks.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Form hook implementations for layout_builder_test. + */ +class LayoutBuilderTestFormHooks { + + /** + * Implements hook_form_BASE_FORM_ID_alter() for layout_builder_configure_block. + */ + #[Hook('form_layout_builder_configure_block_alter')] + public function formLayoutBuilderConfigureBlockAlter(&$form, FormStateInterface $form_state, $form_id) : void { + /** @var \Drupal\layout_builder\Form\ConfigureBlockFormBase $form_object */ + $form_object = $form_state->getFormObject(); + $form['layout_builder_test']['storage'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Storage: ' . $form_object->getSectionStorage()->getStorageId(), + ]; + $form['layout_builder_test']['section'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Section: ' . $form_object->getCurrentSection()->getLayoutId(), + ]; + $form['layout_builder_test']['component'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Component: ' . $form_object->getCurrentComponent()->getPluginId(), + ]; + } + + /** + * Implements hook_form_BASE_FORM_ID_alter() for layout_builder_configure_section. + */ + #[Hook('form_layout_builder_configure_section_alter')] + public function formLayoutBuilderConfigureSectionAlter(&$form, FormStateInterface $form_state, $form_id) : void { + /** @var \Drupal\layout_builder\Form\ConfigureSectionForm $form_object */ + $form_object = $form_state->getFormObject(); + $form['layout_builder_test']['storage'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Storage: ' . $form_object->getSectionStorage()->getStorageId(), + ]; + $form['layout_builder_test']['section'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Section: ' . $form_object->getCurrentSection()->getLayoutId(), + ]; + $form['layout_builder_test']['layout'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Layout: ' . $form_object->getCurrentLayout()->getPluginId(), + ]; + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php deleted file mode 100644 index 397eedc8dad..00000000000 --- a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php +++ /dev/null @@ -1,137 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\layout_builder_test\Hook; - -use Drupal\Core\Url; -use Drupal\Core\Link; -use Drupal\Core\Routing\RouteMatchInterface; -use Drupal\Core\Breadcrumb\Breadcrumb; -use Drupal\Core\Entity\Display\EntityFormDisplayInterface; -use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Hook\Attribute\Hook; -use Drupal\Core\Hook\Order\OrderBefore; - -/** - * Hook implementations for layout_builder_test. - */ -class LayoutBuilderTestHooks { - - /** - * Implements hook_plugin_filter_TYPE__CONSUMER_alter(). - */ - #[Hook('plugin_filter_block__layout_builder_alter')] - public function pluginFilterBlockLayoutBuilderAlter(array &$definitions, array $extra): void { - // Explicitly remove the "Help" blocks from the list. - unset($definitions['help_block']); - // Explicitly remove the "Sticky at top of lists field_block". - $disallowed_fields = ['sticky']; - // Remove "Changed" field if this is the first section. - if ($extra['delta'] === 0) { - $disallowed_fields[] = 'changed'; - } - foreach ($definitions as $plugin_id => $definition) { - // Field block IDs are in the form 'field_block:{entity}:{bundle}:{name}', - // for example 'field_block:node:article:revision_timestamp'. - preg_match('/field_block:.*:.*:(.*)/', $plugin_id, $parts); - if (isset($parts[1]) && in_array($parts[1], $disallowed_fields, TRUE)) { - // Unset any field blocks that match our predefined list. - unset($definitions[$plugin_id]); - } - } - } - - /** - * Implements hook_entity_extra_field_info(). - */ - #[Hook('entity_extra_field_info')] - public function entityExtraFieldInfo(): array { - $extra['node']['bundle_with_section_field']['display']['layout_builder_test'] = [ - 'label' => 'Extra label', - 'description' => 'Extra description', - 'weight' => 0, - ]; - $extra['node']['bundle_with_section_field']['display']['layout_builder_test_2'] = [ - 'label' => 'Extra Field 2', - 'description' => 'Extra Field 2 description', - 'weight' => 0, - 'visible' => FALSE, - ]; - return $extra; - } - - /** - * Implements hook_form_BASE_FORM_ID_alter() for layout_builder_configure_block. - */ - #[Hook('form_layout_builder_configure_block_alter')] - public function formLayoutBuilderConfigureBlockAlter(&$form, FormStateInterface $form_state, $form_id) : void { - /** @var \Drupal\layout_builder\Form\ConfigureBlockFormBase $form_object */ - $form_object = $form_state->getFormObject(); - $form['layout_builder_test']['storage'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Storage: ' . $form_object->getSectionStorage()->getStorageId(), - ]; - $form['layout_builder_test']['section'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Section: ' . $form_object->getCurrentSection()->getLayoutId(), - ]; - $form['layout_builder_test']['component'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Component: ' . $form_object->getCurrentComponent()->getPluginId(), - ]; - } - - /** - * Implements hook_form_BASE_FORM_ID_alter() for layout_builder_configure_section. - */ - #[Hook('form_layout_builder_configure_section_alter')] - public function formLayoutBuilderConfigureSectionAlter(&$form, FormStateInterface $form_state, $form_id) : void { - /** @var \Drupal\layout_builder\Form\ConfigureSectionForm $form_object */ - $form_object = $form_state->getFormObject(); - $form['layout_builder_test']['storage'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Storage: ' . $form_object->getSectionStorage()->getStorageId(), - ]; - $form['layout_builder_test']['section'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Section: ' . $form_object->getCurrentSection()->getLayoutId(), - ]; - $form['layout_builder_test']['layout'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Layout: ' . $form_object->getCurrentLayout()->getPluginId(), - ]; - } - - /** - * Implements hook_entity_form_display_alter(). - */ - #[Hook('entity_form_display_alter', module: 'layout_builder')] - public function layoutBuilderEntityFormDisplayAlter(EntityFormDisplayInterface $form_display, array $context): void { - if ($context['form_mode'] === 'layout_builder') { - $form_display->setComponent('status', ['type' => 'boolean_checkbox', 'settings' => ['display_label' => TRUE]]); - } - } - - /** - * Implements hook_system_breadcrumb_alter(). - */ - #[Hook( - 'system_breadcrumb_alter', - order: new OrderBefore( - modules: ['layout_builder'] - ) - )] - public function systemBreadcrumbAlter(Breadcrumb &$breadcrumb, RouteMatchInterface $route_match, array $context): void { - $breadcrumb->addLink(Link::fromTextAndUrl('External link', Url::fromUri('http://www.example.com'))); - } - - /** - * Implements hook_theme(). - */ - #[Hook('theme')] - public function theme() : array { - return ['block__preview_aware_block' => ['base hook' => 'block']]; - } - -} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestMenuHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestMenuHooks.php new file mode 100644 index 00000000000..9304876a908 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestMenuHooks.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Url; +use Drupal\Core\Link; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Breadcrumb\Breadcrumb; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Hook\Order\OrderBefore; + +/** + * Menu hook implementations for layout_builder_test. + */ +class LayoutBuilderTestMenuHooks { + + /** + * Implements hook_system_breadcrumb_alter(). + */ + #[Hook( + 'system_breadcrumb_alter', + order: new OrderBefore( + modules: ['layout_builder'] + ) + )] + public function systemBreadcrumbAlter(Breadcrumb &$breadcrumb, RouteMatchInterface $route_match, array $context): void { + $breadcrumb->addLink(Link::fromTextAndUrl('External link', Url::fromUri('http://www.example.com'))); + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestPluginHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestPluginHooks.php new file mode 100644 index 00000000000..1464d419333 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestPluginHooks.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Plugin hook implementations for layout_builder_test. + */ +class LayoutBuilderTestPluginHooks { + + /** + * Implements hook_plugin_filter_TYPE__CONSUMER_alter(). + */ + #[Hook('plugin_filter_block__layout_builder_alter')] + public function pluginFilterBlockLayoutBuilderAlter(array &$definitions, array $extra): void { + // Explicitly remove the "Help" blocks from the list. + unset($definitions['help_block']); + // Explicitly remove the "Sticky at top of lists field_block". + $disallowed_fields = ['sticky']; + // Remove "Changed" field if this is the first section. + if ($extra['delta'] === 0) { + $disallowed_fields[] = 'changed'; + } + foreach ($definitions as $plugin_id => $definition) { + // Field block IDs are in the form 'field_block:{entity}:{bundle}:{name}', + // for example 'field_block:node:article:revision_timestamp'. + preg_match('/field_block:.*:.*:(.*)/', $plugin_id, $parts); + if (isset($parts[1]) && in_array($parts[1], $disallowed_fields, TRUE)) { + // Unset any field blocks that match our predefined list. + unset($definitions[$plugin_id]); + } + } + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestThemeHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestThemeHooks.php new file mode 100644 index 00000000000..e67249102b5 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestThemeHooks.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Component\Uuid\UuidInterface; + +/** + * Theme hook implementations for layout_builder_test. + */ +class LayoutBuilderTestThemeHooks { + + public function __construct( + protected readonly UuidInterface $uuid, + ) {} + + /** + * Implements hook_theme(). + */ + #[Hook('theme')] + public function theme() : array { + return [ + 'block__preview_aware_block' => [ + 'base hook' => 'block', + ], + ]; + } + + /** + * Implements hook_preprocess_HOOK() for one-column layout template. + */ + #[Hook('preprocess_layout__onecol')] + public function layoutOneCol(&$vars): void { + if (!empty($vars['content']['#entity'])) { + $vars['content']['content'][$this->uuid->generate()] = [ + '#type' => 'markup', + '#markup' => sprintf('Yes, I can access the %s', $vars['content']['#entity']->label()), + ]; + } + } + + /** + * Implements hook_preprocess_HOOK() for two-column layout template. + */ + #[Hook('preprocess_layout__twocol_section')] + public function layoutTwocolSection(&$vars): void { + if (!empty($vars['content']['#entity'])) { + $vars['content']['first'][$this->uuid->generate()] = [ + '#type' => 'markup', + '#markup' => sprintf('Yes, I can access the entity %s in two column', $vars['content']['#entity']->label()), + ]; + } + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/layout_builder_theme_suggestions_test.module b/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/layout_builder_theme_suggestions_test.module deleted file mode 100644 index 5632c3fb8a9..00000000000 --- a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/layout_builder_theme_suggestions_test.module +++ /dev/null @@ -1,19 +0,0 @@ -<?php - -/** - * @file - * For testing theme suggestions. - */ - -declare(strict_types=1); - -/** - * Implements hook_preprocess_HOOK() for the list of layouts. - */ -function layout_builder_theme_suggestions_test_preprocess_item_list__layouts(&$variables): void { - foreach (array_keys($variables['items']) as $layout_id) { - if (isset($variables['items'][$layout_id]['value']['#title']['icon'])) { - $variables['items'][$layout_id]['value']['#title']['icon'] = ['#markup' => __FUNCTION__]; - } - } -} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestHooks.php deleted file mode 100644 index 6f90fe62870..00000000000 --- a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestHooks.php +++ /dev/null @@ -1,28 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\layout_builder_theme_suggestions_test\Hook; - -use Drupal\Core\Hook\Attribute\Hook; - -/** - * Hook implementations for layout_builder_theme_suggestions_test. - */ -class LayoutBuilderThemeSuggestionsTestHooks { - - /** - * Implements hook_theme(). - */ - #[Hook('theme')] - public function theme() : array { - // It is necessary to explicitly register the template via hook_theme() - // because it is added via a module, not a theme. - return [ - 'field__node__body__bundle_with_section_field__default' => [ - 'base hook' => 'field', - ], - ]; - } - -} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestThemeHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestThemeHooks.php new file mode 100644 index 00000000000..3e087944e94 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestThemeHooks.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_theme_suggestions_test\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for layout_builder_theme_suggestions_test. + */ +class LayoutBuilderThemeSuggestionsTestThemeHooks { + + /** + * Implements hook_theme(). + */ + #[Hook('theme')] + public function theme() : array { + // It is necessary to explicitly register the template via hook_theme() + // because it is added via a module, not a theme. + return [ + 'field__node__body__bundle_with_section_field__default' => [ + 'base hook' => 'field', + ], + ]; + } + + /** + * Implements hook_preprocess_HOOK() for the list of layouts. + */ + #[Hook('preprocess_item_list__layouts')] + public function itemListLayouts(&$variables): void { + foreach (array_keys($variables['items']) as $layout_id) { + if (isset($variables['items'][$layout_id]['value']['#title']['icon'])) { + $variables['items'][$layout_id]['value']['#title']['icon'] = ['#markup' => __METHOD__]; + } + } + } + +} diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTestBase.php b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTestBase.php index 94cb2455b5a..75dac6efac2 100644 --- a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTestBase.php +++ b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTestBase.php @@ -10,7 +10,7 @@ use Drupal\Tests\field_ui\Traits\FieldUiTestTrait; /** * Tests the Layout Builder UI. */ -class LayoutBuilderTestBase extends BrowserTestBase { +abstract class LayoutBuilderTestBase extends BrowserTestBase { use FieldUiTestTrait; diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderThemeSuggestionsTest.php b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderThemeSuggestionsTest.php index b107ec4f9b4..d9423cd23e8 100644 --- a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderThemeSuggestionsTest.php +++ b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderThemeSuggestionsTest.php @@ -66,7 +66,7 @@ class LayoutBuilderThemeSuggestionsTest extends BrowserTestBase { $this->drupalGet('node/1/layout'); $page->clickLink('Add section'); - $assert_session->pageTextContains('layout_builder_theme_suggestions_test_preprocess_item_list__layouts'); + $assert_session->pageTextContains('itemListLayouts'); } /** diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/AjaxBlockTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/AjaxBlockTest.php index bf33b3d7c78..13cbab6fdfb 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/AjaxBlockTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/AjaxBlockTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\Tests\system\Traits\OffCanvasTestTrait; +use PHPUnit\Framework\Attributes\Group; /** * Ajax blocks tests. - * - * @group layout_builder */ +#[Group('layout_builder')] class AjaxBlockTest extends WebDriverTestBase { use OffCanvasTestTrait; diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFilterTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFilterTest.php index 92d95a84908..8fa2fa1dec5 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFilterTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFilterTest.php @@ -7,13 +7,14 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Behat\Mink\Element\NodeElement; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * Tests the JavaScript functionality of the block add filter. - * - * @group layout_builder - * @group legacy */ +#[Group('layout_builder')] +#[IgnoreDeprecations] class BlockFilterTest extends WebDriverTestBase { /** diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFormMessagesTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFormMessagesTest.php index c07b69de1ef..555af66c86c 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFormMessagesTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/BlockFormMessagesTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests that messages appear in the off-canvas dialog with configuring blocks. - * - * @group layout_builder */ +#[Group('layout_builder')] class BlockFormMessagesTest extends WebDriverTestBase { use ContextualLinkClickTrait; diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/ContentPreviewToggleTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/ContentPreviewToggleTest.php index 333ae628292..476ec728fa4 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/ContentPreviewToggleTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/ContentPreviewToggleTest.php @@ -8,14 +8,13 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; use Drupal\Tests\system\Traits\OffCanvasTestTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore blocknodebundle testbody - /** * Tests toggling of content preview. - * - * @group layout_builder */ +#[Group('layout_builder')] class ContentPreviewToggleTest extends WebDriverTestBase { use ContextualLinkClickTrait; diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/ContextualLinksTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/ContextualLinksTest.php index efa1b1ba979..fb62f1bed6f 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/ContextualLinksTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/ContextualLinksTest.php @@ -7,14 +7,13 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore blocktest - /** * Test contextual links compatibility with the Layout Builder. - * - * @group layout_builder */ +#[Group('layout_builder')] class ContextualLinksTest extends WebDriverTestBase { use AssertPageCacheContextsAndTagsTrait; diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/FieldBlockTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/FieldBlockTest.php index 75fdf40097c..37c733435cd 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/FieldBlockTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/FieldBlockTest.php @@ -7,15 +7,18 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\layout_builder\Plugin\Block\FieldBlock; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; // cspell:ignore datefield - /** - * @coversDefaultClass \Drupal\layout_builder\Plugin\Block\FieldBlock - * - * @group field - * @group legacy + * Tests Drupal\layout_builder\Plugin\Block\FieldBlock. */ +#[CoversClass(FieldBlock::class)] +#[Group('field')] +#[IgnoreDeprecations] class FieldBlockTest extends WebDriverTestBase { /** diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php index 97808e73b69..66e6a3d6a6c 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php @@ -11,12 +11,12 @@ use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; use Drupal\Tests\file\Functional\FileFieldCreationTrait; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Test access to private files in block fields on the Layout Builder. - * - * @group layout_builder */ +#[Group('layout_builder')] class InlineBlockPrivateFilesTest extends InlineBlockTestBase { use FileFieldCreationTrait; diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTest.php index 2dcbbf645e5..9097e244c18 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTest.php @@ -6,13 +6,14 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\node\Entity\Node; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * Tests that the inline block feature works correctly. - * - * @group layout_builder - * @group #slow */ +#[Group('layout_builder')] +#[Group('#slow')] class InlineBlockTest extends InlineBlockTestBase { /** @@ -113,9 +114,8 @@ class InlineBlockTest extends InlineBlockTestBase { /** * Tests adding a new entity block and then not saving the layout. - * - * @dataProvider layoutNoSaveProvider */ + #[DataProvider('layoutNoSaveProvider')] public function testNoLayoutSave($operation, $no_save_button_text, $confirm_button_text): void { $this->drupalLogin($this->drupalCreateUser([ 'access contextual links', diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php index 6931a733d96..bd46767863a 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Drupal\Tests\layout_builder\FunctionalJavascript; -use Drupal\block_content\Entity\BlockContentType; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\Tests\block_content\Traits\BlockContentCreationTrait; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; // cspell:ignore blockbasic @@ -15,6 +15,9 @@ use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; */ abstract class InlineBlockTestBase extends WebDriverTestBase { + use BlockContentCreationTrait { + createBlockContentType as baseCreateBlockContentType; + } use ContextualLinkClickTrait; /** @@ -214,13 +217,11 @@ abstract class InlineBlockTestBase extends WebDriverTestBase { * The block type label. */ protected function createBlockContentType($id, $label) { - $bundle = BlockContentType::create([ + $this->baseCreateBlockContentType([ 'id' => $id, 'label' => $label, 'revision' => 1, - ]); - $bundle->save(); - block_content_add_body_field($bundle->id()); + ], TRUE); } } diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/ItemLayoutFieldBlockTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/ItemLayoutFieldBlockTest.php index 4bb6313816f..217347b4692 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/ItemLayoutFieldBlockTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/ItemLayoutFieldBlockTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; +use PHPUnit\Framework\Attributes\Group; /** * Field blocks tests for the override layout. - * - * @group layout_builder */ +#[Group('layout_builder')] class ItemLayoutFieldBlockTest extends WebDriverTestBase { /** diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php index 4067edd2616..003248030d5 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php @@ -7,20 +7,18 @@ 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; use Drupal\Tests\system\Traits\OffCanvasTestTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore blocknodebundle fieldbody - /** * Tests the Layout Builder disables interactions of rendered blocks. - * - * @group layout_builder - * @group #slow */ +#[Group('layout_builder')] +#[Group('#slow')] class LayoutBuilderDisableInteractionsTest extends WebDriverTestBase { use ContextualLinkClickTrait; @@ -190,7 +188,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_builder/tests/src/FunctionalJavascript/LayoutBuilderNestedFormUiTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderNestedFormUiTest.php index 6cb5b136b87..f7d0f2a6dd6 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderNestedFormUiTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderNestedFormUiTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests placing blocks containing forms in theLayout Builder UI. - * - * @group layout_builder */ +#[Group('layout_builder')] class LayoutBuilderNestedFormUiTest extends WebDriverTestBase { /** diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderOptInTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderOptInTest.php index f5e106cfc9a..55b3de28f6f 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderOptInTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderOptInTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\layout_builder\Traits\EnableLayoutBuilderTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests the ability for opting in and out of Layout Builder. - * - * @group layout_builder */ +#[Group('layout_builder')] class LayoutBuilderOptInTest extends WebDriverTestBase { use EnableLayoutBuilderTrait; diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderTest.php index fb237f03a22..33fe433ad31 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderTest.php @@ -10,12 +10,12 @@ use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; use Drupal\Tests\system\Traits\OffCanvasTestTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests the Layout Builder UI. - * - * @group layout_builder */ +#[Group('layout_builder')] class LayoutBuilderTest extends WebDriverTestBase { use ContextualLinkClickTrait; diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderToolbarTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderToolbarTest.php index 5e0155ae06d..9e7b73fad53 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderToolbarTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderToolbarTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Test Layout Builder integration with Toolbar. - * - * @group layout_builder */ +#[Group('layout_builder')] class LayoutBuilderToolbarTest extends WebDriverTestBase { /** diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php index a9205d9f0dc..f3217671822 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php @@ -7,14 +7,13 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\block_content\Entity\BlockContentType; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore blocknodebundle fieldbody fieldlayout - /** * Tests the Layout Builder UI. - * - * @group layout_builder */ +#[Group('layout_builder')] class LayoutBuilderUiTest extends WebDriverTestBase { use ContextualLinkClickTrait; diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/MoveBlockFormTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/MoveBlockFormTest.php index df748df0e08..238f1d4723c 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/MoveBlockFormTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/MoveBlockFormTest.php @@ -8,14 +8,13 @@ use Behat\Mink\Element\NodeElement; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore blocknodebundle fieldbody fieldlinks - /** * Tests moving blocks via the form. - * - * @group layout_builder */ +#[Group('layout_builder')] class MoveBlockFormTest extends WebDriverTestBase { use ContextualLinkClickTrait; diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/TestMultiWidthLayoutsTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/TestMultiWidthLayoutsTest.php index c7037f99391..7e225dcf73d 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/TestMultiWidthLayoutsTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/TestMultiWidthLayoutsTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; +use PHPUnit\Framework\Attributes\Group; /** * Test the multi-width layout plugins. - * - * @group layout_builder */ +#[Group('layout_builder')] class TestMultiWidthLayoutsTest extends WebDriverTestBase { /** diff --git a/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderBlockContentDependencyTest.php b/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderBlockContentDependencyTest.php new file mode 100644 index 00000000000..faa5d26bec9 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderBlockContentDependencyTest.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\layout_builder\Kernel; + +use Drupal\KernelTests\KernelTestBase; + +/** + * Test for installing Layout Builder before Block Content in the same request. + * + * @group layout_builder + */ +class LayoutBuilderBlockContentDependencyTest extends KernelTestBase { + + /** + * Test that block_content can be successfully installed after layout_builder. + * + * The InlineBlock plugin class in layout_builder uses + * RefinableDependentAccessTrait, which used to live in block_content, though + * block_content is not a layout_builder dependency. Since the BlockContent + * entity type class also uses the same trait, if, in order and in the same + * request: + * 1. layout_builder is installed first without block_content + * 2. block plugins are discovered + * 3. block_content is installed, + * a fatal error can occur, because the trait was missing before block_content + * is installed and gets aliased to an empty trait. When the installation of + * the block_content module installs the BlockContent entity type, the empty + * trait is missing the methods that need to be implemented from the + * interface. + * + * @see \Drupal\Component\Plugin\Discovery\AttributeClassDiscovery + * @see \Drupal\Component\Discovery\MissingClassDetectionClassLoader + */ + public function testInstallLayoutBuilderAndBlockContent(): void { + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('block_content')); + // Prevent classes in the block_content modules from being loaded before the + // module is installed. + $this->classLoader->setPsr4("Drupal\\block_content\\", ''); + + // Install test module that will act on layout_builder being installed and + // at that time does block plugin discovery first, then installs + // block_content. + \Drupal::service('module_installer')->install(['layout_builder_block_content_dependency_test']); + + \Drupal::service('module_installer')->install(['layout_builder']); + $this->assertTrue(\Drupal::moduleHandler()->moduleExists('block_content')); + } + +} diff --git a/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php b/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php index 530feea461d..baa6a080aa5 100644 --- a/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php +++ b/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Drupal\Tests\layout_builder\Unit; -use Drupal\block_content\Access\RefinableDependentAccessInterface; use Drupal\Component\Plugin\Context\ContextInterface; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\RefinableDependentAccessInterface; use Drupal\Core\Block\BlockManagerInterface; use Drupal\Core\Block\BlockPluginInterface; use Drupal\Core\Cache\Cache; 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/link/tests/src/FunctionalJavascript/LinkFieldFormStatesTest.php b/core/modules/link/tests/src/FunctionalJavascript/LinkFieldFormStatesTest.php index be20978ea8f..2942c03c71b 100644 --- a/core/modules/link/tests/src/FunctionalJavascript/LinkFieldFormStatesTest.php +++ b/core/modules/link/tests/src/FunctionalJavascript/LinkFieldFormStatesTest.php @@ -5,13 +5,14 @@ declare(strict_types=1); namespace Drupal\Tests\link\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * Tests link field form states functionality. - * - * @group link - * @group #slow */ +#[Group('link')] +#[Group('#slow')] class LinkFieldFormStatesTest extends WebDriverTestBase { /** @@ -40,8 +41,9 @@ class LinkFieldFormStatesTest extends WebDriverTestBase { } /** - * @dataProvider linkFieldFormStatesData - */ + * Tests link field form states. + */ + #[DataProvider('linkFieldFormStatesData')] public function testLinkFieldFormStates(string $uri, string $title): void { $this->drupalGet('entity_test/add'); $session = $this->assertSession(); diff --git a/core/modules/link/tests/src/Kernel/LinkFormatterTest.php b/core/modules/link/tests/src/Kernel/LinkFormatterTest.php new file mode 100644 index 00000000000..8ebda26d9c5 --- /dev/null +++ b/core/modules/link/tests/src/Kernel/LinkFormatterTest.php @@ -0,0 +1,132 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\link\Kernel; + +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\KernelTests\Core\Entity\EntityKernelTestBase; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; + +/** + * Tests the Field Formatter for the link field type. + * + * @group link + */ +class LinkFormatterTest extends EntityKernelTestBase { + + /** + * Modules to enable. + * + * @var array + */ + protected static $modules = ['link']; + + /** + * The entity type used in this test. + * + * @var string + */ + protected string $entityType = 'entity_test'; + + /** + * The bundle used in this test. + * + * @var string + */ + protected string $bundle = 'entity_test'; + + /** + * The name of the field used in this test. + * + * @var string + */ + protected string $fieldName = 'field_test'; + + /** + * The entity to be tested. + * + * @var \Drupal\Core\Entity\EntityInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Use Stark theme for testing markup output. + \Drupal::service('theme_installer')->install(['stark']); + $this->config('system.theme')->set('default', 'stark')->save(); + $this->installEntitySchema('entity_test'); + // Grant the 'view test entity' permission. + $this->installConfig(['user']); + Role::load(RoleInterface::ANONYMOUS_ID) + ->grantPermission('view test entity') + ->save(); + + FieldStorageConfig::create([ + 'field_name' => $this->fieldName, + 'type' => 'link', + 'entity_type' => $this->entityType, + 'cardinality' => 1, + ])->save(); + + FieldConfig::create([ + 'field_name' => $this->fieldName, + 'entity_type' => $this->entityType, + 'bundle' => $this->bundle, + 'label' => 'Field test', + ])->save(); + } + + /** + * Tests the link formatters. + * + * @param string $formatter + * The name of the link formatter to test. + * + * @dataProvider providerLinkFormatter + */ + public function testLinkFormatter(string $formatter): void { + $entity = $this->container->get('entity_type.manager') + ->getStorage($this->entityType) + ->create([ + 'name' => $this->randomMachineName(), + $this->fieldName => [ + 'uri' => 'https://www.drupal.org/', + 'title' => 'Hello world', + 'options' => [ + 'attributes' => [ + 'class' => 'classy', + 'onmouseover' => 'alert(document.cookie)', + ], + ], + ], + ]); + $entity->save(); + + $build = $entity->get($this->fieldName)->view(['type' => $formatter]); + + $renderer = $this->container->get('renderer'); + $renderer->renderRoot($build[0]); + + $output = (string) $build[0]['#markup']; + $this->assertStringContainsString('<a href="https://www.drupal.org/" class="classy">', $output); + $this->assertStringNotContainsString('onmouseover=', $output); + } + + /** + * Data provider for ::testLinkFormatter. + */ + public static function providerLinkFormatter(): array { + return [ + 'default formatter' => ['link'], + 'separate link text and URL' => ['link_separate'], + ]; + } + +} diff --git a/core/modules/link/tests/src/Unit/AttributeXssTest.php b/core/modules/link/tests/src/Unit/AttributeXssTest.php new file mode 100644 index 00000000000..cbf01a40f60 --- /dev/null +++ b/core/modules/link/tests/src/Unit/AttributeXssTest.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\link\Unit; + +use Drupal\link\AttributeXss; +use Drupal\Tests\UnitTestCase; + +/** + * Tests AttributeXss. + * + * @group link + * @covers \Drupal\link\AttributeXss + */ +final class AttributeXssTest extends UnitTestCase { + + /** + * Covers ::sanitizeAttributes. + * + * @dataProvider providerSanitizeAttributes + */ + public function testSanitizeAttributes(array $attributes, array $expected): void { + self::assertSame($expected, AttributeXss::sanitizeAttributes($attributes)); + } + + /** + * Data provider for ::testSanitizeAttributes. + * + * @return \Generator + * Test cases. + */ + public static function providerSanitizeAttributes(): \Generator { + yield 'safe' => [ + ['class' => ['foo', 'bar'], 'data-biscuit' => TRUE], + ['class' => ['foo', 'bar'], 'data-biscuit' => TRUE], + ]; + + yield 'valueless' => [ + ['class' => ['foo', 'bar'], 'selected' => ''], + ['class' => ['foo', 'bar'], 'selected' => ''], + ]; + + yield 'empty names' => [ + ['class' => ['foo', 'bar'], '' => 'live', ' ' => TRUE], + ['class' => ['foo', 'bar']], + ]; + + yield 'only empty names' => [ + ['' => 'live', ' ' => TRUE], + [], + ]; + + yield 'valueless, mangled with a space' => [ + ['class' => ['foo', 'bar'], 'selected href' => 'http://example.com'], + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'http://example.com'], + ]; + + yield 'valueless, mangled with a space, blocked' => [ + ['class' => ['foo', 'bar'], 'selected onclick href' => 'http://example.com'], + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'http://example.com'], + ]; + + yield 'with encoding' => [ + ['class' => ['foo', 'bar'], 'data-how-good' => "It's the bee's knees"], + ['class' => ['foo', 'bar'], 'data-how-good' => "It's the bee's knees"], + ]; + + yield 'valueless, mangled with multiple spaces, blocked' => [ + ['class' => ['foo', 'bar'], 'selected onclick href' => 'http://example.com'], + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'http://example.com'], + ]; + + yield 'valueless, mangled with multiple spaces, blocked, mangled first' => [ + ['selected onclick href' => 'http://example.com', 'class' => ['foo', 'bar']], + ['selected' => 'selected', 'href' => 'http://example.com', 'class' => ['foo', 'bar']], + ]; + + yield 'valueless but with value' => [ + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'http://example.com'], + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'http://example.com'], + ]; + + yield 'valueless but with value, bad protocol' => [ + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'javascript:alert()'], + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'alert()'], + ]; + + yield 'valueless, mangled with a space and bad protocol' => [ + ['class' => ['foo', 'bar'], 'selected href' => 'javascript:alert()'], + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'alert()'], + ]; + + yield 'valueless, mangled with a space and bad protocol, repeated' => [ + ['class' => ['foo', 'bar'], 'selected href' => 'javascript:alert()', 'href' => 'http://example.com'], + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'alert()'], + ]; + + yield 'with a space' => [ + ['class' => ['foo', 'bar'], 'href' => \urlencode('some file.pdf')], + ['class' => ['foo', 'bar'], 'href' => 'some+file.pdf'], + ]; + + yield 'with an unencoded space' => [ + ['class' => ['foo', 'bar'], 'href' => 'some file.pdf'], + ['class' => ['foo', 'bar'], 'href' => 'some file.pdf'], + ]; + + yield 'xss onclick' => [ + ['class' => ['foo', 'bar'], 'onclick' => 'alert("whoop");'], + ['class' => ['foo', 'bar']], + ]; + + yield 'xss onclick, valueless, mangled with a space' => [ + ['class' => ['foo', 'bar'], 'selected onclick href' => 'http://example.com'], + ['class' => ['foo', 'bar'], 'selected' => 'selected', 'href' => 'http://example.com'], + ]; + + yield 'xss protocol' => [ + ['class' => ['foo', 'bar'], 'src' => 'javascript:alert("whoop");'], + ['class' => ['foo', 'bar'], 'src' => 'alert("whoop");'], + ]; + + } + +} diff --git a/core/modules/locale/locale.batch.inc b/core/modules/locale/locale.batch.inc index 0f204b6af2d..5de40ee764c 100644 --- a/core/modules/locale/locale.batch.inc +++ b/core/modules/locale/locale.batch.inc @@ -243,6 +243,15 @@ function locale_translation_batch_fetch_import($project, $langcode, $options, &$ } } } + elseif ($source->type == LOCALE_TRANSLATION_CURRENT) { + /* + * This can happen if the locale_translation_batch_fetch_import + * batch was interrupted + * and the translation was imported by another batch. + */ + $context['message'] = t('Ignoring already imported translation for %project.', ['%project' => $source->project]); + $context['finished'] = 1; + } } } } diff --git a/core/modules/locale/locale.install b/core/modules/locale/locale.install index 457d37890fa..d3377f3773e 100644 --- a/core/modules/locale/locale.install +++ b/core/modules/locale/locale.install @@ -21,12 +21,6 @@ function locale_install(): void { \Drupal::configFactory()->getEditable('locale.settings')->set('translation.path', $directory)->save(); } \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); - - $t_args = [ - ':translate_status' => base_path() . 'admin/reports/translations/check?destination=' . urlencode(base_path() . 'admin/reports/translations'), - ]; - $message = t('Check <a href=":translate_status">available translations</a> for your language(s).', $t_args); - \Drupal::messenger()->addStatus($message); } /** 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/locale/src/LocaleTranslation.php b/core/modules/locale/src/LocaleTranslation.php index a9ccc93626f..99e852650ee 100644 --- a/core/modules/locale/src/LocaleTranslation.php +++ b/core/modules/locale/src/LocaleTranslation.php @@ -20,7 +20,9 @@ use Symfony\Component\HttpFoundation\RequestStack; */ class LocaleTranslation implements TranslatorInterface, DestructableInterface { - use DependencySerializationTrait; + use DependencySerializationTrait { + __sleep as traitSleep; + } /** * Storage for strings. @@ -161,4 +163,13 @@ class LocaleTranslation implements TranslatorInterface, DestructableInterface { } } + /** + * {@inheritdoc} + */ + public function __sleep(): array { + // ::$translations is an array of LocaleLookup objects, which have the + // database service injected and therefore cannot be serialized safely. + return array_diff($this->traitSleep(), ['translations']); + } + } diff --git a/core/modules/locale/src/PoDatabaseWriter.php b/core/modules/locale/src/PoDatabaseWriter.php index 7e7340cf107..436d710d7ba 100644 --- a/core/modules/locale/src/PoDatabaseWriter.php +++ b/core/modules/locale/src/PoDatabaseWriter.php @@ -198,10 +198,15 @@ class PoDatabaseWriter implements PoWriterInterface { * {@inheritdoc} */ public function writeItems(PoReaderInterface $reader, $count = -1) { + // Processing multiple writes in a transaction is quicker than committing + // each individual write. + $transaction = \Drupal::database()->startTransaction(); $forever = $count == -1; while (($count-- > 0 || $forever) && ($item = $reader->readItem())) { $this->writeItem($item); } + // Commit the transaction. + unset($transaction); } /** diff --git a/core/modules/locale/src/StringDatabaseStorage.php b/core/modules/locale/src/StringDatabaseStorage.php index f023d1968ae..75c73411978 100644 --- a/core/modules/locale/src/StringDatabaseStorage.php +++ b/core/modules/locale/src/StringDatabaseStorage.php @@ -527,7 +527,10 @@ class StringDatabaseStorage implements StringStorageInterface { protected function dbDelete($table, $keys) { $query = $this->connection->delete($table, $this->options); foreach ($keys as $field => $value) { - $query->condition($field, $value); + if (!is_array($value)) { + $value = [$value]; + } + $query->condition($field, $value, 'IN'); } return $query; } diff --git a/core/modules/locale/tests/src/Functional/LocaleInstallTest.php b/core/modules/locale/tests/src/Functional/LocaleInstallTest.php deleted file mode 100644 index fc81fdb19b5..00000000000 --- a/core/modules/locale/tests/src/Functional/LocaleInstallTest.php +++ /dev/null @@ -1,48 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\locale\Functional; - -use Drupal\Tests\BrowserTestBase; - -/** - * Test installation of Locale module. - * - * @group locale - */ -class LocaleInstallTest extends BrowserTestBase { - - /** - * {@inheritdoc} - */ - protected static $modules = [ - 'system', - 'file', - 'language', - ]; - - /** - * {@inheritdoc} - */ - protected $defaultTheme = 'stark'; - - /** - * Tests Locale install message. - */ - public function testLocaleInstallMessage(): void { - $admin_user = $this->drupalCreateUser([ - 'access administration pages', - 'administer modules', - ]); - $this->drupalLogin($admin_user); - - $edit = []; - $edit['modules[locale][enable]'] = 'locale'; - $this->drupalGet('admin/modules'); - $this->submitForm($edit, 'Install'); - - $this->assertSession()->statusMessageContains('available translations', 'status'); - } - -} diff --git a/core/modules/locale/tests/src/Kernel/LocaleBatchTest.php b/core/modules/locale/tests/src/Kernel/LocaleBatchTest.php new file mode 100644 index 00000000000..47249930c6e --- /dev/null +++ b/core/modules/locale/tests/src/Kernel/LocaleBatchTest.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\locale\Kernel; + +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests locale batches. + * + * @group locale + */ +class LocaleBatchTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'locale', + 'system', + 'language', + ]; + + /** + * Checks that the import batch finishes if the translation has already been imported. + */ + public function testBuildProjects(): void { + $this->installConfig(['locale']); + $this->installSchema('locale', ['locale_file']); + $this->container->get('module_handler')->loadInclude('locale', 'batch.inc'); + + \Drupal::database()->insert('locale_file') + ->fields([ + 'project' => 'drupal', + 'langcode' => 'en', + 'filename' => 'drupal.po', + 'version' => \Drupal::VERSION, + 'timestamp' => time(), + ]) + ->execute(); + + $context = []; + locale_translation_batch_fetch_import('drupal', 'en', [], $context); + $this->assertEquals(1, $context['finished']); + $this->assertEquals('Ignoring already imported translation for drupal.', $context['message']); + } + +} diff --git a/core/modules/locale/tests/src/Kernel/LocaleStringTest.php b/core/modules/locale/tests/src/Kernel/LocaleStringTest.php index f5f27b01a62..b007c7ab55b 100644 --- a/core/modules/locale/tests/src/Kernel/LocaleStringTest.php +++ b/core/modules/locale/tests/src/Kernel/LocaleStringTest.php @@ -258,4 +258,27 @@ class LocaleStringTest extends KernelTestBase { ])->save(); } + /** + * Tests that strings are correctly deleted. + */ + public function testDeleteStrings(): void { + $source = $this->storage->createString([ + 'source' => 'Revision ID', + ])->save(); + + $this->storage->createTranslation([ + 'lid' => $source->lid, + 'language' => 'fr', + 'translation' => 'Translated Revision ID', + ])->save(); + + // Confirm that the string has been created. + $this->assertNotEmpty($this->storage->findString(['lid' => $source->lid])); + + $this->storage->deleteStrings(['lid' => $source->lid]); + + // Confirm that the string has been deleted. + $this->assertEmpty($this->storage->findString(['lid' => $source->lid])); + } + } diff --git a/core/modules/locale/tests/src/Kernel/LocaleTranslationTest.php b/core/modules/locale/tests/src/Kernel/LocaleTranslationTest.php index 316384330f8..b52d2934825 100644 --- a/core/modules/locale/tests/src/Kernel/LocaleTranslationTest.php +++ b/core/modules/locale/tests/src/Kernel/LocaleTranslationTest.php @@ -21,11 +21,29 @@ class LocaleTranslationTest extends KernelTestBase { ]; /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installSchema('locale', [ + 'locales_location', + 'locales_source', + 'locales_target', + ]); + } + + /** * Tests that \Drupal\locale\LocaleTranslation is serializable. */ public function testSerializable(): void { + /** @var \Drupal\locale\LocaleTranslation $translation */ $translation = $this->container->get('string_translator.locale.lookup'); $this->assertInstanceOf(LocaleTranslation::class, $translation); + // Ensure that the \Drupal\locale\LocaleTranslation::$translations property + // has some cached translations in it. Without this, serialization will not + // actually be tested fully. + $translation->getStringTranslation('es', 'test', ''); // Prove that serialization and deserialization works without errors. $this->assertNotNull($translation); 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/MediaHooks.php b/core/modules/media/src/Hook/MediaHooks.php index 9a80bfec158..37f24658ce3 100644 --- a/core/modules/media/src/Hook/MediaHooks.php +++ b/core/modules/media/src/Hook/MediaHooks.php @@ -195,10 +195,10 @@ class MediaHooks { $elements['#media_help']['#media_add_help'] = $this->t('Create your media on the <a href=":add_page" target="_blank">media add page</a> (opens a new window), then add it by name to the field below.', [':add_page' => $add_url]); } $elements['#theme'] = 'media_reference_help'; - // @todo template_preprocess_field_multiple_value_form() assumes this key - // exists, but it does not exist in the case of a single widget that - // accepts multiple values. This is for some reason necessary to use - // our template for the entity_autocomplete_tags widget. + // @todo \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() + // assumes this key exists, but it does not exist in the case of a single + // widget that accepts multiple values. This is for some reason necessary + // to use our template for the entity_autocomplete_tags widget. // Research and resolve this in https://www.drupal.org/node/2943020. if (empty($elements['#cardinality_multiple'])) { $elements['#cardinality_multiple'] = NULL; 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/src/OEmbed/UrlResolver.php b/core/modules/media/src/OEmbed/UrlResolver.php index a672c5933d0..562dc430f77 100644 --- a/core/modules/media/src/OEmbed/UrlResolver.php +++ b/core/modules/media/src/OEmbed/UrlResolver.php @@ -141,7 +141,7 @@ class UrlResolver implements UrlResolverInterface { return $this->resourceFetcher->fetchResource($resource_url)->getProvider(); } - throw new ResourceException('No matching provider found.', $url); + throw new ResourceException("No matching oEmbed provider found for resource: \"{$url}\"", $url); } /** diff --git a/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php b/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php index 5fa53b2b9e9..0230cc19b46 100644 --- a/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php +++ b/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php @@ -7,6 +7,8 @@ use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldWidget\StringTextfieldWidget; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\Element\ElementInterface; +use Drupal\Core\Render\Element\Widget; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\media\Entity\MediaType; use Drupal\media\Plugin\media\Source\OEmbedInterface; @@ -28,24 +30,35 @@ class OEmbedWidget extends StringTextfieldWidget { /** * {@inheritdoc} */ - public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { - $element = parent::formElement($items, $delta, $element, $form, $form_state); + public function singleElementObject(FieldItemListInterface $items, $delta, Widget $widget, ElementInterface $form, FormStateInterface $form_state): ElementInterface { + $widget = parent::singleElementObject($items, $delta, $widget, $form, $form_state); + $value = $widget->getChild('value'); + $value->description = $this->getValueDescription($items, $value->description); + return $widget; + } + /** + * Merges description and provider messages. + * + * @param \Drupal\Core\Field\FieldItemListInterface $items + * FieldItemList containing the values to be edited. + * @param scalar|\Stringable|\Drupal\Core\Render\RenderableInterface|array $description + * The description on the form element. + * + * @return string|array + * The description on the value child. + */ + protected function getValueDescription(FieldItemListInterface $items, mixed $description): string|array { /** @var \Drupal\media\Plugin\media\Source\OEmbedInterface $source */ $source = $items->getEntity()->getSource(); $message = $this->t('You can link to media from the following services: @providers', ['@providers' => implode(', ', $source->getProviders())]); - - if (!empty($element['value']['#description'])) { - $element['value']['#description'] = [ + if ($description) { + return [ '#theme' => 'item_list', - '#items' => [$element['value']['#description'], $message], + '#items' => [$description, $message], ]; } - else { - $element['value']['#description'] = $message; - } - - return $element; + return $message; } /** diff --git a/core/modules/media/templates/media-reference-help.html.twig b/core/modules/media/templates/media-reference-help.html.twig index 910dc4e94be..4adc22db002 100644 --- a/core/modules/media/templates/media-reference-help.html.twig +++ b/core/modules/media/templates/media-reference-help.html.twig @@ -3,7 +3,7 @@ * @file * Theme override for media reference fields. * - * @see template_preprocess_field_multiple_value_form() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() */ #} {% diff --git a/core/modules/media/tests/modules/media_test_embed/media_test_embed.module b/core/modules/media/tests/modules/media_test_embed/media_test_embed.module deleted file mode 100644 index abb19d89509..00000000000 --- a/core/modules/media/tests/modules/media_test_embed/media_test_embed.module +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -/** - * @file - * Helper module for the Media Embed text editor plugin tests. - */ - -declare(strict_types=1); - -/** - * Implements hook_preprocess_HOOK(). - */ -function media_test_embed_preprocess_media_embed_error(&$variables): void { - $variables['attributes']['class'][] = 'this-error-message-is-themeable'; -} diff --git a/core/modules/media/tests/modules/media_test_embed/src/Hook/MediaTestEmbedThemeHooks.php b/core/modules/media/tests/modules/media_test_embed/src/Hook/MediaTestEmbedThemeHooks.php new file mode 100644 index 00000000000..ede5f6df253 --- /dev/null +++ b/core/modules/media/tests/modules/media_test_embed/src/Hook/MediaTestEmbedThemeHooks.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\media_test_embed\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for media_test_embed. + */ +class MediaTestEmbedThemeHooks { + + /** + * Implements hook_preprocess_HOOK(). + */ + #[Hook('preprocess_media_embed_error')] + public function preprocessMediaEmbedError(&$variables): void { + $variables['attributes']['class'][] = 'this-error-message-is-themeable'; + } + +} diff --git a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module deleted file mode 100644 index 910318dd868..00000000000 --- a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module +++ /dev/null @@ -1,20 +0,0 @@ -<?php - -/** - * @file - * Helper module for the Media oEmbed tests. - */ - -declare(strict_types=1); - -/** - * Implements hook_preprocess_media_oembed_iframe(). - */ -function media_test_oembed_preprocess_media_oembed_iframe(array &$variables): void { - if ($variables['resource']->getProvider()->getName() === 'YouTube') { - $variables['media'] = str_replace('?feature=oembed', '?feature=oembed&pasta=rigatoni', (string) $variables['media']); - } - // @see \Drupal\Tests\media\Kernel\OEmbedIframeControllerTest - $variables['#attached']['library'][] = 'media_test_oembed/frame'; - $variables['#cache']['tags'][] = 'yo_there'; -} diff --git a/core/modules/media/tests/modules/media_test_oembed/src/Hook/MediaTestOembedThemeHooks.php b/core/modules/media/tests/modules/media_test_oembed/src/Hook/MediaTestOembedThemeHooks.php new file mode 100644 index 00000000000..626d68f9812 --- /dev/null +++ b/core/modules/media/tests/modules/media_test_oembed/src/Hook/MediaTestOembedThemeHooks.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\media_test_oembed\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for media_test_oembed. + */ +class MediaTestOembedThemeHooks { + + /** + * Implements hook_preprocess_media_oembed_iframe(). + */ + #[Hook('preprocess_media_oembed_iframe')] + public function preprocessMediaOembedIframe(array &$variables): void { + if ($variables['resource']->getProvider()->getName() === 'YouTube') { + $variables['media'] = str_replace('?feature=oembed', '?feature=oembed&pasta=rigatoni', (string) $variables['media']); + } + // @see \Drupal\Tests\media\Kernel\OEmbedIframeControllerTest + $variables['#attached']['library'][] = 'media_test_oembed/frame'; + $variables['#cache']['tags'][] = 'yo_there'; + } + +} diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php index b1fc5baaf7d..22fe95ece62 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php @@ -11,12 +11,12 @@ use Drupal\field\Entity\FieldStorageConfig; use Drupal\media\Entity\Media; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; +use PHPUnit\Framework\Attributes\Group; /** * Basic display tests for Media. - * - * @group media */ +#[Group('media')] class MediaDisplayTest extends MediaJavascriptTestBase { /** diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiAddTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiAddTest.php index 554fc35e37c..4825687abfa 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiAddTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiAddTest.php @@ -4,17 +4,24 @@ declare(strict_types=1); namespace Drupal\Tests\media\FunctionalJavascript; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + /** - * @covers ::media_filter_format_edit_form_validate - * @group media - * @group #slow + * Tests Media Embed Filter Configuration Ui Add. + * + * @legacy-covers ::media_filter_format_edit_form_validate */ +#[Group('media')] +#[Group('#slow')] class MediaEmbedFilterConfigurationUiAddTest extends MediaEmbedFilterTestBase { /** - * @covers \Drupal\media\Hook\MediaHooks::formFilterFormatAddFormAlter - * @dataProvider providerTestValidations + * Tests validation when adding. + * + * @legacy-covers \Drupal\media\Hook\MediaHooks::formFilterFormatAddFormAlter */ + #[DataProvider('providerTestValidations')] public function testValidationWhenAdding($filter_html_status, $filter_align_status, $filter_caption_status, $filter_html_image_secure_status, $media_embed, $allowed_html, $expected_error_message): void { $this->drupalGet('admin/config/content/formats/add'); diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiEditTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiEditTest.php index bcc62c73621..ce006de76ec 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiEditTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterConfigurationUiEditTest.php @@ -4,17 +4,24 @@ declare(strict_types=1); namespace Drupal\Tests\media\FunctionalJavascript; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + /** - * @covers ::media_filter_format_edit_form_validate - * @group media - * @group #slow + * Tests Media Embed Filter Configuration Ui Edit. + * + * @legacy-covers ::media_filter_format_edit_form_validate */ +#[Group('media')] +#[Group('#slow')] class MediaEmbedFilterConfigurationUiEditTest extends MediaEmbedFilterTestBase { /** - * @covers \Drupal\media\Hook\MediaHooks::formFilterFormatEditFormAlter - * @dataProvider providerTestValidations + * Tests validation when editing. + * + * @legacy-covers \Drupal\media\Hook\MediaHooks::formFilterFormatEditFormAlter */ + #[DataProvider('providerTestValidations')] public function testValidationWhenEditing($filter_html_status, $filter_align_status, $filter_caption_status, $filter_html_image_secure_status, $media_embed, $allowed_html, $expected_error_message): void { $this->drupalGet('admin/config/content/formats/manage/media_embed_test'); diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterTestBase.php b/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterTestBase.php index 57d5278ee1d..097ddb6b310 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterTestBase.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterTestBase.php @@ -9,7 +9,7 @@ use Drupal\filter\Entity\FilterFormat; /** * Base class for media embed filter configuration tests. */ -class MediaEmbedFilterTestBase extends MediaJavascriptTestBase { +abstract class MediaEmbedFilterTestBase extends MediaJavascriptTestBase { /** * {@inheritdoc} diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaReferenceFieldHelpTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaReferenceFieldHelpTest.php index 80d438685dd..383ea8e7a60 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaReferenceFieldHelpTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaReferenceFieldHelpTest.php @@ -4,11 +4,12 @@ declare(strict_types=1); namespace Drupal\Tests\media\FunctionalJavascript; +use PHPUnit\Framework\Attributes\Group; + /** * Tests related to media reference fields. - * - * @group media */ +#[Group('media')] class MediaReferenceFieldHelpTest extends MediaJavascriptTestBase { /** diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceAudioVideoTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceAudioVideoTest.php index 24c071a501c..379ff5f1b35 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceAudioVideoTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceAudioVideoTest.php @@ -8,12 +8,12 @@ use Drupal\Core\Entity\Entity\EntityViewDisplay; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\file\Entity\File; +use PHPUnit\Framework\Attributes\Group; /** * Tests the Audio and Video media sources. - * - * @group media */ +#[Group('media')] class MediaSourceAudioVideoTest extends MediaSourceTestBase { /** diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceFileTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceFileTest.php index dfb784ba073..6d658a38815 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceFileTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceFileTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\media\FunctionalJavascript; use Drupal\media\Entity\Media; use Drupal\media\Plugin\media\Source\File; +use PHPUnit\Framework\Attributes\Group; /** * Tests the file media source. - * - * @group media */ +#[Group('media')] class MediaSourceFileTest extends MediaSourceTestBase { /** diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceImageTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceImageTest.php index a21aec35012..8e9a32cab5c 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceImageTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceImageTest.php @@ -12,12 +12,12 @@ use Drupal\media\Entity\MediaType; use Drupal\media\Plugin\media\Source\Image; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; /** * Tests the image media source. - * - * @group media */ +#[Group('media')] class MediaSourceImageTest extends MediaSourceTestBase { /** diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php index fdbf1151882..67ffe424eab 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php @@ -4,23 +4,22 @@ declare(strict_types=1); namespace Drupal\Tests\media\FunctionalJavascript; -use Drupal\Core\Session\AccountInterface; use Drupal\Core\Database\Database; +use Drupal\Core\Session\AccountInterface; use Drupal\dblog\Controller\DbLogController; use Drupal\media\Entity\Media; use Drupal\media\Entity\MediaType; use Drupal\media_test_oembed\Controller\ResourceController; use Drupal\Tests\media\Traits\OEmbedTestTrait; use Drupal\user\Entity\Role; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\DependencyInjection\ContainerInterface; // cspell:ignore dailymotion Schipulcon - /** * Tests the oembed:video media source. - * - * @group media */ +#[Group('media')] class MediaSourceOEmbedVideoTest extends MediaSourceTestBase { /** diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaStandardProfileTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaStandardProfileTest.php index 72fa18190b7..36585f409aa 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaStandardProfileTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaStandardProfileTest.php @@ -11,14 +11,13 @@ use Drupal\field\Entity\FieldStorageConfig; use Drupal\media_test_oembed\Controller\ResourceController; use Drupal\node\Entity\Node; use Drupal\Tests\media\Traits\OEmbedTestTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore Drupalin Hustlin Schipulcon - /** * Basic tests for Media configuration in the standard profile. - * - * @group media */ +#[Group('media')] class MediaStandardProfileTest extends MediaJavascriptTestBase { use OEmbedTestTrait; diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaTypeCreationTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaTypeCreationTest.php index 699d97e9a18..0a2fa8c4a3c 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaTypeCreationTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaTypeCreationTest.php @@ -5,14 +5,13 @@ declare(strict_types=1); namespace Drupal\Tests\media\FunctionalJavascript; use Drupal\Component\Utility\Html; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore pastafazoul - /** * Tests the media type creation. - * - * @group media */ +#[Group('media')] class MediaTypeCreationTest extends MediaJavascriptTestBase { /** diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaUiJavascriptTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaUiJavascriptTest.php index 75724223423..a648eaaa8de 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaUiJavascriptTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaUiJavascriptTest.php @@ -8,12 +8,12 @@ use Drupal\field\FieldConfigInterface; use Drupal\media\Entity\Media; use Drupal\media\Entity\MediaType; use Drupal\media\MediaSourceInterface; +use PHPUnit\Framework\Attributes\Group; /** * Ensures that media UI works correctly. - * - * @group media */ +#[Group('media')] class MediaUiJavascriptTest extends MediaJavascriptTestBase { /** diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaViewsWizardTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaViewsWizardTest.php index 1654f5879b2..da0b9fa9ef8 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaViewsWizardTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaViewsWizardTest.php @@ -5,15 +5,15 @@ declare(strict_types=1); namespace Drupal\Tests\media\FunctionalJavascript; use Drupal\views\Views; +use PHPUnit\Framework\Attributes\Group; /** * Tests the media entity type integration into the wizard. * - * @group media - * * @see \Drupal\media\Plugin\views\wizard\Media * @see \Drupal\media\Plugin\views\wizard\MediaRevision */ +#[Group('media')] class MediaViewsWizardTest extends MediaJavascriptTestBase { /** 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/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php index 3b36ee5e377..33ffe1a3992 100644 --- a/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php +++ b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php @@ -18,6 +18,7 @@ use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Field\WidgetBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; @@ -78,6 +79,8 @@ class MediaLibraryWidget extends WidgetBase implements TrustedCallbackInterface * The widget settings. * @param array $third_party_settings * Any third party settings. + * @param \Drupal\Core\Render\ElementInfoManagerInterface $elementInfoManager + * The element info manager. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * Entity type manager service. * @param \Drupal\Core\Session\AccountInterface $current_user @@ -85,8 +88,8 @@ class MediaLibraryWidget extends WidgetBase implements TrustedCallbackInterface * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler * The module handler. */ - public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user, ModuleHandlerInterface $module_handler) { - parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ElementInfoManagerInterface $elementInfoManager, EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user, ModuleHandlerInterface $module_handler) { + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings, $elementInfoManager); $this->entityTypeManager = $entity_type_manager; $this->currentUser = $current_user; $this->moduleHandler = $module_handler; @@ -102,6 +105,7 @@ class MediaLibraryWidget extends WidgetBase implements TrustedCallbackInterface $configuration['field_definition'], $configuration['settings'], $configuration['third_party_settings'], + $container->get('plugin.manager.element_info'), $container->get('entity_type.manager'), $container->get('current_user'), $container->get('module_handler') diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/ContentModerationTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/ContentModerationTest.php index 28f123a946c..0bdb75e7ec1 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/ContentModerationTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/ContentModerationTest.php @@ -12,14 +12,13 @@ use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait; use Drupal\Tests\field\Traits\EntityReferenceFieldCreationTrait; use Drupal\Tests\media\Traits\MediaTypeCreationTrait; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore hoglet - /** * Tests media library integration with content moderation. - * - * @group media_library */ +#[Group('media_library')] class ContentModerationTest extends WebDriverTestBase { use ContentModerationTestTrait; diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/EmbeddedFormWidgetTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/EmbeddedFormWidgetTest.php index 1fb1b9a9352..7e51adf769c 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/EmbeddedFormWidgetTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/EmbeddedFormWidgetTest.php @@ -9,12 +9,13 @@ use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\media\Entity\Media; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * Tests media widget nested inside another widget. - * - * @group media_library */ +#[Group('media_library')] class EmbeddedFormWidgetTest extends WebDriverTestBase { use TestFileCreationTrait; @@ -94,9 +95,8 @@ class EmbeddedFormWidgetTest extends WebDriverTestBase { /** * Tests media inside another widget that validates too enthusiastically. - * - * @dataProvider insertionReselectionProvider */ + #[DataProvider('insertionReselectionProvider')] public function testInsertionAndReselection($widget): void { $this->container ->get('entity_display.repository') diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/EntityReferenceWidgetTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/EntityReferenceWidgetTest.php index ea476c9ee96..bb02c1eca7d 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/EntityReferenceWidgetTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/EntityReferenceWidgetTest.php @@ -8,12 +8,12 @@ use Drupal\field\Entity\FieldConfig; use Drupal\FunctionalJavascriptTests\SortableTestTrait; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; /** * Tests the Media library entity reference widget. - * - * @group media_library */ +#[Group('media_library')] class EntityReferenceWidgetTest extends MediaLibraryTestBase { use SortableTestTrait; diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/FieldUiIntegrationTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/FieldUiIntegrationTest.php index 7bd1e52628e..1b1378c16aa 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/FieldUiIntegrationTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/FieldUiIntegrationTest.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace Drupal\Tests\media_library\FunctionalJavascript; -// cspell:ignore shatner +use PHPUnit\Framework\Attributes\Group; +// cspell:ignore shatner /** * Tests field UI integration for media library widget. - * - * @group media_library */ +#[Group('media_library')] class FieldUiIntegrationTest extends MediaLibraryTestBase { /** diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/MediaOverviewTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/MediaOverviewTest.php index a88502c6596..535edb25fb0 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/MediaOverviewTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/MediaOverviewTest.php @@ -4,11 +4,12 @@ declare(strict_types=1); namespace Drupal\Tests\media_library\FunctionalJavascript; +use PHPUnit\Framework\Attributes\Group; + /** * Tests the grid-style media overview page. - * - * @group media_library */ +#[Group('media_library')] class MediaOverviewTest extends MediaLibraryTestBase { /** diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/TranslationsTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/TranslationsTest.php index f3dbb33ad1b..0d389b472fe 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/TranslationsTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/TranslationsTest.php @@ -12,12 +12,12 @@ use Drupal\media\Entity\Media; use Drupal\Tests\field\Traits\EntityReferenceFieldCreationTrait; use Drupal\Tests\media\Traits\MediaTypeCreationTrait; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests media library for translatable media. - * - * @group media_library */ +#[Group('media_library')] class TranslationsTest extends WebDriverTestBase { use EntityReferenceFieldCreationTrait; diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/ViewsUiIntegrationTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/ViewsUiIntegrationTest.php index 73df14ec6fc..69043cdd0e2 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/ViewsUiIntegrationTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/ViewsUiIntegrationTest.php @@ -4,11 +4,12 @@ declare(strict_types=1); namespace Drupal\Tests\media_library\FunctionalJavascript; +use PHPUnit\Framework\Attributes\Group; + /** * Tests Media Library's integration with Views UI. - * - * @group media_library */ +#[Group('media_library')] class ViewsUiIntegrationTest extends MediaLibraryTestBase { /** diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetAccessTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetAccessTest.php index 78f4d6acf00..8e2c4411af2 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetAccessTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetAccessTest.php @@ -9,12 +9,12 @@ use Drupal\media\Entity\Media; use Drupal\media_library\MediaLibraryState; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; /** * Tests the media library UI access. - * - * @group media_library */ +#[Group('media_library')] class WidgetAccessTest extends MediaLibraryTestBase { /** diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetAnonymousTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetAnonymousTest.php index d7efb311cda..56a7ddd9008 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetAnonymousTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetAnonymousTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\media_library\FunctionalJavascript; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; /** * Tests that the widget works as expected for anonymous users. - * - * @group media_library */ +#[Group('media_library')] class WidgetAnonymousTest extends MediaLibraryTestBase { /** diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOEmbedTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOEmbedTest.php index 46277c473e6..d6b141f8a8d 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOEmbedTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOEmbedTest.php @@ -7,14 +7,13 @@ namespace Drupal\Tests\media_library\FunctionalJavascript; use Drupal\media\Entity\Media; use Drupal\media_test_oembed\Controller\ResourceController; use Drupal\Tests\media\Traits\OEmbedTestTrait; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore Drupalin Hustlin Schipulcon - /** * Tests that oEmbed media can be added in the Media library's widget. - * - * @group media_library */ +#[Group('media_library')] class WidgetOEmbedTest extends MediaLibraryTestBase { use OEmbedTestTrait; @@ -124,10 +123,10 @@ class WidgetOEmbedTest extends MediaLibraryTestBase { // assertWaitOnAjaxRequest() required for input "id" attributes to // consistently match their label's "for" attribute. $assert_session->assertWaitOnAjaxRequest(); - $this->waitForText('No matching provider found.'); + $this->waitForText('No matching oEmbed provider found for resource:'); // Assert we can not add a video ID that doesn't exist. We need to use a // video ID that will not be filtered by the regex, because otherwise the - // message 'No matching provider found.' will be returned. + // message 'No matching oEmbed provider found for resource: "..."' will be returned. $page->fillField('Add Type Five via URL', 'https://www.youtube.com/watch?v=PWjcqE3QKBg1'); $page->pressButton('Add'); // assertWaitOnAjaxRequest() required for input "id" attributes to @@ -312,10 +311,10 @@ class WidgetOEmbedTest extends MediaLibraryTestBase { // assertWaitOnAjaxRequest() required for input "id" attributes to // consistently match their label's "for" attribute. $assert_session->assertWaitOnAjaxRequest(); - $this->waitForText('No matching provider found.'); + $this->waitForText('No matching oEmbed provider found for resource:'); // Assert we can not add a video ID that doesn't exist. We need to use a // video ID that will not be filtered by the regex, because otherwise the - // message 'No matching provider found.' will be returned. + // message 'No matching oEmbed provider found for resource: "..."' will be returned. $page->fillField('Add Type Five via URL', 'https://www.youtube.com/watch?v=PWjcqE3QKBg1'); $page->pressButton('Add'); // assertWaitOnAjaxRequest() required for input "id" attributes to diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOverflowTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOverflowTest.php index 8a13b0e070c..08ec7dd7f5f 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOverflowTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOverflowTest.php @@ -5,16 +5,17 @@ declare(strict_types=1); namespace Drupal\Tests\media_library\FunctionalJavascript; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * Tests that uploads in the 'media_library_widget' works as expected. * - * @group media_library - * @group #slow - * * @todo This test will occasionally fail with SQLite until * https://www.drupal.org/node/3066447 is addressed. */ +#[Group('media_library')] +#[Group('#slow')] class WidgetOverflowTest extends MediaLibraryTestBase { use TestFileCreationTrait; @@ -95,9 +96,8 @@ class WidgetOverflowTest extends MediaLibraryTestBase { * The operation of the button to click. For example, if this is "insert", * the "Save and insert" button will be pressed. If NULL, the "Save" button * will be pressed. - * - * @dataProvider providerWidgetOverflow */ + #[DataProvider('providerWidgetOverflow')] public function testWidgetOverflow(?string $selected_operation): void { // If we want to press the "Save and insert" or "Save and select" buttons, // we need to enable the advanced UI. @@ -138,9 +138,8 @@ class WidgetOverflowTest extends MediaLibraryTestBase { * The operation of the button to click. For example, if this is "insert", * the "Save and insert" button will be pressed. If NULL, the "Save" button * will be pressed. - * - * @dataProvider providerWidgetOverflow */ + #[DataProvider('providerWidgetOverflow')] public function testUnlimitedCardinality(?string $selected_operation): void { if ($selected_operation) { $this->config('media_library.settings')->set('advanced_ui', TRUE)->save(); diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetUploadTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetUploadTest.php index 7e0b3af57f0..86cee3e17ad 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetUploadTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetUploadTest.php @@ -6,15 +6,15 @@ namespace Drupal\Tests\media_library\FunctionalJavascript; use Drupal\media\Entity\Media; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests that uploads in the 'media_library_widget' works as expected. * - * @group media_library - * * @todo This test will occasionally fail with SQLite until * https://www.drupal.org/node/3066447 is addressed. */ +#[Group('media_library')] class WidgetUploadTest extends MediaLibraryTestBase { use TestFileCreationTrait; diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetViewsTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetViewsTest.php index 09933c865a7..868aedfed4f 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetViewsTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetViewsTest.php @@ -4,11 +4,12 @@ declare(strict_types=1); namespace Drupal\Tests\media_library\FunctionalJavascript; +use PHPUnit\Framework\Attributes\Group; + /** * Tests the views in the media library widget. - * - * @group media_library */ +#[Group('media_library')] class WidgetViewsTest extends MediaLibraryTestBase { /** diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetWithoutTypesTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetWithoutTypesTest.php index 87eaea2af68..1008beadcc7 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetWithoutTypesTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetWithoutTypesTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\media_library\FunctionalJavascript; use Drupal\Core\Url; use Drupal\field_ui\FieldUI; +use PHPUnit\Framework\Attributes\Group; /** * Tests the media library widget when no media types are available. - * - * @group media_library */ +#[Group('media_library')] class WidgetWithoutTypesTest extends MediaLibraryTestBase { /** diff --git a/core/modules/menu_link_content/tests/src/Kernel/MenuLinksTest.php b/core/modules/menu_link_content/tests/src/Kernel/MenuLinksTest.php index 34e0a0eee04..751b93dd92d 100644 --- a/core/modules/menu_link_content/tests/src/Kernel/MenuLinksTest.php +++ b/core/modules/menu_link_content/tests/src/Kernel/MenuLinksTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\menu_link_content\Kernel; +use Drupal\Core\Link; use Drupal\Core\Menu\MenuTreeParameters; use Drupal\entity_test\Entity\EntityTestExternal; use Drupal\KernelTests\KernelTestBase; @@ -477,4 +478,32 @@ class MenuLinksTest extends KernelTestBase { static::assertIsArray($build); } + /** + * Assert that attributes are filtered. + */ + public function testXssFiltering(): void { + $options = [ + 'menu_name' => 'menu-test', + 'bundle' => 'menu_link_content', + 'link' => [ + [ + 'uri' => 'https://www.drupal.org/', + 'options' => [ + 'attributes' => [ + 'class' => 'classy', + 'onmouseover' => 'alert(document.cookie)', + ], + ], + ], + ], + 'title' => 'Link test', + ]; + $link = MenuLinkContent::create($options); + $link->save(); + assert($link instanceof MenuLinkContent); + $output = Link::fromTextAndUrl($link->getTitle(), $link->getUrlObject())->toString()->getGeneratedLink(); + $this->assertStringContainsString('<a href="https://www.drupal.org/" class="classy">', $output); + $this->assertStringNotContainsString('onmouseover=', $output); + } + } diff --git a/core/modules/menu_ui/src/Hook/MenuUiHooks.php b/core/modules/menu_ui/src/Hook/MenuUiHooks.php index 09e7c99f898..f36dcea8206 100644 --- a/core/modules/menu_ui/src/Hook/MenuUiHooks.php +++ b/core/modules/menu_ui/src/Hook/MenuUiHooks.php @@ -293,6 +293,38 @@ class MenuUiHooks { } /** + * Implements hook_ENTITY_TYPE_delete(). + */ + #[Hook('menu_delete')] + public function menuDelete(EntityInterface $entity): void { + if (!$this->entityTypeManager->hasDefinition('node_type')) { + return; + } + + // Remove the menu from content type third party settings. + $menu_id = $entity->id(); + $parent_prefix = $menu_id . ':'; + $storage = $this->entityTypeManager->getStorage('node_type'); + foreach ($storage->loadMultiple() as $content_type) { + $third_party_settings = $original_third_party_settings = $content_type->getThirdPartySettings('menu_ui'); + if (isset($third_party_settings['available_menus']) && in_array($menu_id, $third_party_settings['available_menus'])) { + $key = array_search($menu_id, $third_party_settings['available_menus']); + if ($key !== FALSE) { + unset($third_party_settings['available_menus'][$key]); + } + $content_type->setThirdPartySetting('menu_ui', 'available_menus', $third_party_settings['available_menus']); + } + if (isset($third_party_settings['parent']) && substr($third_party_settings['parent'], 0, strlen($parent_prefix)) == $parent_prefix) { + $third_party_settings['parent'] = ''; + $content_type->setThirdPartySetting('menu_ui', 'parent', $third_party_settings['parent']); + } + if ($third_party_settings != $original_third_party_settings) { + $content_type->save(); + } + } + } + + /** * Implements hook_system_breadcrumb_alter(). */ #[Hook('system_breadcrumb_alter')] diff --git a/core/modules/menu_ui/tests/src/FunctionalJavascript/MenuUiJavascriptTest.php b/core/modules/menu_ui/tests/src/FunctionalJavascript/MenuUiJavascriptTest.php index a174b7a33c1..eddb64ab927 100644 --- a/core/modules/menu_ui/tests/src/FunctionalJavascript/MenuUiJavascriptTest.php +++ b/core/modules/menu_ui/tests/src/FunctionalJavascript/MenuUiJavascriptTest.php @@ -9,12 +9,12 @@ use Drupal\system\Entity\Menu; use Drupal\system\MenuStorage; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; use Drupal\Tests\menu_ui\Traits\MenuUiTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests custom menu and menu links operations using the UI. - * - * @group menu_ui */ +#[Group('menu_ui')] class MenuUiJavascriptTest extends WebDriverTestBase { use ContextualLinkClickTrait; diff --git a/core/modules/menu_ui/tests/src/Kernel/MenuDeleteTest.php b/core/modules/menu_ui/tests/src/Kernel/MenuDeleteTest.php new file mode 100644 index 00000000000..3bb81874387 --- /dev/null +++ b/core/modules/menu_ui/tests/src/Kernel/MenuDeleteTest.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\menu_ui\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Drupal\menu_ui\Hook\MenuUiHooks; +use Drupal\node\Entity\NodeType; +use Drupal\system\Entity\Menu; + +/** + * Tests the menu_delete hook. + * + * @group menu_ui + */ +class MenuDeleteTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['node', 'menu_ui', 'system']; + + /** + * @covers \Drupal\menu_ui\Hook\MenuUiHooks::menuDelete + * @dataProvider providerMenuDelete + */ + public function testMenuDelete($settings, $expected): void { + $menu = Menu::create([ + 'id' => 'mock', + 'label' => $this->randomMachineName(16), + 'description' => 'Description text', + ]); + $menu->save(); + $content_type = NodeType::create([ + 'status' => TRUE, + 'dependencies' => [ + 'module' => ['menu_ui'], + ], + 'third_party_settings' => [ + 'menu_ui' => $settings, + ], + 'name' => 'Test type', + 'type' => 'test_type', + ]); + $content_type->save(); + $this->assertEquals($settings['available_menus'], $content_type->getThirdPartySetting('menu_ui', 'available_menus')); + $this->assertEquals($settings['parent'], $content_type->getThirdPartySetting('menu_ui', 'parent')); + + $hooks = new MenuUiHooks(\Drupal::entityTypeManager()); + $hooks->menuDelete($menu); + + $content_type = NodeType::load('test_type'); + $this->assertEquals($expected['available_menus'], $content_type->getThirdPartySetting('menu_ui', 'available_menus')); + $this->assertEquals($expected['parent'], $content_type->getThirdPartySetting('menu_ui', 'parent')); + } + + /** + * Provides data for testMenuDelete(). + */ + public static function providerMenuDelete(): array { + return [ + [ + ['available_menus' => ['mock'], 'parent' => 'mock:'], + ['available_menus' => [], 'parent' => ''], + ], + [ + ['available_menus' => ['mock'], 'parent' => 'mock:menu_link_content:e0cd7689-016e-43e4-af8f-7ce82801ab95'], + ['available_menus' => [], 'parent' => ''], + ], + [ + ['available_menus' => ['main', 'mock'], 'parent' => 'mock:'], + ['available_menus' => ['main'], 'parent' => ''], + ], + [ + ['available_menus' => ['main'], 'parent' => 'main:'], + ['available_menus' => ['main'], 'parent' => 'main:'], + ], + [ + ['available_menus' => ['main'], 'parent' => 'main:menu_link_content:e0cd7689-016e-43e4-af8f-7ce82801ab95'], + ['available_menus' => ['main'], 'parent' => 'main:menu_link_content:e0cd7689-016e-43e4-af8f-7ce82801ab95'], + ], + ]; + } + +} diff --git a/core/modules/migrate/migrate.api.php b/core/modules/migrate/migrate.api.php index 1e58b0090ff..5d2af7db180 100644 --- a/core/modules/migrate/migrate.api.php +++ b/core/modules/migrate/migrate.api.php @@ -156,7 +156,7 @@ function hook_migrate_prepare_row(Row $row, MigrateSourceInterface $source, Migr if ($migration->id() == 'd6_filter_formats') { $value = $source->getDatabase()->query('SELECT [value] FROM {variable} WHERE [name] = :name', [':name' => 'my_module_filter_foo_' . $row->getSourceProperty('format')])->fetchField(); if ($value) { - $row->setSourceProperty('settings:my_module:foo', unserialize($value)); + $row->setSourceProperty('settings:my_module:foo', unserialize($value, ['allowed_classes' => FALSE])); } } } @@ -179,7 +179,7 @@ function hook_migrate_prepare_row(Row $row, MigrateSourceInterface $source, Migr function hook_migrate_MIGRATION_ID_prepare_row(Row $row, MigrateSourceInterface $source, MigrationInterface $migration) { $value = $source->getDatabase()->query('SELECT [value] FROM {variable} WHERE [name] = :name', [':name' => 'my_module_filter_foo_' . $row->getSourceProperty('format')])->fetchField(); if ($value) { - $row->setSourceProperty('settings:my_module:foo', unserialize($value)); + $row->setSourceProperty('settings:my_module:foo', unserialize($value, ['allowed_classes' => FALSE])); } } diff --git a/core/modules/migrate/src/Plugin/Discovery/AnnotatedClassDiscoveryAutomatedProviders.php b/core/modules/migrate/src/Plugin/Discovery/AnnotatedClassDiscoveryAutomatedProviders.php index 9adf60b46ff..30cc28562e8 100644 --- a/core/modules/migrate/src/Plugin/Discovery/AnnotatedClassDiscoveryAutomatedProviders.php +++ b/core/modules/migrate/src/Plugin/Discovery/AnnotatedClassDiscoveryAutomatedProviders.php @@ -65,7 +65,7 @@ class AnnotatedClassDiscoveryAutomatedProviders extends AnnotatedClassDiscovery if (isset($cached['id'])) { // Explicitly unserialize this to create a new object // instance. - $definitions[$cached['id']] = unserialize($cached['content']); + $definitions[$cached['id']] = unserialize($cached['content'], ['allowed_classes' => FALSE]); } continue; } diff --git a/core/modules/migrate/src/Plugin/Migration.php b/core/modules/migrate/src/Plugin/Migration.php index 961d7edd76b..20c99bbd3e0 100644 --- a/core/modules/migrate/src/Plugin/Migration.php +++ b/core/modules/migrate/src/Plugin/Migration.php @@ -226,6 +226,8 @@ class Migration extends PluginBase implements MigrationInterface, RequirementsIn * These are different from the configuration dependencies. Migration * dependencies are only used to store relationships between migrations. * + * @var array + * * The migration_dependencies value is structured like this: * @code * [ @@ -238,10 +240,8 @@ class Migration extends PluginBase implements MigrationInterface, RequirementsIn * ], * ]; * @endcode - * - * @var array */ - // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName, Drupal.Commenting.VariableComment.Missing + // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName protected $migration_dependencies = []; /** diff --git a/core/modules/migrate/src/Plugin/migrate/source/ConfigEntity.php b/core/modules/migrate/src/Plugin/migrate/source/ConfigEntity.php index dc70496282f..a5351c74862 100644 --- a/core/modules/migrate/src/Plugin/migrate/source/ConfigEntity.php +++ b/core/modules/migrate/src/Plugin/migrate/source/ConfigEntity.php @@ -71,7 +71,8 @@ class ConfigEntity extends SqlBase { * {@inheritdoc} */ public function prepareRow(Row $row) { - $row->setSourceProperty('data', unserialize($row->getSourceProperty('data'))); + // @see \Drupal\Core\Config\DatabaseStorage::decode() + $row->setSourceProperty('data', unserialize($row->getSourceProperty('data'), ['allowed_classes' => FALSE])); return parent::prepareRow($row); } 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/src/Row.php b/core/modules/migrate/src/Row.php index 3d961902bcd..2b5e8b2fb4e 100644 --- a/core/modules/migrate/src/Row.php +++ b/core/modules/migrate/src/Row.php @@ -267,6 +267,32 @@ class Row { } /** + * Tests if a property is an empty destination. + * + * @param string $property + * The name of the property. + * + * @return bool + * TRUE if the property is an empty destination. + */ + public function hasEmptyDestinationProperty(string $property): bool { + return in_array($property, $this->emptyDestinationProperties); + } + + /** + * Removes an empty destination property. + * + * @param string $property + * The name of the empty destination property. + */ + public function removeEmptyDestinationProperty(string $property): void { + $this->emptyDestinationProperties = array_diff( + $this->emptyDestinationProperties, + [$property], + ); + } + + /** * Returns the whole destination array. * * @return array diff --git a/core/modules/migrate/tests/src/Functional/MigrateMessageTestBase.php b/core/modules/migrate/tests/src/Functional/MigrateMessageTestBase.php index 6885ba378e9..84dddc3c182 100644 --- a/core/modules/migrate/tests/src/Functional/MigrateMessageTestBase.php +++ b/core/modules/migrate/tests/src/Functional/MigrateMessageTestBase.php @@ -12,10 +12,8 @@ use Drupal\migrate\Plugin\MigrationInterface; /** * Provides base class for testing migrate messages. - * - * @group migrate */ -class MigrateMessageTestBase extends BrowserTestBase { +abstract class MigrateMessageTestBase extends BrowserTestBase { /** * {@inheritdoc} diff --git a/core/modules/migrate/tests/src/Kernel/RowTest.php b/core/modules/migrate/tests/src/Kernel/RowTest.php new file mode 100644 index 00000000000..1b4adf181c8 --- /dev/null +++ b/core/modules/migrate/tests/src/Kernel/RowTest.php @@ -0,0 +1,152 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\migrate\Kernel; + +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\KernelTests\KernelTestBase; +use Drupal\migrate\Event\MigratePreRowSaveEvent; +use Drupal\migrate\Event\MigrateEvents; +use Drupal\migrate\MigrateExecutable; +use Drupal\migrate\Plugin\MigrationPluginManagerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; + +/** + * Tests the Row class. + * + * @group migrate + */ +class RowTest extends KernelTestBase { + + /** + * The event dispatcher. + */ + protected EventDispatcherInterface $eventDispatcher; + + /** + * The entity type manager. + */ + protected EntityTypeManagerInterface $entityTypeManager; + + /** + * The migration manager. + */ + protected MigrationPluginManagerInterface $migrationManager; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'entity_test', + 'field', + 'migrate', + 'user', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('entity_test'); + + $this->eventDispatcher = \Drupal::service('event_dispatcher'); + $this->entityTypeManager = \Drupal::service('entity_type.manager'); + $this->migrationManager = \Drupal::service('plugin.manager.migration'); + + // Create two fields that will be set during migration. + $fields = ['field1', 'field2']; + foreach ($fields as $field) { + $this->entityTypeManager->getStorage('field_storage_config')->create([ + 'entity_type' => 'entity_test', + 'field_name' => $field, + 'type' => 'string', + ])->save(); + $this->entityTypeManager->getStorage('field_config')->create([ + 'entity_type' => 'entity_test', + 'field_name' => $field, + 'bundle' => 'entity_test', + ])->save(); + } + } + + /** + * Tests the destination properties of the Row class. + */ + public function testRowDestinations(): void { + $storage = $this->entityTypeManager->getStorage('entity_test'); + + // Execute a migration that creates an entity with two fields. + $data_rows = [ + ['id' => 1, 'field1' => 'f1value', 'field2' => 'f2value'], + ]; + $ids = ['id' => ['type' => 'integer']]; + $definition = [ + 'source' => [ + 'plugin' => 'embedded_data', + 'data_rows' => $data_rows, + 'ids' => $ids, + ], + 'process' => [ + 'id' => 'id', + 'field1' => 'field1', + 'field2' => 'field2', + ], + 'destination' => ['plugin' => 'entity:entity_test'], + ]; + $this->executeMigrationImport($definition); + $entity = $storage->load(1); + $this->assertEquals('f1value', $entity->get('field1')->getValue()[0]['value']); + $this->assertEquals('f2value', $entity->get('field2')->getValue()[0]['value']); + + // Execute a second migration that attempts to remove both field values. + // The event listener prevents the removal of the second field. + $data_rows = [ + ['id' => 1, 'field1' => NULL, 'field2' => NULL], + ]; + $definition['source']['data_rows'] = $data_rows; + $this->eventDispatcher->addListener(MigrateEvents::PRE_ROW_SAVE, [$this, 'preventFieldRemoval']); + $this->executeMigrationImport($definition); + + // The first field is now empty but the second field is still set. + $entity = $storage->load(1); + $this->assertTrue($entity->get('field1')->isEmpty()); + $this->assertEquals('f2value', $entity->get('field2')->getValue()[0]['value']); + } + + /** + * The pre-row-save event handler for the second migration. + * + * Checks row destinations and prevents the removal of the second field. + * + * @param \Drupal\migrate\Event\MigratePreRowSaveEvent $event + * The migration event. + * @param string $name + * The event name. + */ + public function preventFieldRemoval(MigratePreRowSaveEvent $event, string $name): void { + $row = $event->getRow(); + + // Both fields are empty and their existing values will be removed. + $this->assertFalse($row->hasDestinationProperty('field1')); + $this->assertFalse($row->hasDestinationProperty('field2')); + $this->assertTrue($row->hasEmptyDestinationProperty('field1')); + $this->assertTrue($row->hasEmptyDestinationProperty('field2')); + + // Prevent removal of field 2. + $row->removeEmptyDestinationProperty('field2'); + } + + /** + * Executes a migration import for the given migration definition. + * + * @param array $definition + * The migration definition. + */ + protected function executeMigrationImport(array $definition): void { + $migration = $this->migrationManager->createStubMigration($definition); + (new MigrateExecutable($migration))->import(); + } + +} 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/tests/src/Unit/Plugin/migrate/destination/EntityTestBase.php b/core/modules/migrate/tests/src/Unit/Plugin/migrate/destination/EntityTestBase.php index ba9ab78cff6..ed223601abb 100644 --- a/core/modules/migrate/tests/src/Unit/Plugin/migrate/destination/EntityTestBase.php +++ b/core/modules/migrate/tests/src/Unit/Plugin/migrate/destination/EntityTestBase.php @@ -14,7 +14,7 @@ use Drupal\Tests\UnitTestCase; /** * Base test class for entity migration destination functionality. */ -class EntityTestBase extends UnitTestCase { +abstract class EntityTestBase extends UnitTestCase { /** * The migration entity. diff --git a/core/modules/migrate/tests/src/Unit/RowTest.php b/core/modules/migrate/tests/src/Unit/RowTest.php index 69fce6f7860..7db1a51db43 100644 --- a/core/modules/migrate/tests/src/Unit/RowTest.php +++ b/core/modules/migrate/tests/src/Unit/RowTest.php @@ -292,6 +292,43 @@ class RowTest extends UnitTestCase { } /** + * Tests checking for and removing destination properties that may be empty. + * + * @covers ::hasEmptyDestinationProperty + * @covers ::removeEmptyDestinationProperty + */ + public function testDestinationOrEmptyProperty(): void { + $row = new Row($this->testValues, $this->testSourceIds); + + // Set a destination. + $row->setDestinationProperty('nid', 2); + $this->assertTrue($row->hasDestinationProperty('nid')); + $this->assertFalse($row->hasEmptyDestinationProperty('nid')); + + // Set an empty destination. + $row->setEmptyDestinationProperty('a_property_with_no_value'); + $this->assertTrue($row->hasEmptyDestinationProperty('a_property_with_no_value')); + $this->assertFalse($row->hasDestinationProperty('a_property_with_no_value')); + + // Removing an empty destination that is not actually empty has no effect. + $row->removeEmptyDestinationProperty('nid'); + $this->assertTrue($row->hasDestinationProperty('nid')); + $this->assertFalse($row->hasEmptyDestinationProperty('nid')); + + // Removing a destination that is actually empty has no effect. + $row->removeDestinationProperty('a_property_with_no_value'); + $this->assertTrue($row->hasEmptyDestinationProperty('a_property_with_no_value')); + + // Remove the empty destination. + $row->removeEmptyDestinationProperty('a_property_with_no_value'); + $this->assertFalse($row->hasEmptyDestinationProperty('a_property_with_no_value')); + + // Removing a destination that does not exist does not throw an error. + $this->assertFalse($row->hasEmptyDestinationProperty('not_a_property')); + $row->removeEmptyDestinationProperty('not_a_property'); + } + + /** * Tests setting/getting multiple destination IDs. */ public function testMultipleDestination(): void { diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/source/DrupalSqlBase.php b/core/modules/migrate_drupal/src/Plugin/migrate/source/DrupalSqlBase.php index 64ac2031a3f..06d9bbccc97 100644 --- a/core/modules/migrate_drupal/src/Plugin/migrate/source/DrupalSqlBase.php +++ b/core/modules/migrate_drupal/src/Plugin/migrate/source/DrupalSqlBase.php @@ -173,7 +173,7 @@ abstract class DrupalSqlBase extends SqlBase implements DependentPluginInterface catch (\Exception) { $result = FALSE; } - return $result !== FALSE ? unserialize($result) : $default; + return $result !== FALSE ? unserialize($result, ['allowed_classes' => ['stdClass']]) : $default; } /** diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/source/Variable.php b/core/modules/migrate_drupal/src/Plugin/migrate/source/Variable.php index 0b83a099afb..f99cd69e898 100644 --- a/core/modules/migrate_drupal/src/Plugin/migrate/source/Variable.php +++ b/core/modules/migrate_drupal/src/Plugin/migrate/source/Variable.php @@ -129,7 +129,10 @@ class Variable extends DrupalSqlBase { // Create an ID field so we can record migration in the map table. // Arbitrarily, use the first variable name. $values['id'] = reset($this->variables); - return $values + array_map('unserialize', $this->prepareQuery()->execute()->fetchAllKeyed()); + return $values + array_map( + fn($data) => unserialize($data, ['allowed_classes' => FALSE]), + $this->prepareQuery()->execute()->fetchAllKeyed(), + ); } /** diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/source/VariableMultiRow.php b/core/modules/migrate_drupal/src/Plugin/migrate/source/VariableMultiRow.php index ee9b6268ebb..e2bcbddc8d3 100644 --- a/core/modules/migrate_drupal/src/Plugin/migrate/source/VariableMultiRow.php +++ b/core/modules/migrate_drupal/src/Plugin/migrate/source/VariableMultiRow.php @@ -66,7 +66,7 @@ class VariableMultiRow extends DrupalSqlBase { */ public function prepareRow(Row $row) { if ($value = $row->getSourceProperty('value')) { - $row->setSourceProperty('value', unserialize($value)); + $row->setSourceProperty('value', unserialize($value, ['allowed_classes' => FALSE])); } return parent::prepareRow($row); } diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/source/d6/VariableTranslation.php b/core/modules/migrate_drupal/src/Plugin/migrate/source/d6/VariableTranslation.php index 11323f4bf7a..f9e6ef61d75 100644 --- a/core/modules/migrate_drupal/src/Plugin/migrate/source/d6/VariableTranslation.php +++ b/core/modules/migrate_drupal/src/Plugin/migrate/source/d6/VariableTranslation.php @@ -78,7 +78,7 @@ class VariableTranslation extends DrupalSqlBase { foreach ($result as $i18n_variable) { foreach ($values as $key => $value) { if ($values[$key]['language'] === $i18n_variable->language) { - $values[$key][$i18n_variable->name] = unserialize($i18n_variable->value); + $values[$key][$i18n_variable->name] = unserialize($i18n_variable->value, ['allowed_classes' => FALSE]); break; } } diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/source/d7/VariableTranslation.php b/core/modules/migrate_drupal/src/Plugin/migrate/source/d7/VariableTranslation.php index 56121db822a..82d638fa3d8 100644 --- a/core/modules/migrate_drupal/src/Plugin/migrate/source/d7/VariableTranslation.php +++ b/core/modules/migrate_drupal/src/Plugin/migrate/source/d7/VariableTranslation.php @@ -78,7 +78,7 @@ class VariableTranslation extends DrupalSqlBase { foreach ($values as $key => $value) { if ($values[$key]['language'] === $variable_store['realm_key']) { if ($variable_store['serialized']) { - $values[$key][$variable_store['name']] = unserialize($variable_store['value']); + $values[$key][$variable_store['name']] = unserialize($variable_store['value'], ['allowed_classes' => FALSE]); break; } else { diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/source/d8/Config.php b/core/modules/migrate_drupal/src/Plugin/migrate/source/d8/Config.php index b1d409f4017..1bb5b4f51bf 100644 --- a/core/modules/migrate_drupal/src/Plugin/migrate/source/d8/Config.php +++ b/core/modules/migrate_drupal/src/Plugin/migrate/source/d8/Config.php @@ -88,7 +88,8 @@ class Config extends DrupalSqlBase { * {@inheritdoc} */ public function prepareRow(Row $row) { - $row->setSourceProperty('data', unserialize($row->getSourceProperty('data'))); + // @see \Drupal\Core\Config\DatabaseStorage::decode() + $row->setSourceProperty('data', unserialize($row->getSourceProperty('data'), ['allowed_classes' => FALSE])); return parent::prepareRow($row); } 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/FollowUpMigrationsTest.php b/core/modules/migrate_drupal/tests/src/Kernel/d7/FollowUpMigrationsTest.php index 151b7fae247..c7d40c4c2d9 100644 --- a/core/modules/migrate_drupal/tests/src/Kernel/d7/FollowUpMigrationsTest.php +++ b/core/modules/migrate_drupal/tests/src/Kernel/d7/FollowUpMigrationsTest.php @@ -12,6 +12,7 @@ use Drupal\user\Entity\User; * Tests follow-up migrations. * * @group migrate_drupal + * @group #slow */ class FollowUpMigrationsTest 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/migrate_drupal_ui.routing.yml b/core/modules/migrate_drupal_ui/migrate_drupal_ui.routing.yml index 61f77faa44c..1348f7dc04b 100644 --- a/core/modules/migrate_drupal_ui/migrate_drupal_ui.routing.yml +++ b/core/modules/migrate_drupal_ui/migrate_drupal_ui.routing.yml @@ -50,6 +50,6 @@ migrate_drupal_ui.log: defaults: _controller: '\Drupal\migrate_drupal_ui\Controller\MigrateController::showLog' requirements: - _custom_access: '\Drupal\migrate_drupal_ui\MigrateAccessCheck::checkAccess' + _permission: 'access site reports' options: _admin_route: TRUE diff --git a/core/modules/migrate_drupal_ui/src/Form/ReviewForm.php b/core/modules/migrate_drupal_ui/src/Form/ReviewForm.php index a28dada3e9c..c6e76787f31 100644 --- a/core/modules/migrate_drupal_ui/src/Form/ReviewForm.php +++ b/core/modules/migrate_drupal_ui/src/Form/ReviewForm.php @@ -286,7 +286,7 @@ class ReviewForm extends MigrateUpgradeFormBase { foreach ($migration_state as $source_machine_name => $destination_modules) { $data = NULL; if (isset($this->systemData['module'][$source_machine_name]['info'])) { - $data = unserialize($this->systemData['module'][$source_machine_name]['info']); + $data = unserialize($this->systemData['module'][$source_machine_name]['info'], ['allowed_classes' => FALSE]); } $source_module_name = $data['name'] ?? $source_machine_name; // Get the names of all the destination modules. diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateControllerTest.php b/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateControllerTest.php index 68071daba55..56d9e10a91f 100644 --- a/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateControllerTest.php +++ b/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateControllerTest.php @@ -25,14 +25,6 @@ class MigrateControllerTest extends BrowserTestBase { /** * {@inheritdoc} - * - * @todo Remove and fix test to not rely on super user. - * @see https://www.drupal.org/project/drupal/issues/3437620 - */ - protected bool $usesSuperUserAccessPolicy = TRUE; - - /** - * {@inheritdoc} */ protected $defaultTheme = 'stark'; @@ -42,8 +34,9 @@ class MigrateControllerTest extends BrowserTestBase { protected function setUp(): void { parent::setUp(); - // Log in as user 1. Migrations in the UI can only be performed as user 1. - $this->drupalLogin($this->rootUser); + // Log in as a user with access to view the migration report. + $account = $this->drupalCreateUser(['access site reports', 'administer views']); + $this->drupalLogin($account); // Create a migrate message for testing purposes. \Drupal::logger('migrate_drupal_ui')->notice('A test message'); diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateUpgradeTestBase.php b/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateUpgradeTestBase.php index 759f59b50a8..a6633fed5e9 100644 --- a/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateUpgradeTestBase.php +++ b/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateUpgradeTestBase.php @@ -269,7 +269,7 @@ abstract class MigrateUpgradeTestBase extends BrowserTestBase { // Convert $source_id into a keyless array so that // \Drupal\migrate\Plugin\migrate\id_map\Sql::getSourceHash() works as // expected. - $source_id_values = array_values(unserialize($source_id)); + $source_id_values = array_values(unserialize($source_id, ['allowed_classes' => FALSE])); $row = $id_map->getRowBySource($source_id_values); $destination = serialize($id_map->currentDestination()); $message = "Migration of $source_id to $destination as part of the {$migration->id()} migration. The source row status is " . $row['source_row_status']; 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/migrate_drupal_ui/tests/src/FunctionalJavascript/SettingsTest.php b/core/modules/migrate_drupal_ui/tests/src/FunctionalJavascript/SettingsTest.php index f85771b52b6..db9f32cb6a6 100644 --- a/core/modules/migrate_drupal_ui/tests/src/FunctionalJavascript/SettingsTest.php +++ b/core/modules/migrate_drupal_ui/tests/src/FunctionalJavascript/SettingsTest.php @@ -5,14 +5,14 @@ declare(strict_types=1); namespace Drupal\Tests\migrate_drupal_ui\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore drupalmysqldriverdatabasemysql - /** * Tests migrate upgrade credential form with settings in settings.php. - * - * @group migrate_drupal_ui */ +#[Group('migrate_drupal_ui')] class SettingsTest extends WebDriverTestBase { /** @@ -55,9 +55,8 @@ class SettingsTest extends WebDriverTestBase { * * @throws \Behat\Mink\Exception\ElementNotFoundException * @throws \Behat\Mink\Exception\ExpectationException - * - * @dataProvider providerTestCredentialForm */ + #[DataProvider('providerTestCredentialForm')] public function testCredentialForm($source_connection, $version, array $manual, array $databases, $expected_source_connection): void { // Write settings. $migrate_file_public_path = '/var/www/drupal7/sites/default/files'; diff --git a/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php b/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php index 7926175e0dc..9f2a89ca6ea 100644 --- a/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php +++ b/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php @@ -5,6 +5,7 @@ namespace Drupal\mysql\Driver\Database\mysql; use Drupal\Component\Utility\Unicode; use Drupal\Core\Database\DatabaseExceptionWrapper; use Drupal\Core\Database\ExceptionHandler as BaseExceptionHandler; +use Drupal\Core\Database\Exception\SchemaPrimaryKeyMustBeDroppedException; use Drupal\Core\Database\Exception\SchemaTableColumnSizeTooLargeException; use Drupal\Core\Database\Exception\SchemaTableKeyTooLargeException; use Drupal\Core\Database\IntegrityConstraintViolationException; @@ -19,44 +20,81 @@ class ExceptionHandler extends BaseExceptionHandler { * {@inheritdoc} */ public function handleExecutionException(\Exception $exception, StatementInterface $statement, array $arguments = [], array $options = []): void { - if ($exception instanceof \PDOException) { - // Wrap the exception in another exception, because PHP does not allow - // overriding Exception::getMessage(). Its message is the extra database - // debug information. - $code = is_int($exception->getCode()) ? $exception->getCode() : 0; - - // If a max_allowed_packet error occurs the message length is truncated. - // This should prevent the error from recurring if the exception is logged - // to the database using dblog or the like. - if (($exception->errorInfo[1] ?? NULL) === 1153) { - $message = Unicode::truncateBytes($exception->getMessage(), Connection::MIN_MAX_ALLOWED_PACKET); - throw new DatabaseExceptionWrapper($message, $code, $exception); - } - - $message = $exception->getMessage() . ": " . $statement->getQueryString() . "; " . print_r($arguments, TRUE); - - // SQLSTATE 23xxx errors indicate an integrity constraint violation. Also, - // in case of attempted INSERT of a record with an undefined column and no - // default value indicated in schema, MySql returns a 1364 error code. - if ( - substr($exception->getCode(), -6, -3) == '23' || - ($exception->errorInfo[1] ?? NULL) === 1364 - ) { - throw new IntegrityConstraintViolationException($message, $code, $exception); - } - - if ($exception->getCode() === '42000') { - match ($exception->errorInfo[1]) { - 1071 => throw new SchemaTableKeyTooLargeException($message, $code, $exception), - 1074 => throw new SchemaTableColumnSizeTooLargeException($message, $code, $exception), - default => throw new DatabaseExceptionWrapper($message, 0, $exception), - }; - } - - throw new DatabaseExceptionWrapper($message, 0, $exception); + if (!$exception instanceof \PDOException) { + throw $exception; + } + $this->rethrowNormalizedException($exception, $exception->getCode(), $exception->errorInfo[1] ?? NULL, $statement->getQueryString(), $arguments); + } + + /** + * Rethrows exceptions thrown during execution of statement objects. + * + * Wrap the exception in another exception, because PHP does not allow + * overriding Exception::getMessage(). Its message is the extra database + * debug information. + * + * @param \Exception $exception + * The exception to be handled. + * @param int|string $sqlState + * MySql SQLState error condition. + * @param int|null $errorCode + * MySql error code. + * @param string $queryString + * The SQL statement string. + * @param array $arguments + * An array of arguments for the prepared statement. + * + * @throws \Drupal\Core\Database\DatabaseExceptionWrapper + * @throws \Drupal\Core\Database\IntegrityConstraintViolationException + * @throws \Drupal\Core\Database\Exception\SchemaPrimaryKeyMustBeDroppedException + * @throws \Drupal\Core\Database\Exception\SchemaTableColumnSizeTooLargeException + * @throws \Drupal\Core\Database\Exception\SchemaTableKeyTooLargeException + */ + protected function rethrowNormalizedException( + \Exception $exception, + int|string $sqlState, + ?int $errorCode, + string $queryString, + array $arguments, + ): void { + + // SQLState could be 'HY000' which cannot be used as a $code argument for + // exceptions. PDOException is contravariant in this case, but since we are + // re-throwing an exception that inherits from \Exception, we need to + // convert the code to an integer. + // @see https://www.php.net/manual/en/class.exception.php + // @see https://www.php.net/manual/en/class.pdoexception.php + $code = (int) $sqlState; + + // If a max_allowed_packet error occurs the message length is truncated. + // This should prevent the error from recurring if the exception is logged + // to the database using dblog or the like. + if ($errorCode === 1153) { + $message = Unicode::truncateBytes($exception->getMessage(), Connection::MIN_MAX_ALLOWED_PACKET); + throw new DatabaseExceptionWrapper($message, $code, $exception); + } + + $message = $exception->getMessage() . ": " . $queryString . "; " . print_r($arguments, TRUE); + + // SQLSTATE 23xxx errors indicate an integrity constraint violation. Also, + // in case of attempted INSERT of a record with an undefined column and no + // default value indicated in schema, MySql returns a 1364 error code. + if (substr($sqlState, -6, -3) == '23' || $errorCode === 1364) { + throw new IntegrityConstraintViolationException($message, $code, $exception); } - throw $exception; + match ($sqlState) { + 'HY000' => match ($errorCode) { + 4111 => throw new SchemaPrimaryKeyMustBeDroppedException($message, 0, $exception), + default => throw new DatabaseExceptionWrapper($message, 0, $exception), + }, + '42000' => match ($errorCode) { + 1071 => throw new SchemaTableKeyTooLargeException($message, $code, $exception), + 1074 => throw new SchemaTableColumnSizeTooLargeException($message, $code, $exception), + default => throw new DatabaseExceptionWrapper($message, 0, $exception), + }, + default => throw new DatabaseExceptionWrapper($message, 0, $exception), + }; } } diff --git a/core/modules/mysql/src/Driver/Database/mysql/Schema.php b/core/modules/mysql/src/Driver/Database/mysql/Schema.php index a8b9c07564e..c3eb2858433 100644 --- a/core/modules/mysql/src/Driver/Database/mysql/Schema.php +++ b/core/modules/mysql/src/Driver/Database/mysql/Schema.php @@ -2,7 +2,7 @@ namespace Drupal\mysql\Driver\Database\mysql; -use Drupal\Core\Database\DatabaseExceptionWrapper; +use Drupal\Core\Database\Exception\SchemaPrimaryKeyMustBeDroppedException; use Drupal\Core\Database\SchemaException; use Drupal\Core\Database\SchemaObjectExistsException; use Drupal\Core\Database\SchemaObjectDoesNotExistException; @@ -438,11 +438,11 @@ class Schema extends DatabaseSchema { try { $this->executeDdlStatement($query); } - catch (DatabaseExceptionWrapper $e) { + catch (SchemaPrimaryKeyMustBeDroppedException $e) { // MySQL error number 4111 (ER_DROP_PK_COLUMN_TO_DROP_GIPK) indicates that // when dropping and adding a primary key, the generated invisible primary // key (GIPK) column must also be dropped. - if (isset($e->getPrevious()->errorInfo[1]) && $e->getPrevious()->errorInfo[1] === 4111 && isset($keys_new['primary key']) && $this->indexExists($table, 'PRIMARY') && $this->findPrimaryKeyColumns($table) === ['my_row_id']) { + if (isset($keys_new['primary key']) && $this->indexExists($table, 'PRIMARY') && $this->findPrimaryKeyColumns($table) === ['my_row_id']) { $this->executeDdlStatement($query . ', DROP COLUMN [my_row_id]'); } else { 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/mysql/tests/src/Functional/RequirementsTest.php b/core/modules/mysql/tests/src/Functional/RequirementsTest.php index 5d054334b69..38617714bc8 100644 --- a/core/modules/mysql/tests/src/Functional/RequirementsTest.php +++ b/core/modules/mysql/tests/src/Functional/RequirementsTest.php @@ -32,7 +32,7 @@ class RequirementsTest extends BrowserTestBase { // The isolation_level option is only available for MySQL. $connection = Database::getConnection(); - if ($connection->driver() !== 'mysql') { + if (!in_array($connection->driver(), ['mysql', 'mysqli'])) { $this->markTestSkipped("This test does not support the {$connection->driver()} database driver."); } } diff --git a/core/modules/mysqli/mysqli.info.yml b/core/modules/mysqli/mysqli.info.yml new file mode 100644 index 00000000000..38a9239f3e9 --- /dev/null +++ b/core/modules/mysqli/mysqli.info.yml @@ -0,0 +1,9 @@ +name: MySQLi +type: module +description: 'Database driver for MySQLi.' +version: VERSION +package: Core (Experimental) +lifecycle: experimental +hidden: true +dependencies: + - drupal:mysql diff --git a/core/modules/mysqli/mysqli.services.yml b/core/modules/mysqli/mysqli.services.yml new file mode 100644 index 00000000000..82a476ceb9e --- /dev/null +++ b/core/modules/mysqli/mysqli.services.yml @@ -0,0 +1,4 @@ +services: + mysqli.views.cast_sql: + class: Drupal\mysqli\Plugin\views\query\MysqliCastSql + public: false diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Connection.php b/core/modules/mysqli/src/Driver/Database/mysqli/Connection.php new file mode 100644 index 00000000000..e41df23075a --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Connection.php @@ -0,0 +1,191 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\Connection as BaseConnection; +use Drupal\Core\Database\ConnectionNotDefinedException; +use Drupal\Core\Database\Database; +use Drupal\Core\Database\DatabaseAccessDeniedException; +use Drupal\Core\Database\DatabaseNotFoundException; +use Drupal\Core\Database\Transaction\TransactionManagerInterface; +use Drupal\mysql\Driver\Database\mysql\Connection as BaseMySqlConnection; + +/** + * MySQLi implementation of \Drupal\Core\Database\Connection. + */ +class Connection extends BaseMySqlConnection { + + /** + * {@inheritdoc} + */ + protected $statementWrapperClass = Statement::class; + + public function __construct( + \mysqli $connection, + array $connectionOptions = [], + ) { + // If the SQL mode doesn't include 'ANSI_QUOTES' (explicitly or via a + // combination mode), then MySQL doesn't interpret a double quote as an + // identifier quote, in which case use the non-ANSI-standard backtick. + // + // @see https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi_quotes + $ansiQuotesModes = ['ANSI_QUOTES', 'ANSI']; + $isAnsiQuotesMode = FALSE; + if (isset($connectionOptions['init_commands']['sql_mode'])) { + foreach ($ansiQuotesModes as $mode) { + // None of the modes in $ansiQuotesModes are substrings of other modes + // that are not in $ansiQuotesModes, so a simple stripos() does not + // return false positives. + if (stripos($connectionOptions['init_commands']['sql_mode'], $mode) !== FALSE) { + $isAnsiQuotesMode = TRUE; + break; + } + } + } + + if ($this->identifierQuotes === ['"', '"'] && !$isAnsiQuotesMode) { + $this->identifierQuotes = ['`', '`']; + } + + BaseConnection::__construct($connection, $connectionOptions); + } + + /** + * {@inheritdoc} + */ + public static function open(array &$connection_options = []) { + // Sets mysqli error reporting mode to report errors from mysqli function + // calls and to throw mysqli_sql_exception for errors. + // @see https://www.php.net/manual/en/mysqli-driver.report-mode.php + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + // Allow PDO options to be overridden. + $connection_options += [ + 'pdo' => [], + ]; + + try { + $mysqli = @new \mysqli( + $connection_options['host'], + $connection_options['username'], + $connection_options['password'], + $connection_options['database'] ?? '', + !empty($connection_options['port']) ? (int) $connection_options['port'] : 3306, + $connection_options['unix_socket'] ?? '' + ); + if (!$mysqli->set_charset('utf8mb4')) { + throw new InvalidCharsetException('Invalid charset utf8mb4'); + } + } + catch (\mysqli_sql_exception $e) { + if ($e->getCode() === static::DATABASE_NOT_FOUND) { + throw new DatabaseNotFoundException($e->getMessage(), $e->getCode(), $e); + } + elseif ($e->getCode() === static::ACCESS_DENIED) { + throw new DatabaseAccessDeniedException($e->getMessage(), $e->getCode(), $e); + } + + throw new ConnectionNotDefinedException('Invalid database connection: ' . $e->getMessage(), $e->getCode(), $e); + } + + // Force MySQL to use the UTF-8 character set. Also set the collation, if a + // certain one has been set; otherwise, MySQL defaults to + // 'utf8mb4_0900_ai_ci' for the 'utf8mb4' character set. + if (!empty($connection_options['collation'])) { + $mysqli->query('SET NAMES utf8mb4 COLLATE ' . $connection_options['collation']); + } + else { + $mysqli->query('SET NAMES utf8mb4'); + } + + // Set MySQL init_commands if not already defined. Default Drupal's MySQL + // behavior to conform more closely to SQL standards. This allows Drupal + // to run almost seamlessly on many different kinds of database systems. + // These settings force MySQL to behave the same as postgresql, or sqlite + // in regard to syntax interpretation and invalid data handling. See + // https://www.drupal.org/node/344575 for further discussion. Also, as MySQL + // 5.5 changed the meaning of TRADITIONAL we need to spell out the modes one + // by one. + $connection_options += [ + 'init_commands' => [], + ]; + + $connection_options['init_commands'] += [ + 'sql_mode' => "SET sql_mode = 'ANSI,TRADITIONAL'", + ]; + if (!empty($connection_options['isolation_level'])) { + $connection_options['init_commands'] += [ + 'isolation_level' => 'SET SESSION TRANSACTION ISOLATION LEVEL ' . strtoupper($connection_options['isolation_level']), + ]; + } + + // Execute initial commands. + foreach ($connection_options['init_commands'] as $sql) { + $mysqli->query($sql); + } + + return $mysqli; + } + + /** + * {@inheritdoc} + */ + public function driver() { + return 'mysqli'; + } + + /** + * {@inheritdoc} + */ + public function clientVersion() { + return \mysqli_get_client_info(); + } + + /** + * {@inheritdoc} + */ + public function createDatabase($database): void { + // Escape the database name. + $database = Database::getConnection()->escapeDatabase($database); + + try { + // Create the database and set it as active. + $this->connection->query("CREATE DATABASE $database"); + $this->connection->query("USE $database"); + } + catch (\Exception $e) { + throw new DatabaseNotFoundException($e->getMessage()); + } + } + + /** + * {@inheritdoc} + */ + public function quote($string, $parameter_type = \PDO::PARAM_STR) { + return "'" . $this->connection->escape_string((string) $string) . "'"; + } + + /** + * {@inheritdoc} + */ + public function lastInsertId(?string $name = NULL): string { + return (string) $this->connection->insert_id; + } + + /** + * {@inheritdoc} + */ + public function exceptionHandler() { + return new ExceptionHandler(); + } + + /** + * {@inheritdoc} + */ + protected function driverTransactionManager(): TransactionManagerInterface { + return new TransactionManager($this); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php b/core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php new file mode 100644 index 00000000000..78e7a331f12 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\StatementInterface; +use Drupal\mysql\Driver\Database\mysql\ExceptionHandler as BaseMySqlExceptionHandler; + +/** + * MySQLi database exception handler class. + */ +class ExceptionHandler extends BaseMySqlExceptionHandler { + + /** + * {@inheritdoc} + */ + public function handleExecutionException(\Exception $exception, StatementInterface $statement, array $arguments = [], array $options = []): void { + // Close the client statement to release handles. + if ($statement->hasClientStatement()) { + $statement->getClientStatement()->close(); + } + + if (!($exception instanceof \mysqli_sql_exception)) { + throw $exception; + } + $this->rethrowNormalizedException($exception, $exception->getSqlState(), $exception->getCode(), $statement->getQueryString(), $arguments); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php b/core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php new file mode 100644 index 00000000000..f27a083541e --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli\Install; + +use Drupal\mysql\Driver\Database\mysql\Install\Tasks as BaseInstallTasks; + +/** + * Specifies installation tasks for MySQLi. + */ +class Tasks extends BaseInstallTasks { + + /** + * {@inheritdoc} + */ + public function installable() { + return extension_loaded('mysqli'); + } + + /** + * {@inheritdoc} + */ + public function name() { + return $this->t('@parent via mysqli (Experimental)', ['@parent' => parent::name()]); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php b/core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php new file mode 100644 index 00000000000..e6f2c86148c --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\DatabaseExceptionWrapper; + +/** + * This exception class signals an invalid charset is being used. + */ +class InvalidCharsetException extends DatabaseExceptionWrapper { +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php b/core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php new file mode 100644 index 00000000000..31386bc907b --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php @@ -0,0 +1,250 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +// cspell:ignore DBAL MULTICHAR + +/** + * A class to convert a SQL statement with named placeholders to positional. + * + * The parsing logic and the implementation is inspired by the PHP PDO parser, + * and a simplified copy of the parser implementation done by the Doctrine DBAL + * project. + * + * This class is a near-copy of Doctrine\DBAL\SQL\Parser, which is part of the + * Doctrine project: <http://www.doctrine-project.org>. It was copied from + * version 4.0.0. + * + * Original copyright: + * + * Copyright (c) 2006-2018 Doctrine Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * @see https://github.com/doctrine/dbal/blob/4.0.0/src/SQL/Parser.php + * + * @internal + */ +final class NamedPlaceholderConverter { + /** + * A list of regex patterns for parsing. + */ + private const string SPECIAL_CHARS = ':\?\'"`\\[\\-\\/'; + private const string BACKTICK_IDENTIFIER = '`[^`]*`'; + private const string BRACKET_IDENTIFIER = '(?<!\b(?i:ARRAY))\[(?:[^\]])*\]'; + private const string MULTICHAR = ':{2,}'; + private const string NAMED_PARAMETER = ':[a-zA-Z0-9_]+'; + private const string POSITIONAL_PARAMETER = '(?<!\\?)\\?(?!\\?)'; + private const string ONE_LINE_COMMENT = '--[^\r\n]*'; + private const string MULTI_LINE_COMMENT = '/\*([^*]+|\*+[^/*])*\**\*/'; + private const string SPECIAL = '[' . self::SPECIAL_CHARS . ']'; + private const string OTHER = '[^' . self::SPECIAL_CHARS . ']+'; + + /** + * The combined regex pattern for parsing. + */ + private string $sqlPattern; + + /** + * The list of original named arguments. + * + * The initial placeholder colon is removed. + * + * @var array<string|int, mixed> + */ + private array $originalParameters = []; + + /** + * The maximum positional placeholder parsed. + * + * Normally Drupal does not produce SQL with positional placeholders, but + * this is to manage the edge case. + */ + private int $originalParameterIndex = 0; + + /** + * The converted SQL statement in its parts. + * + * @var list<string> + */ + private array $convertedSQL = []; + + /** + * The list of converted arguments. + * + * @var list<mixed> + */ + private array $convertedParameters = []; + + public function __construct() { + // Builds the combined regex pattern for parsing. + $this->sqlPattern = sprintf('(%s)', implode('|', [ + $this->getAnsiSQLStringLiteralPattern("'"), + $this->getAnsiSQLStringLiteralPattern('"'), + self::BACKTICK_IDENTIFIER, + self::BRACKET_IDENTIFIER, + self::MULTICHAR, + self::ONE_LINE_COMMENT, + self::MULTI_LINE_COMMENT, + self::OTHER, + ])); + } + + /** + * Parses an SQL statement with named placeholders. + * + * This method explodes the SQL statement in parts that can be reassembled + * into a string with positional placeholders. + * + * @param string $sql + * The SQL statement with named placeholders. + * @param array<string|int, mixed> $args + * The statement arguments. + */ + public function parse(string $sql, array $args): void { + // Reset the object state. + $this->originalParameters = []; + $this->originalParameterIndex = 0; + $this->convertedSQL = []; + $this->convertedParameters = []; + + foreach ($args as $key => $value) { + if (is_int($key)) { + // Positional placeholder; edge case. + $this->originalParameters[$key] = $value; + } + else { + // Named placeholder like ':placeholder'; remove the initial colon. + $parameter = $key[0] === ':' ? substr($key, 1) : $key; + $this->originalParameters[$parameter] = $value; + } + } + + /** @var array<string,callable> $patterns */ + $patterns = [ + self::NAMED_PARAMETER => function (string $sql): void { + $this->addNamedParameter($sql); + }, + self::POSITIONAL_PARAMETER => function (string $sql): void { + $this->addPositionalParameter($sql); + }, + $this->sqlPattern => function (string $sql): void { + $this->addOther($sql); + }, + self::SPECIAL => function (string $sql): void { + $this->addOther($sql); + }, + ]; + + $offset = 0; + + while (($handler = current($patterns)) !== FALSE) { + if (preg_match('~\G' . key($patterns) . '~s', $sql, $matches, 0, $offset) === 1) { + $handler($matches[0]); + reset($patterns); + $offset += strlen($matches[0]); + } + elseif (preg_last_error() !== PREG_NO_ERROR) { + throw new \RuntimeException('Regular expression error'); + } + else { + next($patterns); + } + } + + assert($offset === strlen($sql)); + } + + /** + * Helper to return a regex pattern from a delimiter character. + * + * @param string $delimiter + * A delimiter character. + * + * @return string + * The regex pattern. + */ + private function getAnsiSQLStringLiteralPattern(string $delimiter): string { + return $delimiter . '[^' . $delimiter . ']*' . $delimiter; + } + + /** + * Adds a positional placeholder to the converted parts. + * + * Normally Drupal does not produce SQL with positional placeholders, but + * this is to manage the edge case. + * + * @param string $sql + * The SQL part. + */ + private function addPositionalParameter(string $sql): void { + $index = $this->originalParameterIndex; + + if (!array_key_exists($index, $this->originalParameters)) { + throw new \RuntimeException('Missing Positional Parameter ' . $index); + } + + $this->convertedSQL[] = '?'; + $this->convertedParameters[] = $this->originalParameters[$index]; + + $this->originalParameterIndex++; + } + + /** + * Adds a named placeholder to the converted parts. + * + * @param string $sql + * The SQL part. + */ + private function addNamedParameter(string $sql): void { + $name = substr($sql, 1); + + if (!array_key_exists($name, $this->originalParameters)) { + throw new \RuntimeException('Missing Named Parameter ' . $name); + } + + $this->convertedSQL[] = '?'; + $this->convertedParameters[] = $this->originalParameters[$name]; + } + + /** + * Adds a generic SQL string fragment to the converted parts. + * + * @param string $sql + * The SQL part. + */ + private function addOther(string $sql): void { + $this->convertedSQL[] = $sql; + } + + /** + * Returns the converted SQL statement with positional placeholders. + * + * @return string + * The converted SQL statement with positional placeholders. + */ + public function getConvertedSQL(): string { + return implode('', $this->convertedSQL); + } + + /** + * Returns the array of arguments for use with positional placeholders. + * + * @return list<mixed> + * The array of arguments for use with positional placeholders. + */ + public function getConvertedParameters(): array { + return $this->convertedParameters; + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Result.php b/core/modules/mysqli/src/Driver/Database/mysqli/Result.php new file mode 100644 index 00000000000..2c5e57c3aa8 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Result.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\DatabaseExceptionWrapper; +use Drupal\Core\Database\FetchModeTrait; +use Drupal\Core\Database\Statement\FetchAs; +use Drupal\Core\Database\Statement\ResultBase; + +/** + * Class for mysqli-provided results of a data query language (DQL) statement. + */ +class Result extends ResultBase { + + use FetchModeTrait; + + /** + * Constructor. + * + * @param \Drupal\Core\Database\Statement\FetchAs $fetchMode + * The fetch mode. + * @param array{class: class-string, constructor_args: list<mixed>, column: int, cursor_orientation?: int, cursor_offset?: int} $fetchOptions + * The fetch options. + * @param \mysqli_result|false $mysqliResult + * The MySQLi result object. + * @param \mysqli $mysqliConnection + * Client database connection object. + */ + public function __construct( + FetchAs $fetchMode, + array $fetchOptions, + protected readonly \mysqli_result|false $mysqliResult, + protected readonly \mysqli $mysqliConnection, + ) { + parent::__construct($fetchMode, $fetchOptions); + } + + /** + * {@inheritdoc} + */ + public function rowCount(): ?int { + // The most accurate value to return for Drupal here is the first + // occurrence of an integer in the string stored by the connection's + // $info property. + // This is something like 'Rows matched: 1 Changed: 1 Warnings: 0' for + // UPDATE or DELETE operations, 'Records: 2 Duplicates: 1 Warnings: 0' + // for INSERT ones. + // This however requires a regex parsing of the string which is expensive; + // $affected_rows would be less accurate but much faster. We would need + // Drupal to be less strict in testing, and never rely on this value in + // runtime (which would be healthy anyway). + if ($this->mysqliConnection->info !== NULL) { + $matches = []; + if (preg_match('/\s(\d+)\s/', $this->mysqliConnection->info, $matches) === 1) { + return (int) $matches[0]; + } + else { + throw new DatabaseExceptionWrapper('Invalid data in the $info property of the mysqli connection - ' . $this->mysqliConnection->info); + } + } + elseif ($this->mysqliConnection->affected_rows !== NULL) { + return $this->mysqliConnection->affected_rows; + } + throw new DatabaseExceptionWrapper('Unable to retrieve affected rows data'); + } + + /** + * {@inheritdoc} + */ + public function setFetchMode(FetchAs $mode, array $fetchOptions): bool { + // There are no methods to set fetch mode in \mysqli_result. + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function fetch(FetchAs $mode, array $fetchOptions): array|object|int|float|string|bool|NULL { + assert($this->mysqliResult instanceof \mysqli_result); + + $mysqli_row = $this->mysqliResult->fetch_assoc(); + + if (!$mysqli_row) { + return FALSE; + } + + // Stringify all non-NULL column values. + $row = array_map(fn ($value) => $value === NULL ? NULL : (string) $value, $mysqli_row); + + return $this->assocToFetchMode($row, $mode, $fetchOptions); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Statement.php b/core/modules/mysqli/src/Driver/Database/mysqli/Statement.php new file mode 100644 index 00000000000..f3b4346992d --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Statement.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\Connection; +use Drupal\Core\Database\Statement\FetchAs; +use Drupal\Core\Database\Statement\StatementBase; + +/** + * MySQLi implementation of \Drupal\Core\Database\Query\StatementInterface. + */ +class Statement extends StatementBase { + + /** + * Holds the index position of named parameters. + * + * The mysqli driver only allows positional placeholders '?', whereas in + * Drupal the SQL is generated with named placeholders ':name'. In order to + * execute the SQL, the string containing the named placeholders is converted + * to using positional ones, and the position (index) of each named + * placeholder in the string is stored here. + */ + protected array $paramsPositions; + + /** + * Constructs a Statement object. + * + * @param \Drupal\Core\Database\Connection $connection + * Drupal database connection object. + * @param \mysqli $clientConnection + * Client database connection object. + * @param string $queryString + * The SQL query string. + * @param array $driverOpts + * (optional) Array of query options. + * @param bool $rowCountEnabled + * (optional) Enables counting the rows affected. Defaults to FALSE. + */ + public function __construct( + Connection $connection, + \mysqli $clientConnection, + string $queryString, + protected array $driverOpts = [], + bool $rowCountEnabled = FALSE, + ) { + parent::__construct($connection, $clientConnection, $queryString, $rowCountEnabled); + $this->setFetchMode(FetchAs::Object); + } + + /** + * Returns the client-level database statement object. + * + * This method should normally be used only within database driver code. + * + * @return \mysqli_stmt + * The client-level database statement. + */ + public function getClientStatement(): \mysqli_stmt { + if ($this->hasClientStatement()) { + assert($this->clientStatement instanceof \mysqli_stmt); + return $this->clientStatement; + } + throw new \LogicException('\\mysqli_stmt not initialized'); + } + + /** + * {@inheritdoc} + */ + public function execute($args = [], $options = []) { + if (isset($options['fetch'])) { + if (is_string($options['fetch'])) { + $this->setFetchMode(FetchAs::ClassObject, $options['fetch']); + } + else { + if (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\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + } + $this->setFetchMode($options['fetch']); + } + } + + $startEvent = $this->dispatchStatementExecutionStartEvent($args ?? []); + + try { + // Prepare the lower-level statement if it's not been prepared already. + if (!$this->hasClientStatement()) { + // Replace named placeholders with positional ones if needed. + $this->paramsPositions = array_flip(array_keys($args)); + $converter = new NamedPlaceholderConverter(); + $converter->parse($this->queryString, $args); + [$convertedQueryString, $args] = [$converter->getConvertedSQL(), $converter->getConvertedParameters()]; + $this->clientStatement = $this->clientConnection->prepare($convertedQueryString); + } + else { + // Transform the $args to positional. + $tmp = []; + foreach ($this->paramsPositions as $param => $pos) { + $tmp[$pos] = $args[$param]; + } + $args = $tmp; + } + + // In mysqli, the results of the statement execution are returned in a + // different object than the statement itself. + $return = $this->getClientStatement()->execute($args); + $this->result = new Result( + $this->fetchMode, + $this->fetchOptions, + $this->getClientStatement()->get_result(), + $this->clientConnection, + ); + $this->markResultsetIterable($return); + } + catch (\Exception $e) { + $this->dispatchStatementExecutionFailureEvent($startEvent, $e); + throw $e; + } + + $this->dispatchStatementExecutionEndEvent($startEvent); + + return $return; + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php b/core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php new file mode 100644 index 00000000000..90237fd6a43 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\Transaction\ClientConnectionTransactionState; +use Drupal\Core\Database\Transaction\TransactionManagerBase; + +/** + * MySqli implementation of TransactionManagerInterface. + */ +class TransactionManager extends TransactionManagerBase { + + /** + * {@inheritdoc} + */ + protected function beginClientTransaction(): bool { + return $this->connection->getClientConnection()->begin_transaction(); + } + + /** + * {@inheritdoc} + */ + protected function addClientSavepoint(string $name): bool { + return $this->connection->getClientConnection()->savepoint($name); + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientSavepoint(string $name): bool { + // Mysqli does not have a rollback_to_savepoint method, and it does not + // allow a prepared statement for 'ROLLBACK TO SAVEPOINT', so we need to + // fallback to querying on the client connection directly. + try { + return (bool) $this->connection->getClientConnection()->query('ROLLBACK TO SAVEPOINT ' . $name); + } + catch (\mysqli_sql_exception) { + // If the rollback failed, most likely the savepoint was not there + // because the transaction is no longer active. In this case we void the + // transaction stack. + $this->voidClientTransaction(); + return TRUE; + } + } + + /** + * {@inheritdoc} + */ + protected function releaseClientSavepoint(string $name): bool { + return $this->connection->getClientConnection()->release_savepoint($name); + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientTransaction(): bool { + // Note: mysqli::rollback() returns TRUE if there's no active transaction. + // This is diverging from PDO MySql. A PHP bug report exists. + // @see https://bugs.php.net/bug.php?id=81533. + $clientRollback = $this->connection->getClientConnection()->rollBack(); + $this->setConnectionTransactionState($clientRollback ? + ClientConnectionTransactionState::RolledBack : + ClientConnectionTransactionState::RollbackFailed + ); + return $clientRollback; + } + + /** + * {@inheritdoc} + */ + protected function commitClientTransaction(): bool { + $clientCommit = $this->connection->getClientConnection()->commit(); + $this->setConnectionTransactionState($clientCommit ? + ClientConnectionTransactionState::Committed : + ClientConnectionTransactionState::CommitFailed + ); + return $clientCommit; + } + +} diff --git a/core/modules/mysqli/src/Hook/MysqliHooks.php b/core/modules/mysqli/src/Hook/MysqliHooks.php new file mode 100644 index 00000000000..340b17373a1 --- /dev/null +++ b/core/modules/mysqli/src/Hook/MysqliHooks.php @@ -0,0 +1,102 @@ +<?php + +namespace Drupal\mysqli\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\Routing\RouteMatchInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * Hook implementations for mysqli. + */ +class MysqliHooks { + + use StringTranslationTrait; + + /** + * Implements hook_help(). + */ + #[Hook('help')] + public function help($route_name, RouteMatchInterface $route_match): ?string { + switch ($route_name) { + case 'help.page.mysqli': + $output = ''; + $output .= '<h3>' . $this->t('About') . '</h3>'; + $output .= '<p>' . $this->t('The MySQLi module provides the connection between Drupal and a MySQL, MariaDB or equivalent database using the mysqli PHP extension. For more information, see the <a href=":mysqli">online documentation for the MySQLi module</a>.', [':mysqli' => 'https://www.drupal.org/documentation/modules/mysqli']) . '</p>'; + return $output; + + } + return NULL; + } + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtimeRequirements(): array { + $requirements = []; + + // Test with MySql databases. + if (Database::isActiveConnection()) { + $connection = Database::getConnection(); + // Only show requirements when MySQLi is the default database connection. + if (!($connection->driver() === 'mysqli' && $connection->getProvider() === 'mysqli')) { + return []; + } + + $query = $connection->isMariaDb() ? 'SELECT @@SESSION.tx_isolation' : 'SELECT @@SESSION.transaction_isolation'; + + $isolation_level = $connection->query($query)->fetchField(); + + $tables_missing_primary_key = []; + $tables = $connection->schema()->findTables('%'); + foreach ($tables as $table) { + $primary_key_column = Database::getConnection()->query("SHOW KEYS FROM {" . $table . "} WHERE Key_name = 'PRIMARY'")->fetchAllAssoc('Column_name'); + if (empty($primary_key_column)) { + $tables_missing_primary_key[] = $table; + } + } + + $description = []; + if ($isolation_level == 'READ-COMMITTED') { + if (empty($tables_missing_primary_key)) { + $severity_level = RequirementSeverity::OK; + } + else { + $severity_level = RequirementSeverity::Error; + } + } + else { + if ($isolation_level == 'REPEATABLE-READ') { + $severity_level = RequirementSeverity::Warning; + } + else { + $severity_level = RequirementSeverity::Error; + $description[] = $this->t('This is not supported by Drupal.'); + } + $description[] = $this->t('The recommended level for Drupal is "READ COMMITTED".'); + } + + if (!empty($tables_missing_primary_key)) { + $description[] = $this->t('For this to work correctly, all tables must have a primary key. The following table(s) do not have a primary key: @tables.', ['@tables' => implode(', ', $tables_missing_primary_key)]); + } + + $description[] = $this->t('See the <a href=":performance_doc">setting MySQL transaction isolation level</a> page for more information.', [ + ':performance_doc' => 'https://www.drupal.org/docs/system-requirements/setting-the-mysql-transaction-isolation-level', + ]); + + $requirements['mysql_transaction_level'] = [ + 'title' => $this->t('Transaction isolation level'), + 'severity' => $severity_level, + 'value' => $isolation_level, + 'description' => Markup::create(implode(' ', $description)), + ]; + } + + return $requirements; + } + +} diff --git a/core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php b/core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php new file mode 100644 index 00000000000..d1f1ca55f8f --- /dev/null +++ b/core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php @@ -0,0 +1,11 @@ +<?php + +namespace Drupal\mysqli\Plugin\views\query; + +use Drupal\mysql\Plugin\views\query\MysqlCastSql; + +/** + * MySQLi specific cast handling. + */ +class MysqliCastSql extends MysqlCastSql { +} diff --git a/core/modules/mysqli/tests/src/Functional/GenericTest.php b/core/modules/mysqli/tests/src/Functional/GenericTest.php new file mode 100644 index 00000000000..73638106960 --- /dev/null +++ b/core/modules/mysqli/tests/src/Functional/GenericTest.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Functional; + +use Drupal\Core\Extension\ExtensionLifecycle; +use Drupal\Tests\system\Functional\Module\GenericModuleTestBase; +use PHPUnit\Framework\Attributes\Group; + +/** + * Generic module test for mysqli. + */ +#[Group('mysqli')] +class GenericTest extends GenericModuleTestBase { + + /** + * Checks visibility of the module. + */ + public function testMysqliModule(): void { + $module = $this->getModule(); + \Drupal::service('module_installer')->install([$module]); + $info = \Drupal::service('extension.list.module')->getExtensionInfo($module); + $this->assertTrue($info['hidden']); + $this->assertSame(ExtensionLifecycle::EXPERIMENTAL, $info['lifecycle']); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php new file mode 100644 index 00000000000..c940eb919d3 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\ConnectionTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * MySQL-specific connection tests. + */ +#[Group('Database')] +class ConnectionTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php new file mode 100644 index 00000000000..42fa5d733df --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\ConnectionUnitTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * MySQL-specific connection unit tests. + */ +#[Group('Database')] +class ConnectionUnitTest extends BaseMySqlTest { + + /** + * Tests pdo options override. + */ + public function testConnectionOpen(): void { + $this->markTestSkipped('mysqli is not a pdo driver.'); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php new file mode 100644 index 00000000000..2e27fff09f5 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\DatabaseExceptionWrapperTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests exceptions thrown by queries. + */ +#[Group('Database')] +class DatabaseExceptionWrapperTest extends BaseMySqlTest { + + /** + * Tests Connection::prepareStatement exceptions on preparation. + * + * Core database drivers use PDO emulated statements or the StatementPrefetch + * class, which defer the statement check to the moment of the execution. In + * order to test a failure at preparation time, we have to force the + * connection not to emulate statement preparation. Still, this is only valid + * for the MySql driver. + */ + public function testPrepareStatementFailOnPreparation(): void { + $this->markTestSkipped('mysqli is not a pdo driver.'); + } + + /** + * Tests Connection::prepareStatement exception on execution. + */ + public function testPrepareStatementFailOnExecution(): void { + $this->expectException(\mysqli_sql_exception::class); + $stmt = $this->connection->prepareStatement('bananas', []); + $stmt->execute(); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php new file mode 100644 index 00000000000..ead54a27c01 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Component\Utility\Environment; +use Drupal\Core\Database\Database; +use Drupal\Core\Database\DatabaseExceptionWrapper; +use Drupal\Tests\mysql\Kernel\mysql\LargeQueryTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests handling of large queries. + */ +#[Group('Database')] +class LargeQueryTest extends BaseMySqlTest { + + /** + * Tests truncation of messages when max_allowed_packet exception occurs. + */ + public function testMaxAllowedPacketQueryTruncating(): void { + $connectionInfo = Database::getConnectionInfo(); + Database::addConnectionInfo('default', 'testMaxAllowedPacketQueryTruncating', $connectionInfo['default']); + $testConnection = Database::getConnection('testMaxAllowedPacketQueryTruncating'); + + // The max_allowed_packet value is configured per database instance. + // Retrieve the max_allowed_packet value from the current instance and + // check if PHP is configured with sufficient allowed memory to be able + // to generate a query larger than max_allowed_packet. + $max_allowed_packet = $testConnection->query('SELECT @@global.max_allowed_packet')->fetchField(); + if (!Environment::checkMemoryLimit($max_allowed_packet + (16 * 1024 * 1024))) { + $this->markTestSkipped('The configured max_allowed_packet exceeds the php memory limit. Therefore the test is skipped.'); + } + + $long_name = str_repeat('a', $max_allowed_packet + 1); + try { + $testConnection->query('SELECT [name] FROM {test} WHERE [name] = :name', [':name' => $long_name]); + $this->fail("An exception should be thrown for queries larger than 'max_allowed_packet'"); + } + catch (\Throwable $e) { + Database::closeConnection('testMaxAllowedPacketQueryTruncating'); + // Got a packet bigger than 'max_allowed_packet' bytes exception thrown. + $this->assertInstanceOf(DatabaseExceptionWrapper::class, $e); + $this->assertEquals(1153, $e->getPrevious()->getCode()); + // 'max_allowed_packet' exception message truncated. + // Use strlen() to count the bytes exactly, not the Unicode chars. + $this->assertLessThanOrEqual($max_allowed_packet, strlen($e->getMessage())); + } + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php new file mode 100644 index 00000000000..894245826cb --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\PrefixInfoTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests that the prefix info for a database schema is correct. + */ +#[Group('Database')] +class PrefixInfoTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php new file mode 100644 index 00000000000..23fa565156f --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\SchemaTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests schema API for the MySQL driver. + */ +#[Group('Database')] +class SchemaTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php new file mode 100644 index 00000000000..7bbf1b85b39 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\KernelTests\Core\Database\DriverSpecificDatabaseTestBase; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests compatibility of the MySQL driver with various sql_mode options. + */ +#[Group('Database')] +class SqlModeTest extends DriverSpecificDatabaseTestBase { + + /** + * Tests quoting identifiers in queries. + */ + public function testQuotingIdentifiers(): void { + // Use SQL-reserved words for both the table and column names. + $query = $this->connection->query('SELECT [update] FROM {select}'); + $this->assertEquals('Update value 1', $query->fetchObject()->update); + $this->assertStringContainsString('SELECT `update` FROM `', $query->getQueryString()); + } + + /** + * {@inheritdoc} + */ + protected function getDatabaseConnectionInfo() { + $info = parent::getDatabaseConnectionInfo(); + + // This runs during setUp(), so is not yet skipped for non MySQL databases. + // We defer skipping the test to later in setUp(), so that that can be + // based on databaseType() rather than 'driver', but here all we have to go + // on is 'driver'. + if ($info['default']['driver'] === 'mysqli') { + $info['default']['init_commands']['sql_mode'] = "SET sql_mode = ''"; + } + + return $info; + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php new file mode 100644 index 00000000000..7ccdcf1022f --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\KernelTests\Core\Database\DriverSpecificSyntaxTestBase; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests MySql syntax interpretation. + */ +#[Group('Database')] +class SyntaxTest extends DriverSpecificSyntaxTestBase { + + /** + * Tests string concatenation with separator, with field values. + */ + public function testConcatWsFields(): void { + $result = $this->connection->query("SELECT CONCAT_WS('-', CONVERT(:a1 USING utf8mb4), [name], CONVERT(:a2 USING utf8mb4), [age]) FROM {test} WHERE [age] = :age", [ + ':a1' => 'name', + ':a2' => 'age', + ':age' => 25, + ]); + $this->assertSame('name-John-age-25', $result->fetchField()); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php new file mode 100644 index 00000000000..19539fa6587 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\TemporaryQueryTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests the temporary query functionality. + */ +#[Group('Database')] +class TemporaryQueryTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php new file mode 100644 index 00000000000..60f6c27540d --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\KernelTests\Core\Database\DriverSpecificTransactionTestBase; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests transaction for the MySQLi driver. + */ +#[Group('Database')] +class TransactionTest extends DriverSpecificTransactionTestBase { + + /** + * Tests starting a transaction when there's one active on the client. + * + * MySQLi does not fail if multiple commits are made on the client, so this + * test is failing. Let's change this if/when MySQLi will provide a way to + * check if a client transaction is active. + * + * This is mitigated by the fact that transaction should not be initiated from + * code outside the TransactionManager, that keeps track of the stack of + * transaction-related operations in its stack. + */ + public function testStartTransactionWhenActive(): void { + $this->markTestSkipped('Skipping this while MySQLi cannot detect if a client transaction is active.'); + $this->connection->getClientConnection()->begin_transaction(); + $this->connection->startTransaction(); + $this->assertFalse($this->connection->inTransaction()); + } + + /** + * Tests committing a transaction when there's none active on the client. + * + * MySQLi does not fail if multiple commits are made on the client, so this + * test is failing. Let's change this if/when MySQLi will provide a way to + * check if a client transaction is active. + * + * This is mitigated by the fact that transaction should not be initiated from + * code outside the TransactionManager, that keeps track of the stack of + * transaction-related operations in its stack. + */ + public function testCommitTransactionWhenInactive(): void { + $this->markTestSkipped('Skipping this while MySQLi cannot detect if a client transaction is active.'); + $transaction = $this->connection->startTransaction(); + $this->assertTrue($this->connection->inTransaction()); + $this->connection->getClientConnection()->commit(); + $this->assertFalse($this->connection->inTransaction()); + unset($transaction); + } + +} diff --git a/core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php b/core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php new file mode 100644 index 00000000000..a000a132e20 --- /dev/null +++ b/core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php @@ -0,0 +1,400 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Unit; + +use Drupal\mysqli\Driver\Database\mysqli\NamedPlaceholderConverter; +use Drupal\Tests\UnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests \Drupal\mysqli\Driver\Database\mysqli\NamedPlaceholderConverter. + */ +#[CoversClass(NamedPlaceholderConverter::class)] +#[Group('Database')] +class NamedPlaceholderConverterTest extends UnitTestCase { + + /** + * Tests ::parse(). + * + * @legacy-covers ::parse + * @legacy-covers ::getConvertedSQL + * @legacy-covers ::getConvertedParameters + */ + #[DataProvider('statementsWithParametersProvider')] + public function testParse(string $sql, array $parameters, string $expectedSql, array $expectedParameters): void { + $converter = new NamedPlaceholderConverter(); + $converter->parse($sql, $parameters); + $this->assertSame($expectedSql, $converter->getConvertedSQL()); + $this->assertSame($expectedParameters, $converter->getConvertedParameters()); + } + + /** + * Data for testParse. + */ + public static function statementsWithParametersProvider(): iterable { + yield [ + 'SELECT ?', + ['foo'], + 'SELECT ?', + ['foo'], + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar IN (?, ?, ?)', + ['baz', 'qux', 'fred'], + 'SELECT * FROM Foo WHERE bar IN (?, ?, ?)', + ['baz', 'qux', 'fred'], + ]; + + yield [ + 'SELECT ? FROM ?', + ['baz', 'qux'], + 'SELECT ? FROM ?', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT "?" FROM foo WHERE bar = ?', + ['baz'], + 'SELECT "?" FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + "SELECT '?' FROM foo WHERE bar = ?", + ['baz'], + "SELECT '?' FROM foo WHERE bar = ?", + ['baz'], + ]; + + yield [ + 'SELECT `?` FROM foo WHERE bar = ?', + ['baz'], + 'SELECT `?` FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT [?] FROM foo WHERE bar = ?', + ['baz'], + 'SELECT [?] FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + ['baz'], + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + ['baz'], + ]; + + yield [ + "SELECT 'foo-bar?' FROM foo WHERE bar = ?", + ['baz'], + "SELECT 'foo-bar?' FROM foo WHERE bar = ?", + ['baz'], + ]; + + yield [ + 'SELECT "foo-bar?" FROM foo WHERE bar = ?', + ['baz'], + 'SELECT "foo-bar?" FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT `foo-bar?` FROM foo WHERE bar = ?', + ['baz'], + 'SELECT `foo-bar?` FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT [foo-bar?] FROM foo WHERE bar = ?', + ['baz'], + 'SELECT [foo-bar?] FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT :foo FROM :bar', + [':foo' => 'baz', ':bar' => 'qux'], + 'SELECT ? FROM ?', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar IN (:name1, :name2)', + [':name1' => 'baz', ':name2' => 'qux'], + 'SELECT * FROM Foo WHERE bar IN (?, ?)', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT ":foo" FROM Foo WHERE bar IN (:name1, :name2)', + [':name1' => 'baz', ':name2' => 'qux'], + 'SELECT ":foo" FROM Foo WHERE bar IN (?, ?)', + ['baz', 'qux'], + ]; + + yield [ + "SELECT ':foo' FROM Foo WHERE bar IN (:name1, :name2)", + [':name1' => 'baz', ':name2' => 'qux'], + "SELECT ':foo' FROM Foo WHERE bar IN (?, ?)", + ['baz', 'qux'], + ]; + + yield [ + 'SELECT :foo_id', + [':foo_id' => 'bar'], + 'SELECT ?', + ['bar'], + ]; + + yield [ + 'SELECT @rank := 1 AS rank, :foo AS foo FROM :bar', + [':foo' => 'baz', ':bar' => 'qux'], + 'SELECT @rank := 1 AS rank, ? AS foo FROM ?', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar > :start_date AND baz > :start_date', + [':start_date' => 'qux'], + 'SELECT * FROM Foo WHERE bar > ? AND baz > ?', + ['qux', 'qux'], + ]; + + yield [ + 'SELECT foo::date as date FROM Foo WHERE bar > :start_date AND baz > :start_date', + [':start_date' => 'qux'], + 'SELECT foo::date as date FROM Foo WHERE bar > ? AND baz > ?', + ['qux', 'qux'], + ]; + + yield [ + 'SELECT `d.ns:col_name` FROM my_table d WHERE `d.date` >= :param1', + [':param1' => 'qux'], + 'SELECT `d.ns:col_name` FROM my_table d WHERE `d.date` >= ?', + ['qux'], + ]; + + yield [ + 'SELECT [d.ns:col_name] FROM my_table d WHERE [d.date] >= :param1', + [':param1' => 'qux'], + 'SELECT [d.ns:col_name] FROM my_table d WHERE [d.date] >= ?', + ['qux'], + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[:foo])', + [':foo' => 'qux'], + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + ['qux'], + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, array[:foo])', + [':foo' => 'qux'], + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, array[?])', + ['qux'], + ]; + + yield [ + "SELECT table.column1, ARRAY['3'] FROM schema.table table WHERE table.f1 = :foo AND ARRAY['3']", + [':foo' => 'qux'], + "SELECT table.column1, ARRAY['3'] FROM schema.table table WHERE table.f1 = ? AND ARRAY['3']", + ['qux'], + ]; + + yield [ + "SELECT table.column1, ARRAY['3']::integer[] FROM schema.table table WHERE table.f1 = :foo AND ARRAY['3']::integer[]", + [':foo' => 'qux'], + "SELECT table.column1, ARRAY['3']::integer[] FROM schema.table table WHERE table.f1 = ? AND ARRAY['3']::integer[]", + ['qux'], + ]; + + yield [ + "SELECT table.column1, ARRAY[:foo] FROM schema.table table WHERE table.f1 = :bar AND ARRAY['3']", + [':foo' => 'qux', ':bar' => 'git'], + "SELECT table.column1, ARRAY[?] FROM schema.table table WHERE table.f1 = ? AND ARRAY['3']", + ['qux', 'git'], + ]; + + yield [ + 'SELECT table.column1, ARRAY[:foo]::integer[] FROM schema.table table' . " WHERE table.f1 = :bar AND ARRAY['3']::integer[]", + [':foo' => 'qux', ':bar' => 'git'], + 'SELECT table.column1, ARRAY[?]::integer[] FROM schema.table table' . " WHERE table.f1 = ? AND ARRAY['3']::integer[]", + ['qux', 'git'], + ]; + + yield 'Parameter array with placeholder keys missing starting colon' => [ + 'SELECT table.column1, ARRAY[:foo]::integer[] FROM schema.table table' . " WHERE table.f1 = :bar AND ARRAY['3']::integer[]", + ['foo' => 'qux', 'bar' => 'git'], + 'SELECT table.column1, ARRAY[?]::integer[] FROM schema.table table' . " WHERE table.f1 = ? AND ARRAY['3']::integer[]", + ['qux', 'git'], + ]; + + yield 'Quotes inside literals escaped by doubling' => [ + <<<'SQL' +SELECT * FROM foo +WHERE bar = ':not_a_param1 ''":not_a_param2"''' + OR bar=:a_param1 + OR bar=:a_param2||':not_a_param3' + OR bar=':not_a_param4 '':not_a_param5'' :not_a_param6' + OR bar='' + OR bar=:a_param3 +SQL, + [':a_param1' => 'qux', ':a_param2' => 'git', ':a_param3' => 'foo'], + <<<'SQL' +SELECT * FROM foo +WHERE bar = ':not_a_param1 ''":not_a_param2"''' + OR bar=? + OR bar=?||':not_a_param3' + OR bar=':not_a_param4 '':not_a_param5'' :not_a_param6' + OR bar='' + OR bar=? +SQL, + ['qux', 'git', 'foo'], + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE :condition_0 ESCAPE \'\\\\\') AND (data.description LIKE :condition_1 ESCAPE \'\\\\\') ORDER BY id ASC', + [':condition_0' => 'qux', ':condition_1' => 'git'], + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE ? ESCAPE \'\\\\\') AND (data.description LIKE ? ESCAPE \'\\\\\') ORDER BY id ASC', + ['qux', 'git'], + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE :condition_0 ESCAPE "\\\\") AND (data.description LIKE :condition_1 ESCAPE "\\\\") ORDER BY id ASC', + [':condition_0' => 'qux', ':condition_1' => 'git'], + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE ? ESCAPE "\\\\") AND (data.description LIKE ? ESCAPE "\\\\") ORDER BY id ASC', + ['qux', 'git'], + ]; + + yield 'Combined single and double quotes' => [ + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE :condition_0 ESCAPE "\\") + AND (data.description LIKE :condition_1 ESCAPE '\\') ORDER BY id ASC +SQL, + [':condition_0' => 'qux', ':condition_1' => 'git'], + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE ? ESCAPE "\\") + AND (data.description LIKE ? ESCAPE '\\') ORDER BY id ASC +SQL, + ['qux', 'git'], + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE :condition_0 ESCAPE `\\\\`) AND (data.description LIKE :condition_1 ESCAPE `\\\\`) ORDER BY id ASC', + [':condition_0' => 'qux', ':condition_1' => 'git'], + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE ? ESCAPE `\\\\`) AND (data.description LIKE ? ESCAPE `\\\\`) ORDER BY id ASC', + ['qux', 'git'], + ]; + + yield 'Combined single quotes and backticks' => [ + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE :condition_0 ESCAPE '\\') + AND (data.description LIKE :condition_1 ESCAPE `\\`) ORDER BY id ASC +SQL, + [':condition_0' => 'qux', ':condition_1' => 'git'], + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE ? ESCAPE '\\') + AND (data.description LIKE ? ESCAPE `\\`) ORDER BY id ASC +SQL, + ['qux', 'git'], + ]; + + yield '? placeholders inside comments' => [ + <<<'SQL' +/* + * test placeholder ? + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> ? + AND dummy = ? +SQL, + ['baz'], + <<<'SQL' +/* + * test placeholder ? + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> ? + AND dummy = ? +SQL, + ['baz'], + ]; + + yield 'Named placeholders inside comments' => [ + <<<'SQL' +/* + * test :placeholder + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> :dummy + AND dummy = :key +SQL, + [':key' => 'baz'], + <<<'SQL' +/* + * test :placeholder + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> :dummy + AND dummy = ? +SQL, + ['baz'], + ]; + + yield 'Escaped question' => [ + <<<'SQL' +SELECT '{"a":null}'::jsonb ?? :key +SQL, + [':key' => 'qux'], + <<<'SQL' +SELECT '{"a":null}'::jsonb ?? ? +SQL, + ['qux'], + ]; + } + + /** + * Tests reusing the parser object. + * + * @legacy-covers ::parse + * @legacy-covers ::getConvertedSQL + * @legacy-covers ::getConvertedParameters + */ + public function testParseReuseObject(): void { + $converter = new NamedPlaceholderConverter(); + $converter->parse('SELECT ?', ['foo']); + $this->assertSame('SELECT ?', $converter->getConvertedSQL()); + $this->assertSame(['foo'], $converter->getConvertedParameters()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing Positional Parameter 0'); + $converter->parse('SELECT ?', []); + } + +} diff --git a/core/modules/navigation/components/badge/badge.component.yml b/core/modules/navigation/components/badge/badge.component.yml index a7bb04f963e..a7940d1efa5 100644 --- a/core/modules/navigation/components/badge/badge.component.yml +++ b/core/modules/navigation/components/badge/badge.component.yml @@ -21,6 +21,9 @@ props: enum: - info - success + meta:enum: + info: Information + success: Success slots: label: type: string diff --git a/core/modules/navigation/components/title/title.component.yml b/core/modules/navigation/components/title/title.component.yml index 696960d455f..1001c42c109 100644 --- a/core/modules/navigation/components/title/title.component.yml +++ b/core/modules/navigation/components/title/title.component.yml @@ -23,6 +23,9 @@ props: enum: - ellipsis - xs + meta:enum: + ellipsis: Ellipsis + xs: 'Extra-small' extra_classes: type: array title: Extra classes. @@ -43,6 +46,15 @@ props: - h5 - h6 - span + meta:enum: + h1: Heading 1 + h2: Heading 2 + h3: Heading 3 + h4: Heading 4 + h5: Heading 5 + h6: Heading 6 + span: Inline + x-translation-context: HTML tag # Provide a default value default: h2 icon: diff --git a/core/modules/navigation/components/toolbar-button/toolbar-button.component.yml b/core/modules/navigation/components/toolbar-button/toolbar-button.component.yml index 87e29a14bf4..130cca22e1c 100644 --- a/core/modules/navigation/components/toolbar-button/toolbar-button.component.yml +++ b/core/modules/navigation/components/toolbar-button/toolbar-button.component.yml @@ -37,6 +37,17 @@ props: - primary - small-offset - weight--400 + meta:enum: + collapsible: Collapsible + dark: Dark + expand--down: Expands down + expand--side: Expands to the side + large: Large + non-interactive: Non-Interactive + primary: Primary + small-offset: Small offset + weight--400: Weight 400 + x-translation-context: Toolbar button modifiers extra_classes: type: array title: Extra classes. @@ -53,6 +64,11 @@ props: - a - button - span + meta:enum: + a: Link + button: Button + span: Inline + x-translation-context: HTML tag # Provide a default value default: button icon: diff --git a/core/modules/navigation/components/toolbar-button/toolbar-button.css b/core/modules/navigation/components/toolbar-button/toolbar-button.css index 3af3cbfdd39..85bfb00883c 100644 --- a/core/modules/navigation/components/toolbar-button/toolbar-button.css +++ b/core/modules/navigation/components/toolbar-button/toolbar-button.css @@ -36,7 +36,7 @@ text-align: start; -webkit-text-decoration: none; text-decoration: none; - word-break: break-word; + overflow-wrap: break-word; color: var(--toolbar-button-color); border: 0; border-radius: var(--admin-toolbar-space-8); diff --git a/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css b/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css index 9b40b8fcb46..0e1d5f3bdd5 100644 --- a/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css +++ b/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css @@ -33,7 +33,7 @@ cursor: pointer; text-align: start; text-decoration: none; - word-break: break-word; + overflow-wrap: break-word; color: var(--toolbar-button-color); border: 0; border-radius: var(--admin-toolbar-space-8); diff --git a/core/modules/navigation/css/base/variables.css b/core/modules/navigation/css/base/variables.css index 91063f3f09d..c56c7322b6f 100644 --- a/core/modules/navigation/css/base/variables.css +++ b/core/modules/navigation/css/base/variables.css @@ -74,14 +74,14 @@ We need it root to calculate the size of the displace in .dialog-off-canvas-main /* White. */ --admin-toolbar-color-white: var(--drupal-admin-color-white, #fff); /* Expanded background color. */ - --admin-toolbar-color-expanded: rgba(231, 234, 243, 0.5); /* --admin-toolbar-color-gray-050 */ + --admin-toolbar-color-expanded: rgb(231, 234, 243, 0.5); /* --admin-toolbar-color-gray-050 */ /* Fonts. */ --admin-toolbar-font-family: inter, sans-serif; /* Shadows. */ - --admin-toolbar-color-shadow-0: rgba(0, 0, 0, 0); - --admin-toolbar-color-shadow-6: rgba(0, 0, 0, 0.06); - --admin-toolbar-color-shadow-8: rgba(0, 0, 0, 0.08); - --admin-toolbar-color-shadow-15: rgba(0, 0, 0, 0.15); + --admin-toolbar-color-shadow-0: rgb(0, 0, 0, 0); + --admin-toolbar-color-shadow-6: rgb(0, 0, 0, 0.06); + --admin-toolbar-color-shadow-8: rgb(0, 0, 0, 0.08); + --admin-toolbar-color-shadow-15: rgb(0, 0, 0, 0.15); /** * Spaces. diff --git a/core/modules/navigation/css/base/variables.pcss.css b/core/modules/navigation/css/base/variables.pcss.css index ae9655a7ad0..844e0ba048a 100644 --- a/core/modules/navigation/css/base/variables.pcss.css +++ b/core/modules/navigation/css/base/variables.pcss.css @@ -71,14 +71,14 @@ We need it root to calculate the size of the displace in .dialog-off-canvas-main /* White. */ --admin-toolbar-color-white: var(--drupal-admin-color-white, #fff); /* Expanded background color. */ - --admin-toolbar-color-expanded: rgba(231, 234, 243, 0.5); /* --admin-toolbar-color-gray-050 */ + --admin-toolbar-color-expanded: rgb(231, 234, 243, 0.5); /* --admin-toolbar-color-gray-050 */ /* Fonts. */ --admin-toolbar-font-family: inter, sans-serif; /* Shadows. */ - --admin-toolbar-color-shadow-0: rgba(0, 0, 0, 0); - --admin-toolbar-color-shadow-6: rgba(0, 0, 0, 0.06); - --admin-toolbar-color-shadow-8: rgba(0, 0, 0, 0.08); - --admin-toolbar-color-shadow-15: rgba(0, 0, 0, 0.15); + --admin-toolbar-color-shadow-0: rgb(0, 0, 0, 0); + --admin-toolbar-color-shadow-6: rgb(0, 0, 0, 0.06); + --admin-toolbar-color-shadow-8: rgb(0, 0, 0, 0.08); + --admin-toolbar-color-shadow-15: rgb(0, 0, 0, 0.15); /** * Spaces. diff --git a/core/modules/navigation/css/components/admin-toolbar.css b/core/modules/navigation/css/components/admin-toolbar.css index 25fd9ca1cc0..8e8636d8a85 100644 --- a/core/modules/navigation/css/components/admin-toolbar.css +++ b/core/modules/navigation/css/components/admin-toolbar.css @@ -94,7 +94,7 @@ body { .admin-toolbar { block-size: calc(100vh - var(--drupal-displace-offset-top, 0px)); transform: none; - inset-block-start: var(--drupal-displace-offset-top, 0); + inset-block-start: 0; } } @media only screen and (max-height: 18.75rem) { @@ -333,7 +333,7 @@ body { display: none; width: 100vw; height: 100vh; - background-color: rgba(0, 0, 0, 0.14); + background-color: rgb(0, 0, 0, 0.14); } :where([data-admin-toolbar="expanded"]) .admin-toolbar-overlay { display: block; diff --git a/core/modules/navigation/css/components/admin-toolbar.pcss.css b/core/modules/navigation/css/components/admin-toolbar.pcss.css index 0b13b08252e..55ffd803e69 100644 --- a/core/modules/navigation/css/components/admin-toolbar.pcss.css +++ b/core/modules/navigation/css/components/admin-toolbar.pcss.css @@ -95,7 +95,7 @@ body { @media (--admin-toolbar-desktop) { block-size: calc(100vh - var(--drupal-displace-offset-top, 0px)); transform: none; - inset-block-start: var(--drupal-displace-offset-top, 0); + inset-block-start: 0; } @media only screen and (max-height: 300px) { @@ -354,7 +354,7 @@ body { display: none; width: 100vw; height: 100vh; - background-color: rgba(0, 0, 0, 0.14); + background-color: rgb(0, 0, 0, 0.14); :where([data-admin-toolbar="expanded"]) & { display: block; diff --git a/core/modules/navigation/css/components/toolbar-dropdown.css b/core/modules/navigation/css/components/toolbar-dropdown.css index f21bf044137..eca3b63b27e 100644 --- a/core/modules/navigation/css/components/toolbar-dropdown.css +++ b/core/modules/navigation/css/components/toolbar-dropdown.css @@ -50,7 +50,7 @@ padding: var(--admin-toolbar-space-8) var(--admin-toolbar-space-16); border-radius: var(--admin-toolbar-space-12); background: white; - box-shadow: 0 14px 30px 0 rgba(0, 0, 0, 0.1); + box-shadow: 0 14px 30px 0 rgb(0, 0, 0, 0.1); } [data-drupal-dropdown][aria-expanded="true"] + .toolbar-dropdown__menu { display: block; diff --git a/core/modules/navigation/css/components/toolbar-dropdown.pcss.css b/core/modules/navigation/css/components/toolbar-dropdown.pcss.css index 6c462a08ba8..91cd88d0a5f 100644 --- a/core/modules/navigation/css/components/toolbar-dropdown.pcss.css +++ b/core/modules/navigation/css/components/toolbar-dropdown.pcss.css @@ -49,7 +49,7 @@ padding: var(--admin-toolbar-space-8) var(--admin-toolbar-space-16); border-radius: var(--admin-toolbar-space-12); background: white; - box-shadow: 0 14px 30px 0 rgba(0, 0, 0, 0.1); + box-shadow: 0 14px 30px 0 rgb(0, 0, 0, 0.1); } [data-drupal-dropdown][aria-expanded="true"] + .toolbar-dropdown__menu { diff --git a/core/modules/navigation/css/components/toolbar-menu.css b/core/modules/navigation/css/components/toolbar-menu.css index 33e2af3bcb9..9b411c7cba7 100644 --- a/core/modules/navigation/css/components/toolbar-menu.css +++ b/core/modules/navigation/css/components/toolbar-menu.css @@ -51,7 +51,7 @@ -webkit-text-decoration: none; text-decoration: none; letter-spacing: var(--admin-toolbar-letter-spacing-0-06); - word-break: break-word; + overflow-wrap: break-word; color: var(--admin-toolbar-color-gray-800); border: none; background-color: transparent; @@ -133,5 +133,5 @@ rotate: -90deg; } [dir="rtl"] :is([aria-expanded="true"] .toolbar-menu__chevron) { - rotate: 0; + rotate: none; } diff --git a/core/modules/navigation/css/components/toolbar-menu.pcss.css b/core/modules/navigation/css/components/toolbar-menu.pcss.css index 7f2c98950cc..60fc599d712 100644 --- a/core/modules/navigation/css/components/toolbar-menu.pcss.css +++ b/core/modules/navigation/css/components/toolbar-menu.pcss.css @@ -55,7 +55,7 @@ text-align: start; text-decoration: none; letter-spacing: var(--admin-toolbar-letter-spacing-0-06); - word-break: break-word; + overflow-wrap: break-word; color: var(--admin-toolbar-color-gray-800); border: none; background-color: transparent; @@ -150,7 +150,7 @@ rotate: -90deg; [dir="rtl"] & { - rotate: 0; + rotate: none; } } } diff --git a/core/modules/navigation/css/components/toolbar-message.css b/core/modules/navigation/css/components/toolbar-message.css index 11b50e9459b..8a852a0fc70 100644 --- a/core/modules/navigation/css/components/toolbar-message.css +++ b/core/modules/navigation/css/components/toolbar-message.css @@ -15,7 +15,7 @@ text-align: start; -webkit-text-decoration: none; text-decoration: none; - word-break: break-word; + overflow-wrap: break-word; color: var(--admin-toolbar-color-gray-800); border: 0; border-radius: var(--admin-toolbar-space-8); diff --git a/core/modules/navigation/css/components/toolbar-message.pcss.css b/core/modules/navigation/css/components/toolbar-message.pcss.css index 9689014aab4..9e0f401fb4a 100644 --- a/core/modules/navigation/css/components/toolbar-message.pcss.css +++ b/core/modules/navigation/css/components/toolbar-message.pcss.css @@ -7,7 +7,7 @@ cursor: pointer; text-align: start; text-decoration: none; - word-break: break-word; + overflow-wrap: break-word; color: var(--admin-toolbar-color-gray-800); border: 0; border-radius: var(--admin-toolbar-space-8); diff --git a/core/modules/navigation/css/components/toolbar-popover.css b/core/modules/navigation/css/components/toolbar-popover.css index 76f587a325e..532e873d96e 100644 --- a/core/modules/navigation/css/components/toolbar-popover.css +++ b/core/modules/navigation/css/components/toolbar-popover.css @@ -48,9 +48,9 @@ padding-block-start: var(--admin-toolbar-space-16); transform: translateX(0); box-shadow: - 0 0 72px rgba(0, 0, 0, 0.2), - 0 0 8px rgba(0, 0, 0, 0.04), - 0 0 40px rgba(0, 0, 0, 0.06); + 0 0 72px rgb(0, 0, 0, 0.2), + 0 0 8px rgb(0, 0, 0, 0.04), + 0 0 40px rgb(0, 0, 0, 0.06); inline-size: var(--admin-toolbar-popover-width); inset-block-start: var(--drupal-displace-offset-top, 0); inset-inline-start: 1px; diff --git a/core/modules/navigation/css/components/toolbar-popover.pcss.css b/core/modules/navigation/css/components/toolbar-popover.pcss.css index 6a8b2a019c2..a12ebe955ed 100644 --- a/core/modules/navigation/css/components/toolbar-popover.pcss.css +++ b/core/modules/navigation/css/components/toolbar-popover.pcss.css @@ -47,9 +47,9 @@ padding-block-start: var(--admin-toolbar-space-16); transform: translateX(0); box-shadow: - 0 0 72px rgba(0, 0, 0, 0.2), - 0 0 8px rgba(0, 0, 0, 0.04), - 0 0 40px rgba(0, 0, 0, 0.06); + 0 0 72px rgb(0, 0, 0, 0.2), + 0 0 8px rgb(0, 0, 0, 0.04), + 0 0 40px rgb(0, 0, 0, 0.06); inline-size: var(--admin-toolbar-popover-width); inset-block-start: var(--drupal-displace-offset-top, 0); inset-inline-start: 1px; diff --git a/core/modules/navigation/css/components/top-bar.css b/core/modules/navigation/css/components/top-bar.css index 65a489cd78b..134b0a506c7 100644 --- a/core/modules/navigation/css/components/top-bar.css +++ b/core/modules/navigation/css/components/top-bar.css @@ -22,7 +22,7 @@ .top-bar { block-size: var(--admin-toolbar-top-bar-height); position: fixed; - inset-block-start: var(--drupal-displace-offset-top, 0); + inset-block-start: 0; inset-inline-start: 0; width: 100vw; padding-block: var(--admin-toolbar-space-12); diff --git a/core/modules/navigation/css/components/top-bar.pcss.css b/core/modules/navigation/css/components/top-bar.pcss.css index b55604dfa56..0a34a4b17da 100644 --- a/core/modules/navigation/css/components/top-bar.pcss.css +++ b/core/modules/navigation/css/components/top-bar.pcss.css @@ -18,7 +18,7 @@ @media (--admin-toolbar-desktop) { block-size: var(--admin-toolbar-top-bar-height); position: fixed; - inset-block-start: var(--drupal-displace-offset-top, 0); + inset-block-start: 0; inset-inline-start: 0; width: 100vw; padding-block: var(--admin-toolbar-space-12); diff --git a/core/modules/navigation/js/admin-toolbar-wrapper.js b/core/modules/navigation/js/admin-toolbar-wrapper.js index c9e2ecb9cae..6c32da0b6f6 100644 --- a/core/modules/navigation/js/admin-toolbar-wrapper.js +++ b/core/modules/navigation/js/admin-toolbar-wrapper.js @@ -81,31 +81,35 @@ Drupal.displace(true); }); - - /** - * Initialize Drupal.displace() - * - * We add the displace attribute to a separate full width element because we - * don't want this element to have transitions. Note that this element and the - * navbar share the same exact width. - */ - const initDisplace = () => { - const displaceElement = doc - .querySelector('.admin-toolbar') - ?.querySelector('.admin-toolbar__displace-placeholder'); - const edge = document.documentElement.dir === 'rtl' ? 'right' : 'left'; - displaceElement?.setAttribute(`data-offset-${edge}`, ''); - Drupal.displace(true); - }; - - initDisplace(); } + /** + * Initialize Drupal.displace() + * + * We add the displace attribute to a separate full width element because we + * don't want this element to have transitions. Note that this element and the + * navbar share the same exact width. + * + * @param {HTMLElement} el - The admin toolbar wrapper. + */ + const initDisplace = (el) => { + const displaceElement = el.querySelector( + '.admin-toolbar__displace-placeholder', + ); + const edge = document.documentElement.dir === 'rtl' ? 'right' : 'left'; + displaceElement?.setAttribute(`data-offset-${edge}`, ''); + Drupal.displace(true); + }; + // Any triggers on page. Inside or outside sidebar. // For now button in sidebar + mobile header and background. Drupal.behaviors.navigationProcessToolbarTriggers = { attach: (context) => { + once('navigation-displace', '.admin-toolbar', context).forEach( + initDisplace, + ); + const triggers = once( 'admin-toolbar-trigger', '[aria-controls="admin-toolbar"]', diff --git a/core/modules/navigation/navigation.services.yml b/core/modules/navigation/navigation.services.yml index 88b6826409a..925efe58a4e 100644 --- a/core/modules/navigation/navigation.services.yml +++ b/core/modules/navigation/navigation.services.yml @@ -45,3 +45,6 @@ services: class: Drupal\navigation\TopBarItemManager parent: default_plugin_manager Drupal\navigation\TopBarItemManagerInterface: '@plugin.manager.top_bar_item' + + Drupal\navigation\Menu\NavigationMenuLinkTreeManipulators: + autowire: true diff --git a/core/modules/navigation/src/Hook/NavigationHooks.php b/core/modules/navigation/src/Hook/NavigationHooks.php index ed4eb828366..540a2659af5 100644 --- a/core/modules/navigation/src/Hook/NavigationHooks.php +++ b/core/modules/navigation/src/Hook/NavigationHooks.php @@ -62,7 +62,7 @@ class NavigationHooks { $output = ''; $output .= '<h3>' . $this->t('About') . '</h3>'; $output .= '<p>' . $this->t('The Navigation module provides a left-aligned, collapsible, vertical sidebar navigation.') . '</p>'; - $output .= '<p>' . $this->t('For more information, see the <a href=":docs">online documentation for the Navigation module</a>.', [':docs' => 'https://www.drupal.org/project/navigation']) . '</p>'; + $output .= '<p>' . $this->t('For more information, see the <a href=":docs">online documentation for the Navigation module</a>.', [':docs' => 'https://www.drupal.org/docs/develop/core-modules-and-themes/core-modules/navigation-module']) . '</p>'; return $output; } $configuration_route = 'layout_builder.navigation.'; 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/src/Menu/NavigationMenuLinkTreeManipulators.php b/core/modules/navigation/src/Menu/NavigationMenuLinkTreeManipulators.php new file mode 100644 index 00000000000..75fefdf1541 --- /dev/null +++ b/core/modules/navigation/src/Menu/NavigationMenuLinkTreeManipulators.php @@ -0,0 +1,159 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation\Menu; + +use Drupal\Core\Menu\MenuLinkDefault; +use Drupal\Core\Menu\MenuLinkTreeElement; +use Drupal\Core\Menu\StaticMenuLinkOverridesInterface; +use Drupal\Core\Routing\RouteProviderInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\system\Controller\SystemController; +use Symfony\Component\Routing\Exception\RouteNotFoundException; + +/** + * Provides a menu link tree manipulator for the navigation menu block. + */ +class NavigationMenuLinkTreeManipulators { + + use StringTranslationTrait; + + public function __construct( + protected readonly RouteProviderInterface $routeProvider, + protected readonly StaticMenuLinkOverridesInterface $overrides, + TranslationInterface $translation, + ) { + $this->setStringTranslation($translation); + } + + /** + * Adds an "overview" child link to second level menu links with children. + * + * In the navigation menu, a second-level menu item is a link if it does not + * have children, but if it does, it instead becomes a button that opens + * its child menu. To provide a way to access the page a second-level menu + * item links to, add an "overview" link that links to the page as a child + * (third-level) menu item. + * + * @param \Drupal\Core\Menu\MenuLinkTreeElement[] $tree + * The menu link tree to manipulate. + * + * @return \Drupal\Core\Menu\MenuLinkTreeElement[] + * The manipulated menu link tree. + */ + public function addSecondLevelOverviewLinks(array $tree): array { + if (!$tree) { + return []; + } + + foreach ($tree as $item) { + if (!$this->isEnabledAndAccessible($item)) { + continue; + } + foreach ($item->subtree as $sub_item) { + if ($this->shouldAddOverviewLink($sub_item)) { + $this->addOverviewLink($sub_item); + } + } + } + + return $tree; + } + + /** + * Whether a menu tree element should have an overview link added to it. + * + * @param \Drupal\Core\Menu\MenuLinkTreeElement $element + * The menu link tree element to check. + * + * @return bool + * TRUE if menu tree element should have a child overview link added. + */ + protected function shouldAddOverviewLink(MenuLinkTreeElement $element): bool { + if (empty($element->subtree) || !$this->isEnabledAndAccessible($element)) { + return FALSE; + } + + $route_name = $element->link->getRouteName(); + if (in_array($route_name, ['<nolink>', '<button>'])) { + return FALSE; + } + + $has_visible_children = FALSE; + foreach ($element->subtree as $sub_element) { + // Do not add overview link if there are no accessible or enabled + // children. + if ($this->isEnabledAndAccessible($sub_element)) { + $has_visible_children = TRUE; + } + + // Do not add overview link if there is already a child linking to the + // same URL. + if ($sub_element->link->getRouteName() === $route_name) { + return FALSE; + } + } + + if (!$has_visible_children) { + return FALSE; + } + + // The systemAdminMenuBlockPage() method in SystemController returns a list + // of child menu links for the page. If the second-level menu item link's + // route uses that controller, do not add the overview link, because that + // duplicates what is already in the navigation menu. + try { + $controller = ltrim($this->routeProvider->getRouteByName($route_name)->getDefault('_controller') ?? '', "\\"); + return $controller !== SystemController::class . '::systemAdminMenuBlockPage'; + } + catch (RouteNotFoundException) { + return TRUE; + } + } + + /** + * Checks whether the menu link tree element is accessible and enabled. + * + * Generally, the 'checkAccess' manipulator should run before this manipulator + * does, so the access objects should be set on all the links, but if it is + * not, treat the link as accessible for the purpose of adding the overview + * child link. + * + * @param \Drupal\Core\Menu\MenuLinkTreeElement $element + * The menu link tree element to be checked. + * + * @return bool + * TRUE if the menu link tree element is enabled and has access allowed. + */ + protected function isEnabledAndAccessible(MenuLinkTreeElement $element): bool { + return $element->link->isEnabled() && (!isset($element->access) || $element->access->isAllowed()); + } + + /** + * Adds "overview" menu tree element as child of a menu tree element. + * + * @param \Drupal\Core\Menu\MenuLinkTreeElement $element + * The menu link tree element to add the overview child element to. + */ + protected function addOverviewLink(MenuLinkTreeElement $element): void { + // Copy the menu link for the menu link element to a new menu link + // definition, except with overrides to make 'Overview' the title, set the + // parent to the original menu link, and set weight to a low number so that + // it likely goes to the top. + $definition = [ + 'title' => $this->t('Overview', [ + '@title' => $element->link->getTitle(), + ]), + 'parent' => $element->link->getPluginId(), + 'provider' => 'navigation', + 'weight' => -1000, + ] + $element->link->getPluginDefinition(); + $link = new MenuLinkDefault([], $element->link->getPluginId() . '.navigation_overview', $definition, $this->overrides); + $overview_element = new MenuLinkTreeElement($link, FALSE, $element->depth + 1, $element->inActiveTrail, []); + $overview_element->access = $element->access ? clone $element->access : NULL; + $element->subtree[$element->link->getPluginId() . '.navigation_overview'] = $overview_element; + } + +} diff --git a/core/modules/navigation/src/Plugin/Block/NavigationMenuBlock.php b/core/modules/navigation/src/Plugin/Block/NavigationMenuBlock.php index 380cc8540aa..8b75563b92f 100644 --- a/core/modules/navigation/src/Plugin/Block/NavigationMenuBlock.php +++ b/core/modules/navigation/src/Plugin/Block/NavigationMenuBlock.php @@ -9,6 +9,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Menu\MenuTreeParameters; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\navigation\Menu\NavigationMenuLinkTreeManipulators; use Drupal\navigation\Plugin\Derivative\SystemMenuNavigationBlock as SystemMenuNavigationBlockDeriver; use Drupal\system\Plugin\Block\SystemMenuBlock; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -85,6 +86,7 @@ final class NavigationMenuBlock extends SystemMenuBlock implements ContainerFact $tree = $this->menuTree->load($menu_name, $parameters); $manipulators = [ ['callable' => 'menu.default_tree_manipulators:checkAccess'], + ['callable' => NavigationMenuLinkTreeManipulators::class . ':addSecondLevelOverviewLinks'], ['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'], ]; $tree = $this->menuTree->transform($tree, $manipulators); diff --git a/core/modules/navigation/src/WorkspacesLazyBuilder.php b/core/modules/navigation/src/WorkspacesLazyBuilder.php index 3134fa4745e..146f7d83dc9 100644 --- a/core/modules/navigation/src/WorkspacesLazyBuilder.php +++ b/core/modules/navigation/src/WorkspacesLazyBuilder.php @@ -59,6 +59,9 @@ final class WorkspacesLazyBuilder { 'title' => $active_workspace ? $active_workspace->label() : $this->t('Live'), 'url' => $url, 'class' => 'workspaces', + 'icon' => [ + 'icon_id' => 'workspaces', + ], ], ], '#attached' => [ diff --git a/core/modules/navigation/templates/top-bar.html.twig b/core/modules/navigation/templates/top-bar.html.twig index 6efdeed523f..319f97f6747 100644 --- a/core/modules/navigation/templates/top-bar.html.twig +++ b/core/modules/navigation/templates/top-bar.html.twig @@ -13,17 +13,19 @@ */ #} {% set attributes = create_attribute() %} -<aside {{ attributes.addClass('top-bar').setAttribute('data-drupal-admin-styles', '').setAttribute('aria-labelledby', 'top-bar__title') }}> - <h3 id="top-bar__title" class="visually-hidden">{{ 'Administrative top bar'|t }}</h3> - <div class="top-bar__content"> - <div class="top-bar__tools"> - {{- tools -}} +{% if tools or context|render or actions|render %} + <aside {{ attributes.addClass('top-bar').setAttribute('data-drupal-admin-styles', '').setAttribute('aria-labelledby', 'top-bar__title').setAttribute('data-offset-top', true) }}> + <h3 id="top-bar__title" class="visually-hidden">{{ 'Administrative top bar'|t }}</h3> + <div class="top-bar__content"> + <div class="top-bar__tools"> + {{- tools -}} + </div> + <div class="top-bar__context"> + {{- context -}} + </div> + <div class="top-bar__actions"> + {{- actions -}} + </div> </div> - <div class="top-bar__context"> - {{- context -}} - </div> - <div class="top-bar__actions"> - {{- actions -}} - </div> - </div> -</aside> + </aside> +{% endif %} diff --git a/core/modules/navigation/tests/navigation_test/navigation_test.module b/core/modules/navigation/tests/navigation_test/navigation_test.module deleted file mode 100644 index 3c7eb2fade8..00000000000 --- a/core/modules/navigation/tests/navigation_test/navigation_test.module +++ /dev/null @@ -1,19 +0,0 @@ -<?php - -/** - * @file - * Contains main module functions. - */ - -declare(strict_types=1); - -use Drupal\Component\Utility\Html; - -/** - * Implements hook_preprocess_HOOK(). - */ -function navigation_test_preprocess_block__navigation(&$variables): void { - // Add some additional classes so we can target the correct contextual link - // in tests. - $variables['attributes']['class'][] = Html::cleanCssIdentifier('block-' . $variables['elements']['#plugin_id']); -} diff --git a/core/modules/navigation/tests/navigation_test/src/Hook/NavigationTestThemeHooks.php b/core/modules/navigation/tests/navigation_test/src/Hook/NavigationTestThemeHooks.php new file mode 100644 index 00000000000..9020deed81d --- /dev/null +++ b/core/modules/navigation/tests/navigation_test/src/Hook/NavigationTestThemeHooks.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation_test\Hook; + +use Drupal\Component\Utility\Html; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for navigation_test module. + */ +class NavigationTestThemeHooks { + + /** + * Implements hook_preprocess_HOOK(). + */ + #[Hook('preprocess_block__navigation')] + public function preprocessBlockNavigation(&$variables): void { + // Add some additional classes so we can target the correct contextual link + // in tests. + $variables['attributes']['class'][] = Html::cleanCssIdentifier('block-' . $variables['elements']['#plugin_id']); + } + +} diff --git a/core/modules/navigation/tests/src/Functional/NavigationWorkspacesUiTest.php b/core/modules/navigation/tests/src/Functional/NavigationWorkspacesUiTest.php new file mode 100644 index 00000000000..48de404b65a --- /dev/null +++ b/core/modules/navigation/tests/src/Functional/NavigationWorkspacesUiTest.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\navigation\Functional; + +use Drupal\Tests\BrowserTestBase; + +/** + * Tests for \Drupal\navigation\WorkspacesLazyBuilder. + * + * @group navigation + */ +class NavigationWorkspacesUiTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['navigation', 'workspaces_ui']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $admin_user = $this->drupalCreateUser([ + 'access navigation', + 'administer workspaces', + ]); + $this->drupalLogin($admin_user); + } + + /** + * Tests the Workspaces button in the navigation bar. + */ + public function testWorkspacesNavigationButton(): void { + $this->drupalGet('<front>'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->elementAttributeContains('css', 'a.toolbar-button--icon--workspaces svg', 'width', '20'); + $this->assertSession()->elementAttributeContains('css', 'a.toolbar-button--icon--workspaces svg', 'class', 'toolbar-button__icon'); + } + +} diff --git a/core/modules/navigation/tests/src/FunctionalJavascript/NavigationBlockUiTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/NavigationBlockUiTest.php index 87d100d40aa..90b6874c6bc 100644 --- a/core/modules/navigation/tests/src/FunctionalJavascript/NavigationBlockUiTest.php +++ b/core/modules/navigation/tests/src/FunctionalJavascript/NavigationBlockUiTest.php @@ -11,12 +11,12 @@ use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; use Drupal\Tests\layout_builder\FunctionalJavascript\LayoutBuilderSortTrait; use Drupal\Tests\system\Traits\OffCanvasTestTrait; use Drupal\user\UserInterface; +use PHPUnit\Framework\Attributes\Group; /** * Tests that the navigation block UI exists and stores data correctly. - * - * @group navigation */ +#[Group('navigation')] class NavigationBlockUiTest extends WebDriverTestBase { use BlockCreationTrait; diff --git a/core/modules/navigation/tests/src/FunctionalJavascript/NavigationUserBlockTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/NavigationUserBlockTest.php index dbbce1ea0c7..e619686a806 100644 --- a/core/modules/navigation/tests/src/FunctionalJavascript/NavigationUserBlockTest.php +++ b/core/modules/navigation/tests/src/FunctionalJavascript/NavigationUserBlockTest.php @@ -7,14 +7,13 @@ namespace Drupal\Tests\navigation\FunctionalJavascript; use Behat\Mink\Element\Element; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; // cspell:ignore navigationuser linksuserwrapper - /** * Tests for \Drupal\navigation\Plugin\NavigationBlock\NavigationUserBlock. - * - * @group navigation */ +#[Group('navigation')] class NavigationUserBlockTest extends WebDriverTestBase { /** diff --git a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php index 2371bef31aa..528c874649f 100644 --- a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php +++ b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Drupal\Tests\navigation\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\PerformanceTestBase; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; /** * Tests performance with the navigation toolbar enabled. @@ -13,11 +15,10 @@ use Drupal\FunctionalJavascriptTests\PerformanceTestBase; * * @todo move this coverage to StandardPerformanceTest when Navigation is * enabled by default. - * - * @group Common - * @group #slow - * @requires extension apcu */ +#[Group('Common')] +#[Group('#slow')] +#[RequiresPhpExtension('apcu')] class PerformanceTest extends PerformanceTestBase { /** @@ -73,14 +74,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,11 +90,11 @@ class PerformanceTest extends PerformanceTestBase { ], 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, - 'CacheTagLookupQueryCount' => 14, + 'CacheTagLookupQueryCount' => 13, 'ScriptCount' => 3, - 'ScriptBytes' => 213500, + 'ScriptBytes' => 167569, 'StylesheetCount' => 2, - 'StylesheetBytes' => 46000, + 'StylesheetBytes' => 45450, ]; $this->assertMetrics($expected, $performance_data); diff --git a/core/modules/navigation/tests/src/Kernel/NavigationMenuBlockTest.php b/core/modules/navigation/tests/src/Kernel/NavigationMenuBlockTest.php index 496b7d1f7af..d9cf1f70a09 100644 --- a/core/modules/navigation/tests/src/Kernel/NavigationMenuBlockTest.php +++ b/core/modules/navigation/tests/src/Kernel/NavigationMenuBlockTest.php @@ -11,6 +11,7 @@ use Drupal\Core\Routing\RouteObjectInterface; use Drupal\Core\Routing\UrlGenerator; use Drupal\KernelTests\KernelTestBase; use Drupal\navigation\Plugin\Block\NavigationMenuBlock; +use Drupal\system\Controller\SystemController; use Drupal\system\Entity\Menu; use Drupal\system\Tests\Routing\MockRouteProvider; use Drupal\Tests\Core\Menu\MenuLinkMock; @@ -106,9 +107,16 @@ class NavigationMenuBlockTest extends KernelTestBase { $options = ['_access_checks' => ['access_check.default']]; $special_options = $options + ['_no_path' => TRUE]; $routes->add('example2', new Route('/example2', [], $requirements, $options)); - $routes->add('example4', new Route('/example4', [], $requirements, $options)); + $routes->add('example4', new Route('/example4', ['_controller' => SystemController::class . '::systemAdminMenuBlockPage'], $requirements, $options)); $routes->add('example9', new Route('/example9', [], $requirements, $options)); - $routes->add('example11', new Route('/example11', [], $requirements, $options)); + $routes->add('example11', new Route('/example11', ['_controller' => SystemController::class . '::systemAdminMenuBlockPage'], $requirements, $options)); + $routes->add('example13', new Route('/example13', [], $requirements, $options)); + $routes->add('example14', new Route('/example14', [], $requirements, $options)); + $routes->add('example15', new Route('/example15', [], $requirements, $options)); + $routes->add('example16', new Route('/example16', [], $requirements, $options)); + $routes->add('example17', new Route('/example17', [], $requirements, $options)); + $routes->add('example18', new Route('/example18', [], $requirements, $options)); + $routes->add('example19', new Route('/example19', [], ['_access' => 'FALSE'], $options)); // Mock special routes defined in system.routing.yml. $routes->add('<nolink>', new Route('', [], $requirements, $special_options)); @@ -144,15 +152,23 @@ class NavigationMenuBlockTest extends KernelTestBase { // - 1 (nolink) // - 2 // - 3 (nolink) - // - 4 + // - 4 (list of child links) // - 9 // - 5 (button) // - 7 (button) // - 10 (nolink) // - 6 // - 8 (nolink) - // - 11 + // - 11 (list of child links) // - 12 (button) + // - 13 + // - 14 (not a list of child links) + // - 15 + // - 16 + // - 17 + // - 18 (disabled) + // - 19 (access denied) + // - 20 (links to same routed URL as 17) // With link 6 being the only external link. // phpcs:disable $links = [ @@ -168,6 +184,14 @@ class NavigationMenuBlockTest extends KernelTestBase { 10 => MenuLinkMock::create(['id' => 'test.example10', 'route_name' => '<nolink>', 'title' => 'title 10', 'parent' => 'test.example7', 'weight' => 7]), 11 => MenuLinkMock::create(['id' => 'test.example11', 'route_name' => 'example11', 'title' => 'title 11', 'parent' => 'test.example8', 'weight' => 7]), 12 => MenuLinkMock::create(['id' => 'test.example12', 'route_name' => '<button>', 'title' => 'title 12', 'parent' => 'test.example11', 'weight' => 7]), + 13 => MenuLinkMock::create(['id' => 'test.example13', 'route_name' => 'example13', 'title' => 'title 13', 'parent' => '', 'weight' => 8]), + 14 => MenuLinkMock::create(['id' => 'test.example14', 'route_name' => 'example14', 'title' => 'title 14', 'parent' => 'test.example13', 'weight' => 8]), + 15 => MenuLinkMock::create(['id' => 'test.example15', 'route_name' => 'example15', 'title' => 'title 15', 'parent' => 'test.example14', 'weight' => 8]), + 16 => MenuLinkMock::create(['id' => 'test.example16', 'route_name' => 'example16', 'title' => 'title 16', 'parent' => '', 'weight' => 9]), + 17 => MenuLinkMock::create(['id' => 'test.example17', 'route_name' => 'example17', 'title' => 'title 17', 'parent' => 'test.example16', 'weight' => 9]), + 18 => MenuLinkMock::create(['id' => 'test.example18', 'route_name' => 'example18', 'title' => 'title 18', 'parent' => 'test.example17', 'weight' => 9, 'enabled' => FALSE]), + 19 => MenuLinkMock::create(['id' => 'test.example19', 'route_name' => 'example19', 'title' => 'title 19', 'parent' => 'test.example17', 'weight' => 9]), + 20 => MenuLinkMock::create(['id' => 'test.example20', 'route_name' => 'example17', 'title' => 'title 20', 'parent' => 'test.example17', 'weight' => 9]), ]; // phpcs:enable foreach ($links as $instance) { @@ -234,16 +258,22 @@ class NavigationMenuBlockTest extends KernelTestBase { 'test.example5' => [], 'test.example6' => [], 'test.example8' => [], + 'test.example13' => [], + 'test.example16' => [], ]; $expectations['level_2_only'] = [ 'test.example3' => [], 'test.example7' => [], 'test.example11' => [], + 'test.example14' => [], + 'test.example17' => [], ]; $expectations['level_3_only'] = [ 'test.example4' => [], 'test.example10' => [], 'test.example12' => [], + 'test.example15' => [], + 'test.example20' => [], ]; $expectations['level_1_and_beyond'] = [ 'test.example1' => [], @@ -263,6 +293,20 @@ class NavigationMenuBlockTest extends KernelTestBase { 'test.example12' => [], ], ], + 'test.example13' => [ + 'test.example14' => [ + 'test.example14.navigation_overview' => [], + 'test.example15' => [], + ], + ], + 'test.example16' => [ + // 17 only has inaccessible and disabled child links, and a child item + // that links to the same url as 17, so there should be no overview link + // child added. + 'test.example17' => [ + 'test.example20' => [], + ], + ], ]; $expectations['level_2_and_beyond'] = [ 'test.example3' => [ @@ -276,6 +320,12 @@ class NavigationMenuBlockTest extends KernelTestBase { 'test.example11' => [ 'test.example12' => [], ], + 'test.example14' => [ + 'test.example15' => [], + ], + 'test.example17' => [ + 'test.example20' => [], + ], ]; $expectations['level_3_and_beyond'] = [ 'test.example4' => [ @@ -283,6 +333,8 @@ class NavigationMenuBlockTest extends KernelTestBase { ], 'test.example10' => [], 'test.example12' => [], + 'test.example15' => [], + 'test.example20' => [], ]; // Scenario 1: test all navigation block instances when there's no active // trail. @@ -346,6 +398,10 @@ class NavigationMenuBlockTest extends KernelTestBase { "//li[contains(@class,'toolbar-menu__item--level-2')]/span[text()='title 10']", "//li[contains(@class,'toolbar-menu__item--level-1')]/button/span[text()='title 11']", "//li[contains(@class,'toolbar-menu__item--level-2')]/button[text()='title 12']", + "//li[contains(@class,'toolbar-block__list-item')]/button/span[text()='title 13']", + "//li[contains(@class,'toolbar-menu__item--level-1')]/button/span[text()='title 14']", + "//li[contains(@class,'toolbar-menu__item--level-2')]/a[text()='Overview']", + "//li[contains(@class,'toolbar-menu__item--level-1')]/button/span[text()='title 17']", ]; foreach ($items_query as $query) { $span = $xpath->query($query); diff --git a/core/modules/navigation/tests/src/Nightwatch/Tests/navigationDisplaceTest.js b/core/modules/navigation/tests/src/Nightwatch/Tests/navigationDisplaceTest.js new file mode 100644 index 00000000000..ce9acfacc51 --- /dev/null +++ b/core/modules/navigation/tests/src/Nightwatch/Tests/navigationDisplaceTest.js @@ -0,0 +1,26 @@ +/** + * Verify that Drupal.displace() attribute is properly added by JavaScript. + */ +module.exports = { + '@tags': ['core', 'navigation'], + browser(browser) { + browser + .drupalInstall() + .drupalInstallModule('navigation', true) + .drupalInstallModule('big_pipe') + .setWindowSize(1220, 800); + }, + after(browser) { + browser.drupalUninstall(); + }, + + 'Verify displace attribute': (browser) => { + browser.drupalLoginAsAdmin(() => { + browser + .drupalRelativeURL('/admin/') + .waitForElementPresent( + '.admin-toolbar__displace-placeholder[data-offset-left]', + ); + }); + }, +}; diff --git a/core/modules/navigation/tests/src/Unit/NavigationMenuLinkTreeManipulatorsTest.php b/core/modules/navigation/tests/src/Unit/NavigationMenuLinkTreeManipulatorsTest.php new file mode 100644 index 00000000000..257033b5fea --- /dev/null +++ b/core/modules/navigation/tests/src/Unit/NavigationMenuLinkTreeManipulatorsTest.php @@ -0,0 +1,308 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\navigation\Unit; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Menu\MenuLinkTreeElement; +use Drupal\Core\Menu\StaticMenuLinkOverridesInterface; +use Drupal\Core\Routing\RouteProviderInterface; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\navigation\Menu\NavigationMenuLinkTreeManipulators; +use Drupal\system\Controller\SystemController; +use Drupal\Tests\Core\Menu\MenuLinkMock; +use Drupal\Tests\UnitTestCase; +use Symfony\Component\Routing\Route; + +/** + * Tests the navigation menu link tree manipulator. + * + * @group navigation + * + * @coversDefaultClass \Drupal\navigation\Menu\NavigationMenuLinkTreeManipulators + */ +class NavigationMenuLinkTreeManipulatorsTest extends UnitTestCase { + + /** + * Tests the addSecondLevelOverviewLinks() tree manipulator. + * + * @covers ::addSecondLevelOverviewLinks + */ + public function testAddSecondLevelOverviewLinks(): void { + $routeProvider = $this->createMock(RouteProviderInterface::class); + // For only the route named 'child_list', return a route object with the + // SystemController::systemAdminMenuBlockPage as the controller. + $childListRoute = new Route('/test-child-list', ['_controller' => SystemController::class . '::systemAdminMenuBlockPage']); + $routeProvider->expects($this->any()) + ->method('getRouteByName') + ->willReturnCallback(static fn ($name) => $name === 'child_list' ? $childListRoute : new Route("/$name")); + $overrides = $this->createMock(StaticMenuLinkOverridesInterface::class); + $translation = $this->createMock(TranslationInterface::class); + $translation + ->method('translateString') + ->willReturnCallback(static fn ($string) => $string); + $manipulator = new NavigationMenuLinkTreeManipulators($routeProvider, $overrides, $translation); + + $originalTree = $this->mockTree(); + // Make sure overview links do not already exist. + $this->assertArrayNotHasKey('test.example3.navigation_overview', $originalTree[2]->subtree[3]->subtree); + $this->assertArrayNotHasKey('test.example6.navigation_overview', $originalTree[5]->subtree[6]->subtree); + $tree = $manipulator->addSecondLevelOverviewLinks($originalTree); + + // First level menu items should not have any children added. + $this->assertEmpty($tree[1]->subtree); + $this->assertEquals($originalTree[2]->subtree, $tree[2]->subtree); + $this->assertEquals($originalTree[5]->subtree, $tree[5]->subtree); + $this->assertEquals($originalTree[8]->subtree, $tree[8]->subtree); + $this->assertEquals($originalTree[11]->subtree, $tree[11]->subtree); + $this->assertEquals($originalTree[13]->subtree, $tree[13]->subtree); + $this->assertEquals($originalTree[16]->subtree, $tree[16]->subtree); + $this->assertEquals($originalTree[19]->subtree, $tree[19]->subtree); + + // Leaves should not have any children added. + $this->assertEmpty($tree[2]->subtree[3]->subtree[4]->subtree); + $this->assertEmpty($tree[5]->subtree[6]->subtree[7]->subtree); + $this->assertEmpty($tree[8]->subtree[9]->subtree[10]->subtree); + $this->assertEmpty($tree[11]->subtree[12]->subtree); + $this->assertEmpty($tree[13]->subtree[14]->subtree[15]->subtree); + $this->assertEmpty($tree[16]->subtree[17]->subtree[18]->subtree); + $this->assertEmpty($tree[19]->subtree[20]->subtree[21]->subtree); + $this->assertEmpty($tree[19]->subtree[20]->subtree[22]->subtree); + + // Links 3 and 6 should have overview children, even though 6 is unrouted. + $this->assertArrayHasKey('test.example3.navigation_overview', $tree[2]->subtree[3]->subtree); + $this->assertArrayHasKey('test.example6.navigation_overview', $tree[5]->subtree[6]->subtree); + + // Link 9 is a child list page, so it should not have an overview child. + $this->assertArrayNotHasKey('test.example9.navigation_overview', $tree[8]->subtree[9]->subtree); + + // Link 14 and Link 17 are <nolink> and <button> routes, so they should not + // have overview children. + $this->assertArrayNotHasKey('test.example14.navigation_overview', $tree[13]->subtree[14]->subtree); + $this->assertArrayNotHasKey('test.example17.navigation_overview', $tree[16]->subtree[17]->subtree); + + // Link 20's child links are either inaccessible, disabled, or link to the + // same route as 20, so it should not have an overview child. + $this->assertArrayNotHasKey('test.example20.navigation_overview', $tree[19]->subtree[20]->subtree); + } + + /** + * Creates a mock tree. + * + * This mocks a tree with the following structure: + * - 1 + * - 2 + * - 3 + * - 4 + * - 5 + * - 6 (external) + * - 7 + * - 8 + * - 9 + * - 10 + * - 11 + * - 12 + * - 13 + * - 14 (nolink) + * - 15 + * - 16 + * - 17 (button) + * - 18 + * - 19 + * - 20 + * - 21 (disabled) + * - 22 (access denied) + * - 23 (links to same routed URL as 20) + * + * With link 9 linking to a page that contains a list of child menu links. + * + * @return \Drupal\Core\Menu\MenuLinkTreeElement[] + * The mock menu tree. + */ + protected function mockTree(): array { + $links = [ + 1 => MenuLinkMock::create([ + 'id' => 'test.example1', + 'route_name' => 'example1', + 'title' => 'foo', + 'parent' => '', + ]), + 2 => MenuLinkMock::create([ + 'id' => 'test.example2', + 'route_name' => 'example2', + 'title' => 'foo', + 'parent' => '', + ]), + 3 => MenuLinkMock::create([ + 'id' => 'test.example3', + 'route_name' => 'example3', + 'title' => 'baz', + 'parent' => 'test.example2', + ]), + 4 => MenuLinkMock::create([ + 'id' => 'test.example4', + 'route_name' => 'example4', + 'title' => 'qux', + 'parent' => 'test.example3', + ]), + 5 => MenuLinkMock::create([ + 'id' => 'test.example5', + 'route_name' => 'example5', + 'title' => 'title5', + 'parent' => '', + ]), + 6 => MenuLinkMock::create([ + 'id' => 'test.example6', + 'route_name' => '', + 'url' => 'https://www.drupal.org/', + 'title' => 'bar_bar', + 'parent' => 'test.example5', + ]), + 7 => MenuLinkMock::create([ + 'id' => 'test.example7', + 'route_name' => 'example7', + 'title' => 'title7', + 'parent' => 'test.example6', + ]), + 8 => MenuLinkMock::create([ + 'id' => 'test.example8', + 'route_name' => 'example8', + 'title' => 'title8', + 'parent' => '', + ]), + 9 => MenuLinkMock::create([ + 'id' => 'test.example9', + 'route_name' => 'child_list', + 'title' => 'title9', + 'parent' => 'test.example8', + ]), + 10 => MenuLinkMock::create([ + 'id' => 'test.example10', + 'route_name' => 'example9', + 'title' => 'title10', + 'parent' => 'test.example9', + ]), + 11 => MenuLinkMock::create([ + 'id' => 'test.example11', + 'route_name' => 'example11', + 'title' => 'title11', + 'parent' => '', + ]), + 12 => MenuLinkMock::create([ + 'id' => 'test.example12', + 'route_name' => 'example12', + 'title' => 'title12', + 'parent' => 'text.example11', + ]), + 13 => MenuLinkMock::create([ + 'id' => 'test.example13', + 'route_name' => 'example13', + 'title' => 'title13', + 'parent' => '', + ]), + 14 => MenuLinkMock::create([ + 'id' => 'test.example14', + 'route_name' => '<nolink>', + 'title' => 'title14', + 'parent' => 'text.example13', + ]), + 15 => MenuLinkMock::create([ + 'id' => 'test.example15', + 'route_name' => 'example15', + 'title' => 'title15', + 'parent' => 'text.example14', + ]), + 16 => MenuLinkMock::create([ + 'id' => 'test.example16', + 'route_name' => 'example16', + 'title' => 'title16', + 'parent' => '', + ]), + 17 => MenuLinkMock::create([ + 'id' => 'test.example17', + 'route_name' => '<button>', + 'title' => 'title17', + 'parent' => 'text.example16', + ]), + 18 => MenuLinkMock::create([ + 'id' => 'test.example18', + 'route_name' => 'example18', + 'title' => 'title18', + 'parent' => 'text.example17', + ]), + 19 => MenuLinkMock::create([ + 'id' => 'test.example19', + 'route_name' => 'example19', + 'title' => 'title19', + 'parent' => '', + ]), + 20 => MenuLinkMock::create([ + 'id' => 'test.example20', + 'route_name' => 'example20', + 'title' => 'title20', + 'parent' => 'test.example19', + ]), + 21 => MenuLinkMock::create([ + 'id' => 'test.example21', + 'route_name' => 'example21', + 'title' => 'title21', + 'parent' => 'test.example20', + 'enabled' => FALSE, + ]), + 22 => MenuLinkMock::create([ + 'id' => 'test.example22', + 'route_name' => 'no_access', + 'title' => 'title22', + 'parent' => 'test.example20', + ]), + 23 => MenuLinkMock::create([ + 'id' => 'test.example23', + 'route_name' => 'example20', + 'title' => 'title23', + 'parent' => 'test.example20', + ]), + ]; + $tree = []; + $tree[1] = new MenuLinkTreeElement($links[1], FALSE, 1, FALSE, []); + $tree[2] = new MenuLinkTreeElement($links[2], TRUE, 1, FALSE, [ + 3 => new MenuLinkTreeElement($links[3], TRUE, 2, FALSE, [ + 4 => new MenuLinkTreeElement($links[4], FALSE, 3, FALSE, []), + ]), + ]); + $tree[5] = new MenuLinkTreeElement($links[5], TRUE, 1, FALSE, [ + 6 => new MenuLinkTreeElement($links[6], TRUE, 2, FALSE, [ + 7 => new MenuLinkTreeElement($links[7], FALSE, 3, FALSE, []), + ]), + ]); + $tree[8] = new MenuLinkTreeElement($links[8], TRUE, 1, FALSE, [ + 9 => new MenuLinkTreeElement($links[9], TRUE, 2, FALSE, [ + 10 => new MenuLinkTreeElement($links[10], FALSE, 3, FALSE, []), + ]), + ]); + $tree[11] = new MenuLinkTreeElement($links[11], TRUE, 1, FALSE, [ + 12 => new MenuLinkTreeElement($links[12], FALSE, 2, FALSE, []), + ]); + $tree[13] = new MenuLinkTreeElement($links[13], TRUE, 1, FALSE, [ + 14 => new MenuLinkTreeElement($links[14], TRUE, 2, FALSE, [ + 15 => new MenuLinkTreeElement($links[15], FALSE, 3, FALSE, []), + ]), + ]); + $tree[16] = new MenuLinkTreeElement($links[16], TRUE, 1, FALSE, [ + 17 => new MenuLinkTreeElement($links[17], TRUE, 2, FALSE, [ + 18 => new MenuLinkTreeElement($links[18], FALSE, 3, FALSE, []), + ]), + ]); + $tree[19] = new MenuLinkTreeElement($links[19], TRUE, 1, FALSE, [ + 20 => new MenuLinkTreeElement($links[20], TRUE, 2, FALSE, [ + 21 => new MenuLinkTreeElement($links[21], FALSE, 3, FALSE, []), + 22 => new MenuLinkTreeElement($links[22], FALSE, 3, FALSE, []), + 23 => new MenuLinkTreeElement($links[23], FALSE, 3, FALSE, []), + ]), + ]); + $tree[19]->subtree[20]->subtree[22]->access = AccessResult::forbidden(); + + return $tree; + } + +} 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/node.module b/core/modules/node/node.module index 4ee3c48a00b..6841f24b96b 100644 --- a/core/modules/node/node.module +++ b/core/modules/node/node.module @@ -36,8 +36,12 @@ use Drupal\node\NodeTypeInterface; * @return array|false * A renderable array containing a list of linked node titles fetched from * $result, or FALSE if there are no rows in $result. + * + * @deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. There is no replacement. + * @see https://www.drupal.org/node/3531959 */ function node_title_list(StatementInterface $result, $title = NULL) { + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3531959', E_USER_DEPRECATED); $items = []; $num_rows = FALSE; $nids = []; @@ -121,8 +125,12 @@ function node_get_type_label(NodeInterface $node) { * * @return string * The node type description. + * + * @deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. Use $node_type->getDescription() instead. + * @see https://www.drupal.org/node/3531945 */ function node_type_get_description(NodeTypeInterface $node_type) { + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. Use $node_type->getDescription() instead. See https://www.drupal.org/node/3531945', E_USER_DEPRECATED); return $node_type->getDescription(); } @@ -325,10 +333,11 @@ function template_preprocess_node(&$variables): void { // $variables['content'] is more flexible and consistent. $submitted_configurable = $node->getFieldDefinition('created')->isDisplayConfigurable('view') || $node->getFieldDefinition('uid')->isDisplayConfigurable('view'); if (!$skip_custom_preprocessing || !$submitted_configurable) { - $variables['date'] = \Drupal::service('renderer')->render($variables['elements']['created']); - unset($variables['elements']['created']); - $variables['author_name'] = \Drupal::service('renderer')->render($variables['elements']['uid']); - unset($variables['elements']['uid']); + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); + $variables['date'] = !empty($variables['elements']['created']) ? $renderer->render($variables['elements']['created']) : ''; + $variables['author_name'] = !empty($variables['elements']['uid']) ? $renderer->render($variables['elements']['uid']) : ''; + unset($variables['elements']['created'], $variables['elements']['uid']); } if (isset($variables['elements']['title']) && (!$skip_custom_preprocessing || !$node->getFieldDefinition('title')->isDisplayConfigurable('view'))) { @@ -387,6 +396,11 @@ function node_form_system_themes_admin_form_submit($form, FormStateInterface $fo } /** + * @addtogroup node_access + * @{ + */ + +/** * Fetches an array of permission IDs granted to the given user ID. * * The implementation here provides only the universal "all" grant. A node @@ -658,6 +672,10 @@ function _node_access_rebuild_batch_finished($success, $results, $operations): v } /** + * @} End of "addtogroup node_access". + */ + +/** * Marks a node to be re-indexed by the node_search plugin. * * @param int $nid 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/Form/NodeForm.php b/core/modules/node/src/Form/NodeForm.php index d5afa396568..295e9ab78ce 100644 --- a/core/modules/node/src/Form/NodeForm.php +++ b/core/modules/node/src/Form/NodeForm.php @@ -10,6 +10,7 @@ use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TempStore\PrivateTempStoreFactory; +use Drupal\Core\Utility\Error; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -163,7 +164,7 @@ class NodeForm extends ContentEntityForm { $form['meta']['author'] = [ '#type' => 'item', '#title' => $this->t('Author'), - '#markup' => $node->getOwner()?->getAccountName(), + '#markup' => $node->getOwner()?->getDisplayName(), '#wrapper_attributes' => ['class' => ['entity-meta__author']], ]; @@ -278,21 +279,22 @@ class NodeForm extends ContentEntityForm { public function save(array $form, FormStateInterface $form_state) { $node = $this->entity; $insert = $node->isNew(); - $node->save(); - $node_link = $node->toLink($this->t('View'))->toString(); - $context = ['@type' => $node->getType(), '%title' => $node->label(), 'link' => $node_link]; - $t_args = ['@type' => node_get_type_label($node), '%title' => $node->toLink()->toString()]; - - if ($insert) { - $this->logger('content')->info('@type: added %title.', $context); - $this->messenger()->addStatus($this->t('@type %title has been created.', $t_args)); - } - else { - $this->logger('content')->info('@type: updated %title.', $context); - $this->messenger()->addStatus($this->t('@type %title has been updated.', $t_args)); - } - if ($node->id()) { + try { + $node->save(); + $node_link = $node->toLink($this->t('View'))->toString(); + $context = ['@type' => $node->getType(), '%title' => $node->label(), 'link' => $node_link]; + $t_args = ['@type' => node_get_type_label($node), '%title' => $node->access('view') ? $node->toLink()->toString() : $node->label()]; + + if ($insert) { + $this->logger('content')->info('@type: added %title.', $context); + $this->messenger()->addStatus($this->t('@type %title has been created.', $t_args)); + } + else { + $this->logger('content')->info('@type: updated %title.', $context); + $this->messenger()->addStatus($this->t('@type %title has been updated.', $t_args)); + } + $form_state->setValue('nid', $node->id()); $form_state->set('nid', $node->id()); if ($node->access('view')) { @@ -310,10 +312,15 @@ class NodeForm extends ContentEntityForm { $store = $this->tempStoreFactory->get('node_preview'); $store->delete($node->uuid()); } - else { + catch (\Exception $e) { // In the unlikely case something went wrong on save, the node will be - // rebuilt and node form redisplayed the same way as in preview. - $this->messenger()->addError($this->t('The post could not be saved.')); + // rebuilt and node form redisplayed. + $this->messenger()->addError($this->t('The content could not be saved. Contact the site administrator if the problem persists.')); + // It's likely that this exception is an EntityStorageException in which + // case we won't have the actual backtrace available. Attempt to get the + // previous exception if available to include the backtrace. + $e = $e->getPrevious() ?: $e; + \Drupal::logger('node')->error('%type saving node form: @message in %function (line %line of %file) @backtrace_string.', Error::decodeException($e)); $form_state->setRebuild(); } } 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/NodeHooks1.php b/core/modules/node/src/Hook/NodeHooks1.php index 8e25f2eb066..d2dbf545c3c 100644 --- a/core/modules/node/src/Hook/NodeHooks1.php +++ b/core/modules/node/src/Hook/NodeHooks1.php @@ -92,7 +92,7 @@ class NodeHooks1 { case 'entity.entity_view_display.node.default': case 'entity.entity_view_display.node.view_mode': $type = $route_match->getParameter('node_type'); - return '<p>' . $this->t('Content items can be displayed using different view modes: Teaser, Full content, Print, RSS, etc. <em>Teaser</em> is a short format that is typically used in lists of multiple content items. <em>Full content</em> is typically used when the content is displayed on its own page.') . '</p>' . '<p>' . $this->t('Here, you can define which fields are shown and hidden when %type content is displayed in each view mode, and define how the fields are displayed in each view mode.', ['%type' => $type->label()]) . '</p>'; + return '<p>' . $this->t('Content items can be displayed using different view modes: Teaser, Full content, Print, RSS, etc. <em>Teaser</em> is a short format that is typically used in lists of multiple content items. <em>Full content</em> is typically used when the content is displayed on its own page.') . '</p><p>' . $this->t('Here, you can define which fields are shown and hidden when %type content is displayed in each view mode, and define how the fields are displayed in each view mode.', ['%type' => $type->label()]) . '</p>'; case 'entity.node.version_history': return '<p>' . $this->t('Revisions allow you to track differences between multiple versions of your content, and revert to older versions.') . '</p>'; 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/NodeAccessControlHandlerInterface.php b/core/modules/node/src/NodeAccessControlHandlerInterface.php index 588391394ee..0d67cfb7bd6 100644 --- a/core/modules/node/src/NodeAccessControlHandlerInterface.php +++ b/core/modules/node/src/NodeAccessControlHandlerInterface.php @@ -30,6 +30,8 @@ interface NodeAccessControlHandlerInterface { /** * Creates the default node access grant entry on the grant storage. + * + * @see \Drupal\node\NodeGrantDatabaseStorageInterface::writeDefault() */ public function writeDefaultGrant(); diff --git a/core/modules/node/src/NodeGrantDatabaseStorage.php b/core/modules/node/src/NodeGrantDatabaseStorage.php index eea6cc10012..fbaab21a211 100644 --- a/core/modules/node/src/NodeGrantDatabaseStorage.php +++ b/core/modules/node/src/NodeGrantDatabaseStorage.php @@ -112,6 +112,13 @@ class NodeGrantDatabaseStorage implements NodeGrantDatabaseStorageInterface { if (count($grants) > 0) { $query->condition($grants); } + if ($query->execute()->fetchField()) { + $access_result = AccessResult::allowed(); + } + else { + $access_result = AccessResult::neutral(); + } + $access_result->addCacheContexts(['user.node_grants:' . $operation]); // Only the 'view' node grant can currently be cached; the others currently // don't have any cacheability metadata. Hopefully, we can add that in the @@ -119,20 +126,10 @@ class NodeGrantDatabaseStorage implements NodeGrantDatabaseStorageInterface { // cases. For now, this must remain marked as uncacheable, even when it is // theoretically cacheable, because we don't have the necessary metadata to // know it for a fact. - $set_cacheability = function (AccessResult $access_result) use ($operation) { - $access_result->addCacheContexts(['user.node_grants:' . $operation]); - if ($operation !== 'view') { - $access_result->setCacheMaxAge(0); - } - return $access_result; - }; - - if ($query->execute()->fetchField()) { - return $set_cacheability(AccessResult::allowed()); - } - else { - return $set_cacheability(AccessResult::neutral()); + if ($operation !== 'view') { + $access_result->setCacheMaxAge(0); } + return $access_result; } /** diff --git a/core/modules/node/src/NodeGrantDatabaseStorageInterface.php b/core/modules/node/src/NodeGrantDatabaseStorageInterface.php index cce74476563..d343a2f350b 100644 --- a/core/modules/node/src/NodeGrantDatabaseStorageInterface.php +++ b/core/modules/node/src/NodeGrantDatabaseStorageInterface.php @@ -42,8 +42,8 @@ interface NodeGrantDatabaseStorageInterface { * @param string $base_table * The base table of the query. * - * @return int - * Status of the access check. + * @return void + * No return value. */ public function alterQuery($query, array $tables, $operation, AccountInterface $account, $base_table); @@ -83,6 +83,12 @@ interface NodeGrantDatabaseStorageInterface { /** * Creates the default node access grant entry. + * + * The default node access grant is a special grant added to the node_access + * table when no modules implement hook_node_grants. It grants view access + * to any published node. + * + * @see self::access() */ public function writeDefault(); 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/Access.php b/core/modules/node/src/Plugin/views/filter/Access.php index 4934a2f2e63..4d579f687ce 100644 --- a/core/modules/node/src/Plugin/views/filter/Access.php +++ b/core/modules/node/src/Plugin/views/filter/Access.php @@ -36,7 +36,7 @@ class Access extends FilterPluginBase { */ public function query() { $account = $this->view->getUser(); - if (!$account->hasPermission('bypass node access')) { + if (!$account->hasPermission('bypass node access') && $this->moduleHandler->hasImplementations('node_grants')) { $table = $this->ensureMyTable(); $grants = $this->query->getConnection()->condition('OR'); foreach (node_access_grants('view', $account) as $realm => $gids) { 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/modules/node_test/src/Hook/NodeTestHooks.php b/core/modules/node/tests/modules/node_test/src/Hook/NodeTestHooks.php index 1887bc56ea6..201e781d196 100644 --- a/core/modules/node/tests/modules/node_test/src/Hook/NodeTestHooks.php +++ b/core/modules/node/tests/modules/node_test/src/Hook/NodeTestHooks.php @@ -32,14 +32,14 @@ class NodeTestHooks { ]; // Add content that should be displayed only in the RSS feed. $build['extra_feed_content'] = [ - '#markup' => '<p>' . 'Extra data that should appear only in the RSS feed for node ' . $node->id() . '.</p>', + '#markup' => '<p>Extra data that should appear only in the RSS feed for node ' . $node->id() . '.</p>', '#weight' => 10, ]; } if ($view_mode != 'rss') { // Add content that should NOT be displayed in the RSS feed. $build['extra_non_feed_content'] = [ - '#markup' => '<p>' . 'Extra data that should appear everywhere except the RSS feed for node ' . $node->id() . '.</p>', + '#markup' => '<p>Extra data that should appear everywhere except the RSS feed for node ' . $node->id() . '.</p>', ]; } } 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/NodeCreationTest.php b/core/modules/node/tests/src/Functional/NodeCreationTest.php index f0192966a1b..7e99e3ba2ec 100644 --- a/core/modules/node/tests/src/Functional/NodeCreationTest.php +++ b/core/modules/node/tests/src/Functional/NodeCreationTest.php @@ -183,8 +183,9 @@ class NodeCreationTest extends NodeTestBase { // Confirm that the node was created. $this->assertSession()->pageTextContains('Basic page ' . $edit['title[0][value]'] . ' has been created.'); - // Verify that the creation message contains a link to a node. - $this->assertSession()->elementExists('xpath', '//div[@data-drupal-messages]//a[contains(@href, "node/")]'); + // Verify that the creation message doesn't contain a link to a node since + // the user cannot view unpublished nodes. + $this->assertSession()->elementNotExists('xpath', '//div[@data-drupal-messages]//a[contains(@href, "node/")]'); } /** @@ -310,6 +311,21 @@ class NodeCreationTest extends NodeTestBase { } /** + * Tests exception handling when saving a node through the form. + */ + public function testNodeCreateExceptionHandling(): void { + $this->drupalGet('node/add/page'); + + $this->submitForm([ + 'title[0][value]' => 'testing_transaction_exception', + 'body[0][value]' => $this->randomMachineName(16), + ], 'Save'); + + $this->assertSession()->pageTextNotContains('The website encountered an unexpected error.'); + $this->assertSession()->pageTextContains('The content could not be saved. Contact the site administrator if the problem persists.'); + } + + /** * Gets the watchdog IDs of the records with the rollback exception message. * * @return int[] diff --git a/core/modules/node/tests/src/Functional/NodeEditFormTest.php b/core/modules/node/tests/src/Functional/NodeEditFormTest.php index dc47998c909..f661ae18ebb 100644 --- a/core/modules/node/tests/src/Functional/NodeEditFormTest.php +++ b/core/modules/node/tests/src/Functional/NodeEditFormTest.php @@ -251,10 +251,15 @@ class NodeEditFormTest extends NodeTestBase { $edit['body[0][value]'] = $this->randomMachineName(16); $this->submitForm($edit, 'Save'); + // Enable user_hooks_test to test the users display name is visible on the + // edit form. + \Drupal::service('module_installer')->install(['user_hooks_test']); + \Drupal::keyValue('user_hooks_test')->set('user_format_name_alter', TRUE); $node = $this->drupalGetNodeByTitle($edit['title[0][value]']); - $this->drupalGet("node/" . $node->id() . "/edit"); + $this->drupalGet($node->toUrl('edit-form')); $this->assertSession()->pageTextContains('Published'); $this->assertSession()->pageTextContains($this->container->get('date.formatter')->format($node->getChangedTime(), 'short')); + $this->assertSession()->responseContains('<em>' . $this->adminUser->id() . '</em>'); } /** 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/FunctionalJavascript/CollapsedSummariesTest.php b/core/modules/node/tests/src/FunctionalJavascript/CollapsedSummariesTest.php index 84f4a376af9..6333930b886 100644 --- a/core/modules/node/tests/src/FunctionalJavascript/CollapsedSummariesTest.php +++ b/core/modules/node/tests/src/FunctionalJavascript/CollapsedSummariesTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\node\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests that outlines of node meta values are displayed in summaries and tabs. - * - * @group node */ +#[Group('node')] class CollapsedSummariesTest extends WebDriverTestBase { /** diff --git a/core/modules/node/tests/src/FunctionalJavascript/ContextualLinksTest.php b/core/modules/node/tests/src/FunctionalJavascript/ContextualLinksTest.php index 4c499c01a86..876d9606776 100644 --- a/core/modules/node/tests/src/FunctionalJavascript/ContextualLinksTest.php +++ b/core/modules/node/tests/src/FunctionalJavascript/ContextualLinksTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\node\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\node\Entity\Node; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; +use PHPUnit\Framework\Attributes\Group; /** * Create a node with revisions and test contextual links. - * - * @group node */ +#[Group('node')] class ContextualLinksTest extends WebDriverTestBase { use ContextualLinkClickTrait; diff --git a/core/modules/node/tests/src/FunctionalJavascript/NodeDeleteConfirmTest.php b/core/modules/node/tests/src/FunctionalJavascript/NodeDeleteConfirmTest.php index 5eb912c577c..22a33d0c4b8 100644 --- a/core/modules/node/tests/src/FunctionalJavascript/NodeDeleteConfirmTest.php +++ b/core/modules/node/tests/src/FunctionalJavascript/NodeDeleteConfirmTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\node\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\views\Views; +use PHPUnit\Framework\Attributes\Group; /** * Tests JavaScript functionality specific to delete operations. - * - * @group node */ +#[Group('node')] class NodeDeleteConfirmTest extends WebDriverTestBase { /** diff --git a/core/modules/node/tests/src/FunctionalJavascript/NodePreviewLinkTest.php b/core/modules/node/tests/src/FunctionalJavascript/NodePreviewLinkTest.php index 33a8795a649..e1dbf426cec 100644 --- a/core/modules/node/tests/src/FunctionalJavascript/NodePreviewLinkTest.php +++ b/core/modules/node/tests/src/FunctionalJavascript/NodePreviewLinkTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\node\FunctionalJavascript; use Drupal\filter\Entity\FilterFormat; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript prevention of navigation away from node previews. - * - * @group node */ +#[Group('node')] class NodePreviewLinkTest extends WebDriverTestBase { /** diff --git a/core/modules/node/tests/src/FunctionalJavascript/SettingSummariesContentTypeTest.php b/core/modules/node/tests/src/FunctionalJavascript/SettingSummariesContentTypeTest.php index 99ba0722d00..309c14c37b3 100644 --- a/core/modules/node/tests/src/FunctionalJavascript/SettingSummariesContentTypeTest.php +++ b/core/modules/node/tests/src/FunctionalJavascript/SettingSummariesContentTypeTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\node\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript updating of summaries on content type form. - * - * @group node */ +#[Group('node')] class SettingSummariesContentTypeTest extends WebDriverTestBase { /** 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..174cdd0760f 100644 --- a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php +++ b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php @@ -8,13 +8,14 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * Tests the Options field allowed values UI functionality. - * - * @group options - * @group #slow */ +#[Group('options')] +#[Group('#slow')] class OptionsFieldUIAllowedValuesTest extends WebDriverTestBase { use FieldUiJSTestTrait; @@ -84,9 +85,8 @@ class OptionsFieldUIAllowedValuesTest extends WebDriverTestBase { /** * Tests option types allowed values. - * - * @dataProvider providerTestOptionsAllowedValues */ + #[DataProvider('providerTestOptionsAllowedValues')] public function testOptionsAllowedValues($option_type, $options, $is_string_option, string $add_row_method): void { $assert = $this->assertSession(); $this->fieldName = 'field_options_text'; @@ -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/options/tests/src/FunctionalJavascript/OptionsFieldUITest.php b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUITest.php index 25dcc1d2ebc..9d7a5c9f547 100644 --- a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUITest.php +++ b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUITest.php @@ -8,13 +8,13 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests the Options field UI functionality. - * - * @group options - * @group #slow */ +#[Group('options')] +#[Group('#slow')] class OptionsFieldUITest extends WebDriverTestBase { use FieldUiJSTestTrait; 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..ead88254823 100644 --- a/core/modules/package_manager/package_manager.services.yml +++ b/core/modules/package_manager/package_manager.services.yml @@ -14,9 +14,6 @@ services: Drupal\package_manager\ExecutableFinder: public: false decorates: 'PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface' - Drupal\package_manager\ProcessFactory: - public: false - decorates: 'PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface' Drupal\package_manager\TranslatableStringFactory: public: false decorates: 'PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface' @@ -47,6 +44,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. @@ -174,7 +172,7 @@ services: PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface: class: PhpTuf\ComposerStager\Internal\Process\Factory\ProcessFactory PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface: - class: PhpTuf\ComposerStager\Internal\Process\Service\ComposerProcessRunner + class: Drupal\package_manager\ComposerRunner PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface: class: PhpTuf\ComposerStager\Internal\Process\Service\OutputCallback PhpTuf\ComposerStager\API\Process\Service\ProcessInterface: @@ -201,3 +199,16 @@ services: PhpTuf\ComposerStager\Internal\Translation\Service\SymfonyTranslatorProxyInterface: class: PhpTuf\ComposerStager\Internal\Translation\Service\SymfonyTranslatorProxy public: false + + package_manager.direct_write_precondition.directories: + class: Drupal\package_manager\DirectWritePreconditionBypass + decorates: 'PhpTuf\ComposerStager\API\Precondition\Service\ActiveAndStagingDirsAreDifferentInterface' + arguments: + - '@.inner' + public: false + package_manager.direct_write_precondition.rsync: + class: Drupal\package_manager\DirectWritePreconditionBypass + decorates: 'PhpTuf\ComposerStager\API\Precondition\Service\RsyncIsAvailableInterface' + 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/ComposerRunner.php b/core/modules/package_manager/src/ComposerRunner.php new file mode 100644 index 00000000000..dbc029f6aa3 --- /dev/null +++ b/core/modules/package_manager/src/ComposerRunner.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\File\FileSystemInterface; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; +use PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; +use Symfony\Component\Process\PhpExecutableFinder; + +// cspell:ignore BINDIR + +/** + * Runs Composer through the current PHP interpreter. + * + * @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 ComposerRunner implements ComposerProcessRunnerInterface { + + public function __construct( + private readonly ExecutableFinderInterface $executableFinder, + private readonly ProcessFactoryInterface $processFactory, + private readonly FileSystemInterface $fileSystem, + private readonly ConfigFactoryInterface $configFactory, + ) {} + + /** + * {@inheritdoc} + */ + public function run(array $command, ?PathInterface $cwd = NULL, array $env = [], ?OutputCallbackInterface $callback = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + // Run Composer through the PHP interpreter so we don't have to rely on + // PHP being in the PATH. + array_unshift($command, (new PhpExecutableFinder())->find(), $this->executableFinder->find('composer')); + + $home = $this->fileSystem->getTempDirectory(); + $home .= '/package_manager_composer_home-'; + $home .= $this->configFactory->get('system.site')->get('uuid'); + $this->fileSystem->prepareDirectory($home, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); + + $process = $this->processFactory->create($command, $cwd, $env + ['COMPOSER_HOME' => $home]); + $process->setTimeout($timeout); + $process->mustRun($callback); + } + +} diff --git a/core/modules/package_manager/src/DirectWritePreconditionBypass.php b/core/modules/package_manager/src/DirectWritePreconditionBypass.php new file mode 100644 index 00000000000..ef346b0ae25 --- /dev/null +++ b/core/modules/package_manager/src/DirectWritePreconditionBypass.php @@ -0,0 +1,99 @@ +<?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\Precondition\Service\RsyncIsAvailableInterface; +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, RsyncIsAvailableInterface { + + use StringTranslationTrait; + + /** + * Whether or not the decorated precondition is being bypassed. + * + * @var bool + */ + private static bool $isBypassed = FALSE; + + public function __construct( + private readonly ActiveAndStagingDirsAreDifferentInterface|RsyncIsAvailableInterface $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/PathExcluder/SiteConfigurationExcluder.php b/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php index 9408e874c97..f8f23d0b86a 100644 --- a/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php +++ b/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php @@ -43,7 +43,7 @@ class SiteConfigurationExcluder implements EventSubscriberInterface { // Exclude site-specific settings files, which are always in the web root. // By default, Drupal core will always try to write-protect these files. - // @see system_requirements() + // @see \Drupal\system\Hook\SystemRequirementsHooks $settings_files = [ 'settings.php', 'settings.local.php', diff --git a/core/modules/package_manager/src/ProcessFactory.php b/core/modules/package_manager/src/ProcessFactory.php deleted file mode 100644 index 7e92977e7ff..00000000000 --- a/core/modules/package_manager/src/ProcessFactory.php +++ /dev/null @@ -1,97 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\package_manager; - -use Drupal\Core\Config\ConfigFactoryInterface; -use Drupal\Core\File\FileSystemInterface; -use PhpTuf\ComposerStager\API\Path\Value\PathInterface; -use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; -use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; - -// cspell:ignore BINDIR - -/** - * Defines a process factory which sets the COMPOSER_HOME environment variable. - * - * @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 ProcessFactory implements ProcessFactoryInterface { - - public function __construct( - private readonly FileSystemInterface $fileSystem, - private readonly ConfigFactoryInterface $configFactory, - private readonly ProcessFactoryInterface $decorated, - ) {} - - /** - * {@inheritdoc} - */ - public function create(array $command, ?PathInterface $cwd = NULL, array $env = []): ProcessInterface { - $process = $this->decorated->create($command, $cwd, $env); - - $env = $process->getEnv(); - if ($command && $this->isComposerCommand($command)) { - $env['COMPOSER_HOME'] = $this->getComposerHomePath(); - } - // Ensure that the current PHP installation is the first place that will be - // searched when looking for the PHP interpreter. - $env['PATH'] = static::getPhpDirectory() . ':' . getenv('PATH'); - $process->setEnv($env); - return $process; - } - - /** - * Returns the directory which contains the PHP interpreter. - * - * @return string - * The path of the directory containing the PHP interpreter. If the server - * is running in a command-line interface, the directory portion of - * PHP_BINARY is returned; otherwise, the compile-time PHP_BINDIR is. - * - * @see php_sapi_name() - * @see https://www.php.net/manual/en/reserved.constants.php - */ - private static function getPhpDirectory(): string { - if (PHP_SAPI === 'cli' || PHP_SAPI === 'cli-server') { - return dirname(PHP_BINARY); - } - return PHP_BINDIR; - } - - /** - * Returns the path to use as the COMPOSER_HOME environment variable. - * - * @return string - * The path which should be used as COMPOSER_HOME. - */ - private function getComposerHomePath(): string { - $home_path = $this->fileSystem->getTempDirectory(); - $home_path .= '/package_manager_composer_home-'; - $home_path .= $this->configFactory->get('system.site')->get('uuid'); - $this->fileSystem->prepareDirectory($home_path, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); - - return $home_path; - } - - /** - * Determines if a command is running Composer. - * - * @param string[] $command - * The command parts. - * - * @return bool - * TRUE if the command is running Composer, FALSE otherwise. - */ - private function isComposerCommand(array $command): bool { - $executable = $command[0]; - $executable_parts = explode('/', $executable); - $file = array_pop($executable_parts); - return str_starts_with($file, 'composer'); - } - -} 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/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php index 61e50a1f81a..7179299efd0 100644 --- a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php @@ -127,7 +127,7 @@ class TestSubscriber implements EventSubscriberInterface { $results = $this->state->get(self::getStateKey(get_class($event)), []); // Record that value of maintenance mode for each event. - $this->state->set(get_class($event) . '.' . 'system.maintenance_mode', $this->state->get('system.maintenance_mode')); + $this->state->set(get_class($event) . '.system.maintenance_mode', $this->state->get('system.maintenance_mode')); if ($results instanceof \Throwable) { throw $results; diff --git a/core/modules/package_manager/tests/src/Build/PackageInstallDirectWriteTest.php b/core/modules/package_manager/tests/src/Build/PackageInstallDirectWriteTest.php new file mode 100644 index 00000000000..307d17cdcdb --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/PackageInstallDirectWriteTest.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Build; + +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests installing packages with direct write mode enabled. + * + * @internal + */ +#[Group('package_manager')] +#[Group('#slow')] +class PackageInstallDirectWriteTest extends TemplateProjectTestBase { + + /** + * Tests installing packages in a stage directory. + */ + public function testPackageInstall(): void { + $this->createTestProject('RecommendedProject'); + $allow_direct_write = var_export(TRUE, 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', + ]); + $this->addRepository('alpha', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/build_test_projects/alpha/1.0.0')); + // Repository definitions affect the lock file hash, so update the hash to + // ensure that Composer won't complain that the lock file is out of sync. + $this->runComposer('composer update --lock', 'project'); + + // Use the API endpoint to create a stage and install alpha 1.0.0. + $this->makePackageManagerTestApiRequest( + '/package-manager-test-api', + [ + 'runtime' => [ + 'drupal/alpha:1.0.0', + ], + ] + ); + // Assert the module was installed. + $this->assertFileEquals( + __DIR__ . '/../../fixtures/build_test_projects/alpha/1.0.0/composer.json', + $this->getWebRoot() . '/modules/contrib/alpha/composer.json', + ); + $this->assertRequestedChangesWereLogged(['Install drupal/alpha 1.0.0']); + $this->assertAppliedChangesWereLogged(['Installed drupal/alpha 1.0.0']); + } + +} diff --git a/core/modules/package_manager/tests/src/Build/PackageInstallSubmoduleTest.php b/core/modules/package_manager/tests/src/Build/PackageInstallSubmoduleTest.php index 18c87b11956..4cc4405d4c8 100644 --- a/core/modules/package_manager/tests/src/Build/PackageInstallSubmoduleTest.php +++ b/core/modules/package_manager/tests/src/Build/PackageInstallSubmoduleTest.php @@ -4,13 +4,15 @@ declare(strict_types=1); namespace Drupal\Tests\package_manager\Build; +use PHPUnit\Framework\Attributes\Group; + /** * Tests installing packages in a stage directory. * - * @group package_manager - * @group #slow * @internal */ +#[Group('package_manager')] +#[Group('#slow')] class PackageInstallSubmoduleTest extends TemplateProjectTestBase { /** diff --git a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php index ec53f485dfb..283c6aefa2c 100644 --- a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php +++ b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php @@ -4,13 +4,15 @@ declare(strict_types=1); namespace Drupal\Tests\package_manager\Build; +use PHPUnit\Framework\Attributes\Group; + /** * Tests installing packages in a stage directory. * - * @group package_manager - * @group #slow * @internal */ +#[Group('package_manager')] +#[Group('#slow')] class PackageInstallTest extends TemplateProjectTestBase { /** @@ -18,7 +20,6 @@ class PackageInstallTest extends TemplateProjectTestBase { */ public function testPackageInstall(): void { $this->createTestProject('RecommendedProject'); - $this->setReleaseMetadata([ 'alpha' => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml', ]); diff --git a/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php index 2b9ef4aa894..da6fc5eae32 100644 --- a/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php +++ b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php @@ -5,14 +5,15 @@ declare(strict_types=1); namespace Drupal\Tests\package_manager\Build; use Drupal\package_manager_test_api\ControllerSandboxManager; +use PHPUnit\Framework\Attributes\Group; /** * Tests updating packages in a stage directory. * - * @group package_manager - * @group #slow * @internal */ +#[Group('package_manager')] +#[Group('#slow')] class PackageUpdateTest extends TemplateProjectTestBase { /** 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/Functional/ComposerRequirementTest.php b/core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php index 45046a8e2ad..15a7a2aab6b 100644 --- a/core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php +++ b/core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php @@ -47,7 +47,10 @@ class ComposerRequirementTest extends PackageManagerTestBase { $config->set('executables.composer', '/path/to/composer')->save(); $this->getSession()->reload(); $assert_session->statusCodeEquals(200); - $assert_session->pageTextContains('Composer was not found. The error message was: Failed to run process: The command "\'/path/to/composer\' \'--format=json\'" failed.'); + $assert_session->pageTextContains('Composer was not found. The error message was: '); + // Check for the part of the command string that is constant (the path to + // the PHP interpreter will vary). + $assert_session->pageTextContains("/php' '/path/to/composer' '--format=json'\" failed."); } } 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/ComposerPluginsValidatorTestBase.php b/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTestBase.php index d9b91b1a760..3100ce4bbf7 100644 --- a/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTestBase.php +++ b/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTestBase.php @@ -12,10 +12,9 @@ use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\ValidationResult; /** - * @group package_manager * @internal */ -class ComposerPluginsValidatorTestBase extends PackageManagerKernelTestBase { +abstract class ComposerPluginsValidatorTestBase extends PackageManagerKernelTestBase { /** * Tests composer plugins are validated during pre-create. 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..8c34ae00273 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php @@ -0,0 +1,272 @@ +<?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 PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @covers \Drupal\package_manager\EventSubscriber\DirectWriteSubscriber + * @covers \Drupal\package_manager\SandboxManagerBase::isDirectWrite + * @covers \Drupal\package_manager\DirectWritePreconditionBypass + * + * @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()); + } + + /** + * Tests that direct-write bypasses certain Composer Stager preconditions. + * + * @param class-string $service_class + * The class name of the precondition service. + * + * @testWith ["PhpTuf\\ComposerStager\\API\\Precondition\\Service\\ActiveAndStagingDirsAreDifferentInterface"] + * ["PhpTuf\\ComposerStager\\API\\Precondition\\Service\\RsyncIsAvailableInterface"] + */ + public function testPreconditionBypass(string $service_class): void { + // Set up conditions where the active and sandbox directories are the same, + // and the path to rsync isn't valid. + $path = $this->container->get(PathFactoryInterface::class) + ->create('/the/absolute/apex'); + $this->config('package_manager.settings') + ->set('executables.rsync', "C:\Not Rsync.exe") + ->save(); + + /** @var \PhpTuf\ComposerStager\API\Precondition\Service\PreconditionInterface $precondition */ + $precondition = $this->container->get($service_class); + // The precondition should be unfulfilled. + $this->assertFalse($precondition->isFulfilled($path, $path)); + + // Initializing a sandbox manager with direct-write support should bypass + // the precondition. + $this->setSetting('package_manager_allow_direct_write', TRUE); + $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class); + $sandbox_manager->create(); + $this->assertTrue($sandbox_manager->isDirectWrite()); + + // The precondition should be fulfilled, and clear that it's because we're + // in direct-write mode. + $this->assertTrue($precondition->isFulfilled($path, $path)); + $this->assertSame('This precondition has been skipped because it is not needed in direct-write mode.', (string) $precondition->getStatusMessage($path, $path)); + } + +} 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/ProcessFactoryTest.php b/core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php deleted file mode 100644 index db27b06efa8..00000000000 --- a/core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php +++ /dev/null @@ -1,36 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\package_manager\Kernel; - -use Drupal\package_manager\ProcessFactory; -use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; - -/** - * @coversDefaultClass \Drupal\package_manager\ProcessFactory - * @group auto_updates - * @internal - */ -class ProcessFactoryTest extends PackageManagerKernelTestBase { - - /** - * Tests that the process factory prepends the PHP directory to PATH. - */ - public function testPhpDirectoryPrependedToPath(): void { - $factory = $this->container->get(ProcessFactoryInterface::class); - $this->assertInstanceOf(ProcessFactory::class, $factory); - - // Ensure that the directory of the PHP interpreter can be found. - $reflector = new \ReflectionObject($factory); - $method = $reflector->getMethod('getPhpDirectory'); - $php_dir = $method->invoke(NULL); - $this->assertNotEmpty($php_dir); - - // The process factory should always put the PHP interpreter's directory - // at the beginning of the PATH environment variable. - $env = $factory->create(['whoami'])->getEnv(); - $this->assertStringStartsWith("$php_dir:", $env['PATH']); - } - -} 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/ServicesTest.php b/core/modules/package_manager/tests/src/Kernel/ServicesTest.php index a1db5a517c1..9e52f9b4075 100644 --- a/core/modules/package_manager/tests/src/Kernel/ServicesTest.php +++ b/core/modules/package_manager/tests/src/Kernel/ServicesTest.php @@ -9,14 +9,12 @@ use Drupal\package_manager\ExecutableFinder; use Drupal\package_manager\LoggingBeginner; use Drupal\package_manager\LoggingCommitter; use Drupal\package_manager\LoggingStager; -use Drupal\package_manager\ProcessFactory; use Drupal\package_manager\TranslatableStringFactory; use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; use PhpTuf\ComposerStager\API\Core\BeginnerInterface; use PhpTuf\ComposerStager\API\Core\CommitterInterface; use PhpTuf\ComposerStager\API\Core\StagerInterface; use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; -use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; /** @@ -38,11 +36,9 @@ class ServicesTest extends KernelTestBase { * Tests that Package Manager's public services can be instantiated. */ public function testPackageManagerServices(): void { - // Ensure that any overridden Composer Stager services were overridden - // correctly. + // Ensure that certain Composer Stager services are decorated correctly. $overrides = [ ExecutableFinderInterface::class => ExecutableFinder::class, - ProcessFactoryInterface::class => ProcessFactory::class, TranslatableFactoryInterface::class => TranslatableStringFactory::class, BeginnerInterface::class => LoggingBeginner::class, StagerInterface::class => LoggingStager::class, 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/ComposerRunnerTest.php b/core/modules/package_manager/tests/src/Unit/ComposerRunnerTest.php new file mode 100644 index 00000000000..91253d377d2 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/ComposerRunnerTest.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\Core\File\FileSystemInterface; +use Drupal\package_manager\ComposerRunner; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; +use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use Prophecy\Argument; + +// cspell:ignore BINDIR + +/** + * Tests Package Manager's Composer runner service. + * + * @internal + */ +#[CoversClass(ComposerRunner::class)] +#[Group('package_manager')] +class ComposerRunnerTest extends UnitTestCase { + + /** + * Tests that the Composer runner runs Composer through the PHP interpreter. + */ + public function testRunner(): void { + $executable_finder = $this->prophesize(ExecutableFinderInterface::class); + $executable_finder->find('composer') + ->willReturn('/mock/composer') + ->shouldBeCalled(); + + $process_factory = $this->prophesize(ProcessFactoryInterface::class); + $process_factory->create( + // Internally, ComposerRunner uses Symfony's PhpExecutableFinder to locate + // the PHP interpreter, which should resolve to PHP_BINARY a command-line + // test environment. + [PHP_BINARY, '/mock/composer', '--version'], + NULL, + Argument::withKey('COMPOSER_HOME'), + )->shouldBeCalled(); + + $file_system = $this->prophesize(FileSystemInterface::class); + $file_system->getTempDirectory()->shouldBeCalled(); + $file_system->prepareDirectory(Argument::cetera())->shouldBeCalled(); + + $runner = new ComposerRunner( + $executable_finder->reveal(), + $process_factory->reveal(), + $file_system->reveal(), + $this->getConfigFactoryStub([ + 'system.site' => [ + 'uuid' => 'testing', + ], + ]), + ); + $runner->run(['--version']); + } + +} 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/src/Plugin/Field/FieldType/PathItem.php b/core/modules/path/src/Plugin/Field/FieldType/PathItem.php index ed7adef588f..b393e8ac30c 100644 --- a/core/modules/path/src/Plugin/Field/FieldType/PathItem.php +++ b/core/modules/path/src/Plugin/Field/FieldType/PathItem.php @@ -82,12 +82,28 @@ class PathItem extends FieldItemBase { // If we have an alias, we need to create or update a path alias entity. if ($alias) { - if (!$update || !$pid) { - $path_alias = $path_alias_storage->create([ - 'path' => '/' . $entity->toUrl()->getInternalPath(), - 'alias' => $alias, - 'langcode' => $alias_langcode, - ]); + $properties = [ + 'path' => '/' . $entity->toUrl()->getInternalPath(), + 'alias' => $alias, + 'langcode' => $alias_langcode, + ]; + + if (!$pid) { + // Try to load it from storage before creating it. In some cases the + // path alias could be created before this function runs. For example, + // \Drupal\workspaces\EntityOperations::entityTranslationInsert will + // create a translation, and an associated path alias will be created + // with it. + $query = $path_alias_storage->getQuery()->accessCheck(FALSE); + foreach ($properties as $field => $value) { + $query->condition($field, $value); + } + $ids = $query->execute(); + $pid = $ids ? reset($ids) : $pid; + } + + if (!$pid) { + $path_alias = $path_alias_storage->create($properties); $path_alias->save(); $this->set('pid', $path_alias->id()); } diff --git a/core/modules/path/tests/modules/path_test_misc/path_test_misc.info.yml b/core/modules/path/tests/modules/path_test_misc/path_test_misc.info.yml new file mode 100644 index 00000000000..701a1028914 --- /dev/null +++ b/core/modules/path/tests/modules/path_test_misc/path_test_misc.info.yml @@ -0,0 +1,5 @@ +name: 'Path test miscellaneous utilities' +type: module +description: 'Utilities for path testing' +package: Testing +version: VERSION diff --git a/core/modules/path/tests/modules/path_test_misc/src/Hook/PathTestMiscHooks.php b/core/modules/path/tests/modules/path_test_misc/src/Hook/PathTestMiscHooks.php new file mode 100644 index 00000000000..5fc6662a09d --- /dev/null +++ b/core/modules/path/tests/modules/path_test_misc/src/Hook/PathTestMiscHooks.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path_test_misc\Hook; + +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\node\NodeInterface; + +/** + * Hook implementations for path_test_misc. + */ +class PathTestMiscHooks { + + /** + * Implements hook_ENTITY_TYPE_presave() for node entities. + * + * This is invoked from testAliasDuplicationPrevention. + */ + #[Hook('node_presave')] + public function nodePresave(NodeInterface $node): void { + if ($node->getTitle() !== 'path duplication test') { + return; + } + + // Update the title to be able to check that this code ran. + $node->setTitle('path duplication test ran'); + + // Create a path alias that has the same values as the one in + // PathItem::postSave. + $path = \Drupal::entityTypeManager()->getStorage('path_alias') + ->create([ + 'path' => '/node/1', + 'alias' => '/my-alias', + 'langcode' => 'en', + ]); + $path->save(); + } + +} diff --git a/core/modules/path/tests/src/Functional/PathNodeFormTest.php b/core/modules/path/tests/src/Functional/PathNodeFormTest.php index 001ba2f09ba..9763a145dc9 100644 --- a/core/modules/path/tests/src/Functional/PathNodeFormTest.php +++ b/core/modules/path/tests/src/Functional/PathNodeFormTest.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace Drupal\Tests\path\Functional; +use Drupal\node\Entity\Node; +use Drupal\node\NodeInterface; + /** * Tests the Path Node form UI. * @@ -14,7 +17,7 @@ class PathNodeFormTest extends PathTestBase { /** * {@inheritdoc} */ - protected static $modules = ['node', 'path']; + protected static $modules = ['node', 'path', 'path_test_misc']; /** * {@inheritdoc} @@ -59,4 +62,27 @@ class PathNodeFormTest extends PathTestBase { $assert_session->fieldNotExists('path[0][alias]'); } + /** + * Tests that duplicate path aliases don't get created. + */ + public function testAliasDuplicationPrevention(): void { + $this->drupalGet('node/add/page'); + $edit['title[0][value]'] = 'path duplication test'; + $edit['path[0][alias]'] = '/my-alias'; + $this->submitForm($edit, 'Save'); + + // Test that PathItem::postSave detects if a path alias exists + // before creating one. + $aliases = \Drupal::entityTypeManager() + ->getStorage('path_alias') + ->loadMultiple(); + static::assertCount(1, $aliases); + $node = Node::load(1); + static::assertInstanceOf(NodeInterface::class, $node); + + // This updated title gets set in PathTestMiscHooks::nodePresave. This + // is a way of ensuring that bit of test code runs. + static::assertEquals('path duplication test ran', $node->getTitle()); + } + } 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/pgsql/tests/src/Kernel/EntityQueryServiceDeprecation.php b/core/modules/pgsql/tests/src/Kernel/EntityQueryServiceDeprecationTest.php index db2c1b88ea3..043b5838d5a 100644 --- a/core/modules/pgsql/tests/src/Kernel/EntityQueryServiceDeprecation.php +++ b/core/modules/pgsql/tests/src/Kernel/EntityQueryServiceDeprecationTest.php @@ -8,17 +8,20 @@ use Drupal\Core\Entity\Query\Sql\QueryFactory as BaseQueryFactory; use Drupal\Core\Entity\Query\Sql\pgsql\QueryFactory as DeprecatedQueryFactory; use Drupal\KernelTests\KernelTestBase; use Drupal\pgsql\EntityQuery\QueryFactory; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * Tests the move of the 'pgsql.entity.query.sql' service. */ -class EntityQueryServiceDeprecation extends KernelTestBase { +#[Group('Database')] +#[Group('pgsql')] +class EntityQueryServiceDeprecationTest extends KernelTestBase { /** * Tests that the core provided service is deprecated. - * - * @group legacy */ + #[IgnoreDeprecations] public function testPostgresServiceDeprecated(): void { $running_driver = $this->container->get('database')->driver(); if ($running_driver === 'pgsql') { 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/responsive_image/tests/src/FunctionalJavascript/ResponsiveImageFieldUiTest.php b/core/modules/responsive_image/tests/src/FunctionalJavascript/ResponsiveImageFieldUiTest.php index 0e85555f524..2cc616e8e62 100644 --- a/core/modules/responsive_image/tests/src/FunctionalJavascript/ResponsiveImageFieldUiTest.php +++ b/core/modules/responsive_image/tests/src/FunctionalJavascript/ResponsiveImageFieldUiTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\responsive_image\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\responsive_image\Entity\ResponsiveImageStyle; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests the responsive image field UI. - * - * @group responsive_image */ +#[Group('responsive_image')] class ResponsiveImageFieldUiTest extends WebDriverTestBase { use FieldUiJSTestTrait; diff --git a/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php index b296446affb..07bee95122c 100644 --- a/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php +++ b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php @@ -54,7 +54,7 @@ trait CookieResourceTestTrait { /** * {@inheritdoc} */ - protected function initAuthentication() { + protected function initAuthentication(): void { $user_login_url = Url::fromRoute('user.login.http') ->setRouteParameter('_format', static::$format); @@ -93,7 +93,7 @@ trait CookieResourceTestTrait { /** * {@inheritdoc} */ - protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response) { + protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response): void { // Requests needing cookie authentication but missing it results in a 403 // response. The cookie authentication mechanism sets no response message. // Hence, effectively, this is just the 403 response that one gets as the @@ -121,7 +121,7 @@ trait CookieResourceTestTrait { /** * {@inheritdoc} */ - protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) { + protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options): void { // X-CSRF-Token request header is unnecessary for safe and side effect-free // HTTP methods. No need for additional assertions. // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html 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/search/src/Plugin/ConfigurableSearchPluginBase.php b/core/modules/search/src/Plugin/ConfigurableSearchPluginBase.php index 7ad95f16823..acc2e49a5cb 100644 --- a/core/modules/search/src/Plugin/ConfigurableSearchPluginBase.php +++ b/core/modules/search/src/Plugin/ConfigurableSearchPluginBase.php @@ -2,14 +2,16 @@ namespace Drupal\search\Plugin; -use Drupal\Component\Utility\NestedArray; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Plugin\ConfigurableTrait; /** * Provides a base implementation for a configurable Search plugin. */ abstract class ConfigurableSearchPluginBase extends SearchPluginBase implements ConfigurableSearchPluginInterface { + use ConfigurableTrait; + /** * The unique ID for the search page using this plugin. * @@ -29,27 +31,6 @@ abstract class ConfigurableSearchPluginBase extends SearchPluginBase implements /** * {@inheritdoc} */ - public function defaultConfiguration() { - return []; - } - - /** - * {@inheritdoc} - */ - public function getConfiguration() { - return $this->configuration; - } - - /** - * {@inheritdoc} - */ - public function setConfiguration(array $configuration) { - $this->configuration = NestedArray::mergeDeep($this->defaultConfiguration(), $configuration); - } - - /** - * {@inheritdoc} - */ public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { } diff --git a/core/modules/search/tests/modules/search_embedded_form/search_embedded_form.module b/core/modules/search/tests/modules/search_embedded_form/search_embedded_form.module deleted file mode 100644 index d21af735eca..00000000000 --- a/core/modules/search/tests/modules/search_embedded_form/search_embedded_form.module +++ /dev/null @@ -1,20 +0,0 @@ -<?php - -/** - * @file - * Test module implementing a form that can be embedded in search results. - * - * A sample use of an embedded form is an e-commerce site where each search - * result may include an embedded form with buttons like "Add to cart" for each - * individual product (node) listed in the search results. - */ - -declare(strict_types=1); - -/** - * Adds the test form to search results. - */ -function search_embedded_form_preprocess_search_result(&$variables): void { - $form = \Drupal::formBuilder()->getForm('Drupal\search_embedded_form\Form\SearchEmbeddedForm'); - $variables['snippet'] = array_merge($variables['snippet'], $form); -} diff --git a/core/modules/search/tests/modules/search_embedded_form/src/Hook/SearchEmbeddedFormThemeHooks.php b/core/modules/search/tests/modules/search_embedded_form/src/Hook/SearchEmbeddedFormThemeHooks.php new file mode 100644 index 00000000000..89036be010b --- /dev/null +++ b/core/modules/search/tests/modules/search_embedded_form/src/Hook/SearchEmbeddedFormThemeHooks.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\search_embedded_form\Hook; + +use Drupal\Core\Form\FormBuilderInterface; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for search_embedded_form module. + * + * A sample use of an embedded form is an e-commerce site where each search + * result may include an embedded form with buttons like "Add to cart" for each + * individual product (node) listed in the search results. + */ +class SearchEmbeddedFormThemeHooks { + + public function __construct( + protected FormBuilderInterface $formBuilder, + ) {} + + /** + * Implements hook_preprocess_HOOK(). + */ + #[Hook('preprocess_search_result')] + public function preprocessSearchResult(&$variables): void { + $form = $this->formBuilder->getForm('Drupal\search_embedded_form\Form\SearchEmbeddedForm'); + $variables['snippet'] = array_merge($variables['snippet'], $form); + } + +} diff --git a/core/modules/settings_tray/css/settings_tray.theme.css b/core/modules/settings_tray/css/settings_tray.theme.css index 9363baad5ec..28070a0f727 100644 --- a/core/modules/settings_tray/css/settings_tray.theme.css +++ b/core/modules/settings_tray/css/settings_tray.theme.css @@ -61,10 +61,10 @@ /* Style the editables while in edit mode. */ .dialog-off-canvas-main-canvas.js-settings-tray-edit-mode .settings-tray-editable { - outline: 1px dashed rgba(0, 0, 0, 0.5); - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.7); + outline: 1px dashed rgb(0, 0, 0, 0.5); + box-shadow: 0 0 0 1px rgb(255, 255, 255, 0.7); } .dialog-off-canvas-main-canvas.js-settings-tray-edit-mode .settings-tray-editable:hover, .dialog-off-canvas-main-canvas.js-settings-tray-edit-mode .settings-tray-editable.settings-tray-active-editable { - background-color: rgba(0, 0, 0, 0.2); + background-color: rgb(0, 0, 0, 0.2); } diff --git a/core/modules/settings_tray/css/settings_tray.toolbar.css b/core/modules/settings_tray/css/settings_tray.toolbar.css index 61b1fa26828..5655111787c 100644 --- a/core/modules/settings_tray/css/settings_tray.toolbar.css +++ b/core/modules/settings_tray/css/settings_tray.toolbar.css @@ -23,7 +23,7 @@ color: #eee; background-color: #0066a1; background-image: linear-gradient(to bottom, #0066a1, #005b98); - text-shadow: 0 1px hsla(0, 0%, 0%, 0.5); + text-shadow: 0 1px hsl(0, 0%, 0%, 0.5); font-weight: 700; -webkit-font-smoothing: antialiased; } @@ -31,7 +31,7 @@ color: #fff; background-color: #0a7bc1; background-image: linear-gradient(to bottom, #0a7bc1, #0a6eb4); - text-shadow: 0 1px hsla(0, 0%, 0%, 0.5); + text-shadow: 0 1px hsl(0, 0%, 0%, 0.5); font-weight: 700; -webkit-font-smoothing: antialiased; } diff --git a/core/modules/settings_tray/tests/src/FunctionalJavascript/ConfigAccessTest.php b/core/modules/settings_tray/tests/src/FunctionalJavascript/ConfigAccessTest.php index 023d3f3a367..2390d415f1d 100644 --- a/core/modules/settings_tray/tests/src/FunctionalJavascript/ConfigAccessTest.php +++ b/core/modules/settings_tray/tests/src/FunctionalJavascript/ConfigAccessTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\settings_tray\FunctionalJavascript; use Drupal\menu_link_content\Entity\MenuLinkContent; use Drupal\user\Entity\Role; +use PHPUnit\Framework\Attributes\Group; /** * Tests handling of configuration overrides. - * - * @group settings_tray */ +#[Group('settings_tray')] class ConfigAccessTest extends SettingsTrayTestBase { /** diff --git a/core/modules/settings_tray/tests/src/FunctionalJavascript/OverriddenConfigurationTest.php b/core/modules/settings_tray/tests/src/FunctionalJavascript/OverriddenConfigurationTest.php index 085b9d6def1..c40b17211bb 100644 --- a/core/modules/settings_tray/tests/src/FunctionalJavascript/OverriddenConfigurationTest.php +++ b/core/modules/settings_tray/tests/src/FunctionalJavascript/OverriddenConfigurationTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\settings_tray\FunctionalJavascript; use Drupal\block\Entity\Block; use Drupal\menu_link_content\Entity\MenuLinkContent; use Drupal\user\Entity\Role; +use PHPUnit\Framework\Attributes\Group; /** * Tests handling of configuration overrides. - * - * @group settings_tray */ +#[Group('settings_tray')] class OverriddenConfigurationTest extends SettingsTrayTestBase { /** diff --git a/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayBlockFormTest.php b/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayBlockFormTest.php index 26c24424f9d..518c8581edf 100644 --- a/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayBlockFormTest.php +++ b/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayBlockFormTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\settings_tray\FunctionalJavascript; use Drupal\settings_tray_test\Plugin\Block\SettingsTrayFormAnnotationIsClassBlock; use Drupal\settings_tray_test\Plugin\Block\SettingsTrayFormAnnotationNoneBlock; use Drupal\user\Entity\Role; +use PHPUnit\Framework\Attributes\Group; /** * Testing opening and saving block forms in the off-canvas dialog. - * - * @group settings_tray */ +#[Group('settings_tray')] class SettingsTrayBlockFormTest extends SettingsTrayTestBase { /** diff --git a/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayTestBase.php b/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayTestBase.php index 15b14a771e8..4b7ba9ca698 100644 --- a/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayTestBase.php +++ b/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayTestBase.php @@ -11,7 +11,7 @@ use Drupal\Tests\system\FunctionalJavascript\OffCanvasTestBase; /** * Base class for Settings Tray tests. */ -class SettingsTrayTestBase extends OffCanvasTestBase { +abstract class SettingsTrayTestBase extends OffCanvasTestBase { use ContextualLinkClickTrait; diff --git a/core/modules/shortcut/css/shortcut.theme.css b/core/modules/shortcut/css/shortcut.theme.css index d7a5fa146f7..d98e6f1864b 100644 --- a/core/modules/shortcut/css/shortcut.theme.css +++ b/core/modules/shortcut/css/shortcut.theme.css @@ -43,7 +43,7 @@ color: #fff; border-radius: 5px; background: #000; - background: rgba(0, 0, 0, 0.5); + background: rgb(0, 0, 0, 0.5); -webkit-backface-visibility: hidden; backface-visibility: hidden; } 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/Schema.php b/core/modules/sqlite/src/Driver/Database/sqlite/Schema.php index cbcdffcd2f9..fa526a9f5d0 100644 --- a/core/modules/sqlite/src/Driver/Database/sqlite/Schema.php +++ b/core/modules/sqlite/src/Driver/Database/sqlite/Schema.php @@ -249,6 +249,11 @@ class Schema extends DatabaseSchema { 'blob:big' => 'BLOB', 'blob:normal' => 'BLOB', + + // Only the SQLite driver has this field map to due to a fatal error + // error caused by this driver's schema on table introspection. + // @todo Add support to all drivers in https://drupal.org/i/3343634 + 'json:normal' => 'JSON', ]; return $map; } 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/config/schema/system.schema.yml b/core/modules/system/config/schema/system.schema.yml index 88f34652b98..cfd2f82952c 100644 --- a/core/modules/system/config/schema/system.schema.yml +++ b/core/modules/system/config/schema/system.schema.yml @@ -69,14 +69,14 @@ system.cron: type: integer label: 'Requirements warning period' constraints: - # @see system_requirements() + # @see \Drupal\system\Hook\SystemRequirementsHooks Range: min: 60 requirements_error: type: integer label: 'Requirements error period' constraints: - # @see system_requirements() + # @see \Drupal\system\Hook\SystemRequirementsHooks Range: min: 300 logging: diff --git a/core/modules/system/css/components/item-list.module.css b/core/modules/system/css/components/item-list.module.css deleted file mode 100644 index 2d23ee5bd33..00000000000 --- a/core/modules/system/css/components/item-list.module.css +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @file - * Styles for item list. - */ - -.item-list__comma-list, -.item-list__comma-list li { - display: inline; -} -.item-list__comma-list { - margin: 0; - padding: 0; -} -.item-list__comma-list li::after { - content: ", "; -} -.item-list__comma-list li:last-child::after { - content: ""; -} 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/css/components/reset-appearance.module.css b/core/modules/system/css/components/reset-appearance.module.css deleted file mode 100644 index 59741a85ce8..00000000000 --- a/core/modules/system/css/components/reset-appearance.module.css +++ /dev/null @@ -1,14 +0,0 @@ -/* - * @file - * Utility class to remove browser styles, especially for button. - */ - -.reset-appearance { - margin: 0; - padding: 0; - border: 0 none; - background: transparent; - line-height: inherit; - -webkit-appearance: none; - appearance: none; -} diff --git a/core/modules/system/css/components/system-status-report-counters.css b/core/modules/system/css/components/system-status-report-counters.css index 7040c257a0f..54ffabe5fc1 100644 --- a/core/modules/system/css/components/system-status-report-counters.css +++ b/core/modules/system/css/components/system-status-report-counters.css @@ -9,7 +9,7 @@ padding: 0.5em 0; text-align: center; white-space: nowrap; - background-color: rgba(0, 0, 0, 0.063); + background-color: rgb(0, 0, 0, 0.063); } @media screen and (min-width: 60em) { diff --git a/core/modules/system/src/Controller/DbUpdateController.php b/core/modules/system/src/Controller/DbUpdateController.php index 131b6a075d5..0bd9e737f59 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. @@ -704,16 +705,20 @@ class DbUpdateController extends ControllerBase { 'title' => $this->t('Front page'), 'url' => Url::fromRoute('<front>')->setOption('base_url', $base_url), ]; - if ($this->account->hasPermission('access administration pages')) { + + $admin_url = Url::fromRoute('system.admin')->setOption('base_url', $base_url); + if ($admin_url->access($this->account)) { $links['admin-pages'] = [ 'title' => $this->t('Administration pages'), - 'url' => Url::fromRoute('system.admin')->setOption('base_url', $base_url), + 'url' => $admin_url, ]; } - if ($this->account->hasPermission('administer site configuration')) { + + $status_report_url = Url::fromRoute('system.status')->setOption('base_url', $base_url); + if ($status_report_url->access($this->account)) { $links['status-report'] = [ 'title' => $this->t('Status report'), - 'url' => Url::fromRoute('system.status')->setOption('base_url', $base_url), + 'url' => $status_report_url, ]; } return $links; diff --git a/core/modules/system/src/Controller/SystemController.php b/core/modules/system/src/Controller/SystemController.php index b340555c628..fc253ac2f7c 100644 --- a/core/modules/system/src/Controller/SystemController.php +++ b/core/modules/system/src/Controller/SystemController.php @@ -280,8 +280,6 @@ class SystemController extends ControllerBase { continue; } - // @todo Add logic for not displaying hidden modules in - // https://drupal.org/node/3117829. $module_name = $modules[$dependency]->info['name']; $theme->module_dependencies_list[$dependency] = $modules[$dependency]->status ? $this->t('@module_name', ['@module_name' => $module_name]) : $this->t('@module_name (<span class="admin-disabled">disabled</span>)', ['@module_name' => $module_name]); 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/EventSubscriber/SecurityFileUploadEventSubscriber.php b/core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php index 737b9a7a53e..c9722a5e4e1 100644 --- a/core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php +++ b/core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php @@ -52,22 +52,17 @@ class SecurityFileUploadEventSubscriber implements EventSubscriberInterface { // http://php.net/manual/security.filesystem.nullbytes.php $filename = str_replace(chr(0), '', $filename); + if ($filename !== $event->getFilename()) { + $event->setFilename($filename)->setSecurityRename(); + } + // Split up the filename by periods. The first part becomes the basename, // the last part the final extension. $filename_parts = explode('.', $filename); // Remove file basename. $filename = array_shift($filename_parts); - // Remove final extension. + // Remove final extension. In the case of dot filenames this will be empty. $final_extension = (string) array_pop($filename_parts); - // Check if we're dealing with a dot file that is also an insecure extension - // e.g. .htaccess. In this scenario there is only one 'part' and the - // extension becomes the filename. We use the original filename from the - // event rather than the trimmed version above. - $insecure_uploads = $this->configFactory->get('system.file')->get('allow_insecure_uploads'); - if (!$insecure_uploads && $final_extension === '' && str_contains($event->getFilename(), '.') && in_array(strtolower($filename), FileSystemInterface::INSECURE_EXTENSIONS, TRUE)) { - $final_extension = $filename; - $filename = ''; - } $extensions = $event->getAllowedExtensions(); if (!empty($extensions) && !in_array(strtolower($final_extension), $extensions, TRUE)) { @@ -81,7 +76,7 @@ class SecurityFileUploadEventSubscriber implements EventSubscriberInterface { return; } - if (!$insecure_uploads && in_array(strtolower($final_extension), FileSystemInterface::INSECURE_EXTENSIONS, TRUE)) { + if (!$this->configFactory->get('system.file')->get('allow_insecure_uploads') && in_array(strtolower($final_extension), FileSystemInterface::INSECURE_EXTENSIONS, TRUE)) { if (empty($extensions) || in_array('txt', $extensions, TRUE)) { // Add .txt to potentially executable files prior to munging to help // prevent exploits. This results in a filenames like filename.php being diff --git a/core/modules/system/src/Form/ModulesListForm.php b/core/modules/system/src/Form/ModulesListForm.php index 90dc9ead38b..13297f3578c 100644 --- a/core/modules/system/src/Form/ModulesListForm.php +++ b/core/modules/system/src/Form/ModulesListForm.php @@ -375,8 +375,6 @@ class ModulesListForm extends FormBase { // If this module requires other modules, add them to the array. /** @var \Drupal\Core\Extension\Dependency $dependency_object */ foreach ($module->requires as $dependency => $dependency_object) { - // @todo Add logic for not displaying hidden modules in - // https://drupal.org/node/3117829. if ($incompatible = $this->checkDependencyMessage($modules, $dependency, $dependency_object)) { $row['#requires'][$dependency] = $incompatible; $row['enable']['#disabled'] = TRUE; diff --git a/core/modules/system/src/Form/ModulesUninstallForm.php b/core/modules/system/src/Form/ModulesUninstallForm.php index c999f37fe23..97f76abe40e 100644 --- a/core/modules/system/src/Form/ModulesUninstallForm.php +++ b/core/modules/system/src/Form/ModulesUninstallForm.php @@ -106,7 +106,8 @@ class ModulesUninstallForm extends FormBase { include_once DRUPAL_ROOT . '/core/includes/install.inc'; // Get a list of all available modules that can be uninstalled. - $uninstallable = array_filter($this->moduleExtensionList->getList(), function ($module) { + $modules = $this->moduleExtensionList->getList(); + $uninstallable = array_filter($modules, function ($module) { return empty($module->info['required']) && $module->status; }); @@ -199,7 +200,13 @@ class ModulesUninstallForm extends FormBase { // we can allow this module to be uninstalled. foreach (array_keys($module->required_by) as $dependent) { if ($this->updateRegistry->getInstalledVersion($dependent) !== $this->updateRegistry::SCHEMA_UNINSTALLED) { - $form['modules'][$module->getName()]['#required_by'][] = $dependent; + $module_name = $modules[$dependent]->info['name']; + if ($dependent != strtolower(str_replace(' ', '_', $module_name))) { + $form['modules'][$module->getName()]['#required_by'][] = $module_name . " (" . $dependent . ")"; + } + else { + $form['modules'][$module->getName()]['#required_by'][] = $module_name; + } $form['uninstall'][$module->getName()]['#disabled'] = TRUE; } } diff --git a/core/modules/system/src/Hook/PageAttachmentsHook.php b/core/modules/system/src/Hook/PageAttachmentsHook.php index fb6335f90c3..3f271571ede 100644 --- a/core/modules/system/src/Hook/PageAttachmentsHook.php +++ b/core/modules/system/src/Hook/PageAttachmentsHook.php @@ -19,7 +19,7 @@ final class PageAttachmentsHook { /** * Implements hook_page_attachments(). * - * @see template_preprocess_maintenance_page() + * @see \Drupal\Core\Theme\ThemePreprocess::preprocessMaintenancePage() * @see \Drupal\Core\EventSubscriber\ActiveLinkResponseFilter */ #[Hook('page_attachments')] diff --git a/core/modules/system/src/Hook/SystemHooks.php b/core/modules/system/src/Hook/SystemHooks.php index ae3e8d71074..e1be05077e2 100644 --- a/core/modules/system/src/Hook/SystemHooks.php +++ b/core/modules/system/src/Hook/SystemHooks.php @@ -159,10 +159,6 @@ class SystemHooks { } /** - * @} End of "defgroup authorize". - */ - - /** * Implements hook_updater_info(). */ #[Hook('updater_info')] @@ -276,7 +272,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. @@ -339,7 +339,7 @@ class SystemHooks { \Drupal::service('file.htaccess_writer')->ensure(); if (\Drupal::config('system.advisories')->get('enabled')) { // Fetch the security advisories so that they will be pre-fetched during - // _system_advisories_requirements() and system_page_top(). + // systemAdvisoriesRequirements() and system_page_top(). /** @var \Drupal\system\SecurityAdvisories\SecurityAdvisoriesFetcher $fetcher */ $fetcher = \Drupal::service('system.sa_fetcher'); $fetcher->getSecurityAdvisories(); diff --git a/core/modules/system/src/Hook/SystemRequirementsHooks.php b/core/modules/system/src/Hook/SystemRequirementsHooks.php new file mode 100644 index 00000000000..49d318eb9bf --- /dev/null +++ b/core/modules/system/src/Hook/SystemRequirementsHooks.php @@ -0,0 +1,29 @@ +<?php + +namespace Drupal\system\Hook; + +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\system\Install\Requirements\SystemRequirements; + +/** + * Requirements hook implementations for system module. + */ +class SystemRequirementsHooks { + + /** + * Implements hook_update_requirements(). + */ + #[Hook('update_requirements')] + public function updateRequirements(): array { + return SystemRequirements::checkRequirements('update'); + } + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtimeRequirements(): array { + return SystemRequirements::checkRequirements('runtime'); + } + +} diff --git a/core/modules/system/src/Install/Requirements/SystemRequirements.php b/core/modules/system/src/Install/Requirements/SystemRequirements.php new file mode 100644 index 00000000000..ee6a1d7a802 --- /dev/null +++ b/core/modules/system/src/Install/Requirements/SystemRequirements.php @@ -0,0 +1,1665 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\system\Install\Requirements; + +use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Component\FileSystem\FileSystem as FileSystemComponent; +use Drupal\Component\Utility\Bytes; +use Drupal\Component\Utility\Environment; +use Drupal\Component\Utility\OpCodeCache; +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\Render\Markup; +use Drupal\Core\Site\Settings; +use Drupal\Core\StreamWrapper\PrivateStream; +use Drupal\Core\StreamWrapper\PublicStream; +use Drupal\Core\StringTranslation\ByteSizeMarkup; +use Drupal\Core\StringTranslation\PluralTranslatableMarkup; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Url; +use Drupal\Core\Utility\Error; +use Drupal\Core\Utility\PhpRequirements; +use Psr\Http\Client\ClientExceptionInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Install time requirements for the system module. + */ +class SystemRequirements implements InstallRequirementsInterface { + + /** + * {@inheritdoc} + */ + public static function getRequirements(): array { + return self::checkRequirements('install'); + } + + /** + * Check requirements for a given phase. + * + * @param string $phase + * The phase in which requirements are checked, as documented in + * hook_runtime_requirements() and hook_update_requirements(). + * + * @return array + * An associative array of requirements, as documented in + * hook_runtime_requirements() and hook_update_requirements(). + */ + public static function checkRequirements(string $phase): array { + global $install_state; + + // Get the current default PHP requirements for this version of Drupal. + $minimum_supported_php = PhpRequirements::getMinimumSupportedPhp(); + + // Reset the extension lists. + /** @var \Drupal\Core\Extension\ModuleExtensionList $module_extension_list */ + $module_extension_list = \Drupal::service('extension.list.module'); + $module_extension_list->reset(); + /** @var \Drupal\Core\Extension\ThemeExtensionList $theme_extension_list */ + $theme_extension_list = \Drupal::service('extension.list.theme'); + $theme_extension_list->reset(); + $requirements = []; + + // Report Drupal version + if ($phase == 'runtime') { + $requirements['drupal'] = [ + 'title' => t('Drupal'), + 'value' => \Drupal::VERSION, + 'severity' => RequirementSeverity::Info, + 'weight' => -10, + ]; + + // Display the currently active installation profile, if the site + // is not running the default installation profile. + $profile = \Drupal::installProfile(); + if ($profile != 'standard' && !empty($profile)) { + $info = $module_extension_list->getExtensionInfo($profile); + $requirements['install_profile'] = [ + 'title' => t('Installation profile'), + 'value' => t('%profile_name (%profile%version)', [ + '%profile_name' => $info['name'], + '%profile' => $profile, + '%version' => !empty($info['version']) ? '-' . $info['version'] : '', + ]), + 'severity' => RequirementSeverity::Info, + 'weight' => -9, + ]; + } + + // Gather all obsolete and experimental modules being enabled. + $obsolete_extensions = []; + $deprecated_modules = []; + $experimental_modules = []; + $enabled_modules = \Drupal::moduleHandler()->getModuleList(); + foreach ($enabled_modules as $module => $data) { + $info = $module_extension_list->getExtensionInfo($module); + if (isset($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER])) { + if ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::EXPERIMENTAL) { + $experimental_modules[$module] = $info['name']; + } + elseif ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::DEPRECATED) { + $deprecated_modules[] = ['name' => $info['name'], 'lifecycle_link' => $info['lifecycle_link']]; + } + elseif ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::OBSOLETE) { + $obsolete_extensions[$module] = ['name' => $info['name'], 'lifecycle_link' => $info['lifecycle_link']]; + } + } + } + + // Warn if any experimental modules are installed. + if (!empty($experimental_modules)) { + $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' => RequirementSeverity::Warning, + ]; + } + // Warn if any deprecated modules are installed. + if (!empty($deprecated_modules)) { + foreach ($deprecated_modules as $deprecated_module) { + $deprecated_modules_link_list[] = (string) Link::fromTextAndUrl($deprecated_module['name'], Url::fromUri($deprecated_module['lifecycle_link']))->toString(); + } + $requirements['deprecated_modules'] = [ + 'title' => t('Deprecated modules installed'), + 'value' => t('Deprecated modules found: %module_list.', [ + '%module_list' => Markup::create(implode(', ', $deprecated_modules_link_list)), + ]), + 'severity' => RequirementSeverity::Warning, + ]; + } + + // Gather all obsolete and experimental themes being installed. + $experimental_themes = []; + $deprecated_themes = []; + $installed_themes = \Drupal::service('theme_handler')->listInfo(); + foreach ($installed_themes as $theme => $data) { + $info = $theme_extension_list->getExtensionInfo($theme); + if (isset($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER])) { + if ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::EXPERIMENTAL) { + $experimental_themes[$theme] = $info['name']; + } + elseif ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::DEPRECATED) { + $deprecated_themes[] = ['name' => $info['name'], 'lifecycle_link' => $info['lifecycle_link']]; + } + elseif ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::OBSOLETE) { + $obsolete_extensions[$theme] = ['name' => $info['name'], 'lifecycle_link' => $info['lifecycle_link']]; + } + } + } + + // Warn if any experimental themes are installed. + if (!empty($experimental_themes)) { + $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' => RequirementSeverity::Warning, + ]; + } + + // Warn if any deprecated themes are installed. + if (!empty($deprecated_themes)) { + foreach ($deprecated_themes as $deprecated_theme) { + $deprecated_themes_link_list[] = (string) Link::fromTextAndUrl($deprecated_theme['name'], Url::fromUri($deprecated_theme['lifecycle_link']))->toString(); + + } + $requirements['deprecated_themes'] = [ + 'title' => t('Deprecated themes installed'), + 'value' => t('Deprecated themes found: %theme_list.', [ + '%theme_list' => Markup::create(implode(', ', $deprecated_themes_link_list)), + ]), + 'severity' => RequirementSeverity::Warning, + ]; + } + + // Warn if any obsolete extensions (themes or modules) are installed. + if (!empty($obsolete_extensions)) { + foreach ($obsolete_extensions as $obsolete_extension) { + $obsolete_extensions_link_list[] = (string) Link::fromTextAndUrl($obsolete_extension['name'], Url::fromUri($obsolete_extension['lifecycle_link']))->toString(); + } + $requirements['obsolete_extensions'] = [ + 'title' => t('Obsolete extensions installed'), + 'value' => t('Obsolete extensions found: %extensions. Obsolete extensions are provided only so that they can be uninstalled cleanly. You should immediately <a href=":uninstall_url">uninstall these extensions</a> since they may be removed in a future release.', [ + '%extensions' => Markup::create(implode(', ', $obsolete_extensions_link_list)), + ':uninstall_url' => Url::fromRoute('system.modules_uninstall')->toString(), + ]), + 'severity' => RequirementSeverity::Warning, + ]; + } + self::systemAdvisoriesRequirements($requirements); + } + + // Web server information. + $request_object = \Drupal::request(); + $software = $request_object->server->get('SERVER_SOFTWARE'); + $requirements['webserver'] = [ + 'title' => t('Web server'), + 'value' => $software, + ]; + + // Tests clean URL support. + if ($phase == 'install' && $install_state['interactive'] && !$request_object->query->has('rewrite') && str_contains($software, 'Apache')) { + // If the Apache rewrite module is not enabled, Apache version must be >= + // 2.2.16 because of the FallbackResource directive in the root .htaccess + // file. Since the Apache version reported by the server is dependent on + // the ServerTokens setting in httpd.conf, we may not be able to + // determine if a given config is valid. Thus we are unable to use + // version_compare() as we need have three possible outcomes: the version + // of Apache is greater than 2.2.16, is less than 2.2.16, or cannot be + // determined accurately. In the first case, we encourage the use of + // mod_rewrite; in the second case, we raise an error regarding the + // minimum Apache version; in the third case, we raise a warning that the + // current version of Apache may not be supported. + $rewrite_warning = FALSE; + $rewrite_error = FALSE; + $apache_version_string = 'Apache'; + + // Determine the Apache version number: major, minor and revision. + if (preg_match('/Apache\/(\d+)\.?(\d+)?\.?(\d+)?/', $software, $matches)) { + $apache_version_string = $matches[0]; + + // Major version number + if ($matches[1] < 2) { + $rewrite_error = TRUE; + } + elseif ($matches[1] == 2) { + if (!isset($matches[2])) { + $rewrite_warning = TRUE; + } + elseif ($matches[2] < 2) { + $rewrite_error = TRUE; + } + elseif ($matches[2] == 2) { + if (!isset($matches[3])) { + $rewrite_warning = TRUE; + } + elseif ($matches[3] < 16) { + $rewrite_error = TRUE; + } + } + } + } + else { + $rewrite_warning = TRUE; + } + + if ($rewrite_warning) { + $requirements['apache_version'] = [ + 'title' => t('Apache version'), + 'value' => $apache_version_string, + '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]), + ]; + } + + if ($rewrite_error) { + $requirements['Apache version'] = [ + 'title' => t('Apache version'), + 'value' => $apache_version_string, + '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']), + ]; + } + + if (!$rewrite_error && !$rewrite_warning) { + $requirements['rewrite_module'] = [ + 'title' => t('Clean URLs'), + 'value' => t('Disabled'), + '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']), + ]; + } + } + + // Verify the user is running a supported PHP version. + // If the site is running a recommended version of PHP, just display it + // as an informational message on the status report. This will be overridden + // with an error or warning if the site is running older PHP versions for + // which Drupal has already or will soon drop support. + $phpversion = $phpversion_label = phpversion(); + if ($phase === 'runtime') { + $phpversion_label = t('@phpversion (<a href=":url">more information</a>)', [ + '@phpversion' => $phpversion, + ':url' => (new Url('system.php'))->toString(), + ]); + } + $requirements['php'] = [ + 'title' => t('PHP'), + 'value' => $phpversion_label, + ]; + + // Check if the PHP version is below what Drupal supports. + if (version_compare($phpversion, $minimum_supported_php) < 0) { + $requirements['php']['description'] = t('Your PHP installation is too old. Drupal requires at least PHP %version. 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.', + [ + '%version' => $minimum_supported_php, + '%recommended' => \Drupal::RECOMMENDED_PHP, + ':php_requirements' => 'https://www.drupal.org/docs/system-requirements/php-requirements', + ] + ); + + // If the PHP version is also below the absolute minimum allowed, it's not + // safe to continue with the requirements check, and should always be an + // error. + if (version_compare($phpversion, \Drupal::MINIMUM_PHP) < 0) { + $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') ? 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'] = RequirementSeverity::Info; + } + + // Test for PHP extensions. + $requirements['php_extensions'] = [ + 'title' => t('PHP extensions'), + ]; + + $missing_extensions = []; + $required_extensions = [ + 'date', + 'dom', + 'filter', + 'gd', + 'hash', + 'json', + 'pcre', + 'pdo', + 'session', + 'SimpleXML', + 'SPL', + 'tokenizer', + 'xml', + 'zlib', + ]; + foreach ($required_extensions as $extension) { + if (!extension_loaded($extension)) { + $missing_extensions[] = $extension; + } + } + + if (!empty($missing_extensions)) { + $description = t('Drupal requires you to enable the PHP extensions in the following list (see the <a href=":system_requirements">system requirements page</a> for more information):', [ + ':system_requirements' => 'https://www.drupal.org/docs/system-requirements', + ]); + + // We use twig inline_template to avoid twig's autoescape. + $description = [ + '#type' => 'inline_template', + '#template' => '{{ description }}{{ missing_extensions }}', + '#context' => [ + 'description' => $description, + 'missing_extensions' => [ + '#theme' => 'item_list', + '#items' => $missing_extensions, + ], + ], + ]; + + $requirements['php_extensions']['value'] = t('Disabled'); + $requirements['php_extensions']['severity'] = RequirementSeverity::Error; + $requirements['php_extensions']['description'] = $description; + } + else { + $requirements['php_extensions']['value'] = t('Enabled'); + } + + if ($phase == 'install' || $phase == 'runtime') { + // Check to see if OPcache is installed. + if (!OpCodeCache::isEnabled()) { + $requirements['php_opcache'] = [ + 'value' => t('Not enabled'), + '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.'), + ]; + } + else { + $requirements['php_opcache']['value'] = t('Enabled'); + } + $requirements['php_opcache']['title'] = t('PHP OPcode caching'); + } + + // Check to see if APCu is installed and configured correctly. + if ($phase == 'runtime' && PHP_SAPI != 'cli') { + $requirements['php_apcu_enabled']['title'] = t('PHP APCu caching'); + $requirements['php_apcu_available']['title'] = t('PHP APCu available caching'); + if (extension_loaded('apcu') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN)) { + $memory_info = apcu_sma_info(TRUE); + $apcu_actual_size = ByteSizeMarkup::create($memory_info['seg_size'] * $memory_info['num_seg']); + $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'] = 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, + ]); + } + 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'] = 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'] = 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'] = RequirementSeverity::OK; + } + $requirements['php_apcu_available']['value'] = t('Memory available: @available.', [ + '@available' => ByteSizeMarkup::create($memory_info['avail_mem']), + ]); + } + } + else { + $requirements['php_apcu_enabled'] += [ + 'value' => t('Not enabled'), + '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.'), + ]; + } + } + + if ($phase != 'update') { + // Test whether we have a good source of random bytes. + $requirements['php_random_bytes'] = [ + 'title' => t('Random number generation'), + ]; + try { + $bytes = random_bytes(10); + if (strlen($bytes) != 10) { + throw new \Exception("Tried to generate 10 random bytes, generated '" . strlen($bytes) . "'"); + } + $requirements['php_random_bytes']['value'] = t('Successful'); + } + catch (\Exception $e) { + // If /dev/urandom is not available on a UNIX-like system, check whether + // open_basedir restrictions are the cause. + $open_basedir_blocks_urandom = FALSE; + if (DIRECTORY_SEPARATOR === '/' && !@is_readable('/dev/urandom')) { + $open_basedir = ini_get('open_basedir'); + if ($open_basedir) { + $open_basedir_paths = explode(PATH_SEPARATOR, $open_basedir); + $open_basedir_blocks_urandom = !array_intersect(['/dev', '/dev/', '/dev/urandom'], $open_basedir_paths); + } + } + $args = [ + ':drupal-php' => 'https://www.drupal.org/docs/system-requirements/php-requirements', + '%exception_message' => $e->getMessage(), + ]; + if ($open_basedir_blocks_urandom) { + $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. The most likely cause is that open_basedir restrictions are in effect and /dev/urandom is not on the allowed list. See the <a href=":drupal-php">system requirements</a> page for more information. %exception_message', $args); + } + else { + $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'] = RequirementSeverity::Error; + } + } + + if ($phase === 'runtime' && PHP_SAPI !== 'cli') { + if (!function_exists('fastcgi_finish_request') && !function_exists('litespeed_finish_request') && !ob_get_status()) { + $requirements['output_buffering'] = [ + 'title' => t('Output Buffering'), + 'error_value' => t('Not enabled'), + '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>.'), + ]; + } + } + + if ($phase == 'install' || $phase == 'update') { + // Test for PDO (database). + $requirements['database_extensions'] = [ + 'title' => t('Database support'), + ]; + + // Make sure PDO is available. + $database_ok = extension_loaded('pdo'); + if (!$database_ok) { + $pdo_message = t('Your web server does not appear to support PDO (PHP Data Objects). Ask your hosting provider if they support the native PDO extension. See the <a href=":link">system requirements</a> page for more information.', [ + ':link' => 'https://www.drupal.org/docs/system-requirements/php-requirements#database', + ]); + } + else { + // Make sure at least one supported database driver exists. + if (empty(Database::getDriverList()->getInstallableList())) { + $database_ok = FALSE; + $pdo_message = t('Your web server does not appear to support any common PDO database extensions. Check with your hosting provider to see if they support PDO (PHP Data Objects) and offer any databases that <a href=":drupal-databases">Drupal supports</a>.', [ + ':drupal-databases' => 'https://www.drupal.org/docs/system-requirements/database-server-requirements', + ]); + } + // Make sure the native PDO extension is available, not the older PEAR + // version. (See install_verify_pdo() for details.) + if (!defined('PDO::ATTR_DEFAULT_FETCH_MODE')) { + $database_ok = FALSE; + $pdo_message = t('Your web server seems to have the wrong version of PDO installed. Drupal requires the PDO extension from PHP core. This system has the older PECL version. See the <a href=":link">system requirements</a> page for more information.', [ + ':link' => 'https://www.drupal.org/docs/system-requirements/php-requirements#database', + ]); + } + } + + if (!$database_ok) { + $requirements['database_extensions']['value'] = t('Disabled'); + $requirements['database_extensions']['severity'] = RequirementSeverity::Error; + $requirements['database_extensions']['description'] = $pdo_message; + } + else { + $requirements['database_extensions']['value'] = t('Enabled'); + } + } + + if ($phase === 'runtime' || $phase === 'update') { + // Database information. + $class = Database::getConnection()->getConnectionOptions()['namespace'] . '\\Install\\Tasks'; + /** @var \Drupal\Core\Database\Install\Tasks $tasks */ + $tasks = new $class(); + $requirements['database_system'] = [ + 'title' => t('Database system'), + 'value' => $tasks->name(), + ]; + $requirements['database_system_version'] = [ + 'title' => t('Database system version'), + 'value' => Database::getConnection()->version(), + ]; + + $errors = $tasks->engineVersionRequirementsCheck(); + $error_count = count($errors); + if ($error_count > 0) { + $error_message = [ + '#theme' => 'item_list', + '#items' => $errors, + // 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'] = RequirementSeverity::Error; + $requirements['database_system_version']['description'] = $error_message; + } + } + + if ($phase === 'runtime' || $phase === 'update') { + // Test database JSON support. + $requirements['database_support_json'] = [ + 'title' => t('Database support for JSON'), + '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'] = RequirementSeverity::Error; + } + } + + // Test PHP memory_limit + $memory_limit = ini_get('memory_limit'); + $requirements['php_memory_limit'] = [ + 'title' => t('PHP memory limit'), + 'value' => $memory_limit == -1 ? t('-1 (Unlimited)') : $memory_limit, + ]; + + if (!Environment::checkMemoryLimit(\Drupal::MINIMUM_PHP_MEMORY_LIMIT, $memory_limit)) { + $description = []; + if ($phase == 'install') { + $description['phase'] = t('Consider increasing your PHP memory limit to %memory_minimum_limit to help prevent errors in the installation process.', ['%memory_minimum_limit' => \Drupal::MINIMUM_PHP_MEMORY_LIMIT]); + } + elseif ($phase == 'update') { + $description['phase'] = t('Consider increasing your PHP memory limit to %memory_minimum_limit to help prevent errors in the update process.', ['%memory_minimum_limit' => \Drupal::MINIMUM_PHP_MEMORY_LIMIT]); + } + elseif ($phase == 'runtime') { + $description['phase'] = t('Depending on your configuration, Drupal can run with a %memory_limit PHP memory limit. However, a %memory_minimum_limit PHP memory limit or above is recommended, especially if your site uses additional custom or contributed modules.', ['%memory_limit' => $memory_limit, '%memory_minimum_limit' => \Drupal::MINIMUM_PHP_MEMORY_LIMIT]); + } + + if (!empty($description['phase'])) { + if ($php_ini_path = get_cfg_var('cfg_file_path')) { + $description['memory'] = t('Increase the memory limit by editing the memory_limit parameter in the file %configuration-file and then restart your web server (or contact your system administrator or hosting provider for assistance).', ['%configuration-file' => $php_ini_path]); + } + else { + $description['memory'] = t('Contact your system administrator or hosting provider for assistance with increasing your PHP memory limit.'); + } + + $handbook_link = t('For more information, see the online handbook entry for <a href=":memory-limit">increasing the PHP memory limit</a>.', [':memory-limit' => 'https://www.drupal.org/node/207036']); + + $description = [ + '#type' => 'inline_template', + '#template' => '{{ description_phase }} {{ description_memory }} {{ handbook }}', + '#context' => [ + 'description_phase' => $description['phase'], + 'description_memory' => $description['memory'], + 'handbook' => $handbook_link, + ], + ]; + + $requirements['php_memory_limit']['description'] = $description; + $requirements['php_memory_limit']['severity'] = RequirementSeverity::Warning; + } + } + + // Test if configuration files and directory are writable. + if ($phase == 'runtime') { + $conf_errors = []; + // Find the site path. Kernel service is not always available at this + // point, but is preferred, when available. + if (\Drupal::hasService('kernel')) { + $site_path = \Drupal::getContainer()->getParameter('site.path'); + } + else { + $site_path = DrupalKernel::findSitePath(Request::createFromGlobals()); + } + // Allow system administrators to disable permissions hardening for the + // site directory. This allows additional files in the site directory to + // be updated when they are managed in a version control system. + if (Settings::get('skip_permissions_hardening')) { + $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 = RequirementSeverity::Warning; + } + else { + $error_value = t('Not protected'); + // In normal operation, writable files or directories are an 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]); + } + } + foreach (['settings.php', 'settings.local.php', 'services.yml'] as $conf_file) { + $full_path = $site_path . '/' . $conf_file; + if (file_exists($full_path) && !drupal_verify_install_file($full_path, FILE_EXIST | FILE_READABLE | FILE_NOT_WRITABLE, 'file', !Settings::get('skip_permissions_hardening'))) { + $conf_errors[] = t("The file %file is not protected from modifications and poses a security risk. You must change the file's permissions to be non-writable.", ['%file' => $full_path]); + } + } + if (!empty($conf_errors)) { + if (count($conf_errors) == 1) { + $description = $conf_errors[0]; + } + else { + // We use twig inline_template to avoid double escaping. + $description = [ + '#type' => 'inline_template', + '#template' => '{{ configuration_error_list }}', + '#context' => [ + 'configuration_error_list' => [ + '#theme' => 'item_list', + '#items' => $conf_errors, + ], + ], + ]; + } + $requirements['configuration_files'] = [ + 'value' => $error_value, + 'severity' => $file_protection_severity, + 'description' => $description, + ]; + } + else { + $requirements['configuration_files'] = [ + 'value' => t('Protected'), + ]; + } + $requirements['configuration_files']['title'] = t('Configuration files'); + } + + // Test the contents of the .htaccess files. + if ($phase == 'runtime' && Settings::get('auto_create_htaccess', TRUE)) { + // Try to write the .htaccess files first, to prevent false alarms in + // case (for example) the /tmp directory was wiped. + /** @var \Drupal\Core\File\HtaccessWriterInterface $htaccessWriter */ + $htaccessWriter = \Drupal::service("file.htaccess_writer"); + $htaccessWriter->ensure(); + foreach ($htaccessWriter->defaultProtectedDirs() as $protected_dir) { + $htaccess_file = $protected_dir->getPath() . '/.htaccess'; + // Check for the string which was added to the recommended .htaccess + // file in the latest security update. + if (!file_exists($htaccess_file) || !($contents = @file_get_contents($htaccess_file)) || !str_contains($contents, 'Drupal_Security_Do_Not_Remove_See_SA_2013_003')) { + $url = 'https://www.drupal.org/SA-CORE-2013-003'; + $requirements[$htaccess_file] = [ + // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString + 'title' => new TranslatableMarkup($protected_dir->getTitle()), + 'value' => t('Not fully protected'), + '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()]), + ]; + } + } + } + + // Report cron status. + if ($phase == 'runtime') { + $cron_config = \Drupal::config('system.cron'); + // Cron warning threshold defaults to two days. + $threshold_warning = $cron_config->get('threshold.requirements_warning'); + // Cron error threshold defaults to two weeks. + $threshold_error = $cron_config->get('threshold.requirements_error'); + + // Determine when cron last ran. + $cron_last = \Drupal::state()->get('system.cron_last'); + if (!is_numeric($cron_last)) { + $cron_last = \Drupal::state()->get('install_time', 0); + } + + // Determine severity based on time since cron last ran. + $severity = RequirementSeverity::Info; + $request_time = \Drupal::time()->getRequestTime(); + if ($request_time - $cron_last > $threshold_error) { + $severity = RequirementSeverity::Error; + } + elseif ($request_time - $cron_last > $threshold_warning) { + $severity = RequirementSeverity::Warning; + } + + // Set summary and description based on values determined above. + $summary = t('Last run @time ago', ['@time' => \Drupal::service('date.formatter')->formatTimeDiffSince($cron_last)]); + + $requirements['cron'] = [ + 'title' => t('Cron maintenance tasks'), + 'severity' => $severity, + 'value' => $summary, + ]; + if ($severity != RequirementSeverity::Info) { + $requirements['cron']['description'][] = [ + [ + '#markup' => t('Cron has not run recently.'), + '#suffix' => ' ', + ], + [ + '#markup' => t('For more information, see the online handbook entry for <a href=":cron-handbook">configuring cron jobs</a>.', [':cron-handbook' => 'https://www.drupal.org/docs/administering-a-drupal-site/cron-automated-tasks/cron-automated-tasks-overview']), + '#suffix' => ' ', + ], + ]; + } + $requirements['cron']['description'][] = [ + [ + '#type' => 'link', + '#prefix' => '(', + '#title' => t('more information'), + '#suffix' => ')', + '#url' => Url::fromRoute('system.cron_settings'), + ], + [ + '#prefix' => '<span class="cron-description__run-cron">', + '#suffix' => '</span>', + '#type' => 'link', + '#title' => t('Run cron'), + '#url' => Url::fromRoute('system.run_cron'), + ], + ]; + } + if ($phase != 'install') { + $directories = [ + PublicStream::basePath(), + // By default no private files directory is configured. For private + // files to be secure the admin needs to provide a path outside the + // webroot. + PrivateStream::basePath(), + \Drupal::service('file_system')->getTempDirectory(), + ]; + } + + // During an install we need to make assumptions about the file system + // unless overrides are provided in settings.php. + if ($phase == 'install') { + $directories = []; + if ($file_public_path = Settings::get('file_public_path')) { + $directories[] = $file_public_path; + } + else { + // If we are installing Drupal, the settings.php file might not exist + // yet in the intended site directory, so don't require it. + $request = Request::createFromGlobals(); + $site_path = DrupalKernel::findSitePath($request); + $directories[] = $site_path . '/files'; + } + if ($file_private_path = Settings::get('file_private_path')) { + $directories[] = $file_private_path; + } + if (Settings::get('file_temp_path')) { + $directories[] = Settings::get('file_temp_path'); + } + else { + // If the temporary directory is not overridden use an appropriate + // temporary path for the system. + $directories[] = FileSystemComponent::getOsTemporaryDirectory(); + } + } + + // Check the config directory if it is defined in settings.php. If it isn't + // defined, the installer will create a valid config directory later, but + // during runtime we must always display an error. + $config_sync_directory = Settings::get('config_sync_directory'); + if (!empty($config_sync_directory)) { + // If we're installing Drupal try and create the config sync directory. + if (!is_dir($config_sync_directory) && $phase == 'install') { + \Drupal::service('file_system')->prepareDirectory($config_sync_directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); + } + if (!is_dir($config_sync_directory)) { + if ($phase == 'install') { + $description = t('An automated attempt to create the directory %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>.', ['%directory' => $config_sync_directory, ':handbook_url' => 'https://www.drupal.org/server-permissions']); + } + else { + $description = t('The directory %directory does not exist.', ['%directory' => $config_sync_directory]); + } + $requirements['config sync directory'] = [ + 'title' => t('Configuration sync directory'), + 'description' => $description, + 'severity' => RequirementSeverity::Error, + ]; + } + } + if ($phase != 'install' && empty($config_sync_directory)) { + $requirements['config sync directory'] = [ + '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' => RequirementSeverity::Error, + ]; + } + + $requirements['file system'] = [ + 'title' => t('File system'), + ]; + + $error = ''; + // For installer, create the directories if possible. + foreach ($directories as $directory) { + if (!$directory) { + continue; + } + if ($phase == 'install') { + \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); + } + $is_writable = is_writable($directory); + $is_directory = is_dir($directory); + if (!$is_writable || !$is_directory) { + $description = ''; + $requirements['file system']['value'] = t('Not writable'); + if (!$is_directory) { + $error = t('The directory %directory does not exist.', ['%directory' => $directory]); + } + else { + $error = t('The directory %directory is not writable.', ['%directory' => $directory]); + } + // The files directory requirement check is done only during install and + // runtime. + if ($phase == 'runtime') { + $description = t('You may need to set the correct directory at the <a href=":admin-file-system">file system settings page</a> or change the current directory\'s permissions so that it is writable.', [':admin-file-system' => Url::fromRoute('system.file_system_settings')->toString()]); + } + elseif ($phase == 'install') { + // For the installer UI, we need different wording. 'value' will + // be treated as version, so provide none there. + $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']); + $requirements['file system']['value'] = ''; + } + if (!empty($description)) { + $description = [ + '#type' => 'inline_template', + '#template' => '{{ error }} {{ description }}', + '#context' => [ + 'error' => $error, + 'description' => $description, + ], + ]; + $requirements['file system']['description'] = $description; + $requirements['file system']['severity'] = RequirementSeverity::Error; + } + } + else { + // This function can be called before the config_cache table has been + // created. + if ($phase == 'install' || \Drupal::config('system.file')->get('default_scheme') == 'public') { + $requirements['file system']['value'] = t('Writable (<em>public</em> download method)'); + } + else { + $requirements['file system']['value'] = t('Writable (<em>private</em> download method)'); + } + } + } + + // See if updates are available in update.php. + if ($phase == 'runtime') { + $requirements['update'] = [ + 'title' => t('Database updates'), + 'value' => t('Up to date'), + ]; + + // Check installed modules. + $has_pending_updates = FALSE; + /** @var \Drupal\Core\Update\UpdateHookRegistry $update_registry */ + $update_registry = \Drupal::service('update.update_hook_registry'); + foreach (\Drupal::moduleHandler()->getModuleList() as $module => $filename) { + $updates = $update_registry->getAvailableUpdates($module); + if ($updates) { + $default = $update_registry->getInstalledVersion($module); + if (max($updates) > $default) { + $has_pending_updates = TRUE; + break; + } + } + } + if (!$has_pending_updates) { + /** @var \Drupal\Core\Update\UpdateRegistry $post_update_registry */ + $post_update_registry = \Drupal::service('update.post_update_registry'); + $missing_post_update_functions = $post_update_registry->getPendingUpdateFunctions(); + if (!empty($missing_post_update_functions)) { + $has_pending_updates = TRUE; + } + } + + if ($has_pending_updates) { + $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()]); + } + + $requirements['entity_update'] = [ + 'title' => t('Entity/field definitions'), + 'value' => t('Up to date'), + ]; + // Verify that no entity updates are pending. + if ($change_list = \Drupal::entityDefinitionUpdateManager()->getChangeSummary()) { + $build = []; + foreach ($change_list as $entity_type_id => $changes) { + $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id); + $build[] = [ + '#theme' => 'item_list', + '#title' => $entity_type->getLabel(), + '#items' => $changes, + ]; + } + + $entity_update_issues = \Drupal::service('renderer')->renderInIsolation($build); + $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]); + } + } + + // Display the deployment identifier if set. + if ($phase == 'runtime') { + if ($deployment_identifier = Settings::get('deployment_identifier')) { + $requirements['deployment identifier'] = [ + 'title' => t('Deployment identifier'), + 'value' => $deployment_identifier, + 'severity' => RequirementSeverity::Info, + ]; + } + } + + // Verify the update.php access setting + if ($phase == 'runtime') { + if (Settings::get('update_free_access')) { + $requirements['update access'] = [ + 'value' => t('Not protected'), + '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\']']), + ]; + } + else { + $requirements['update access'] = [ + 'value' => t('Protected'), + ]; + } + $requirements['update access']['title'] = t('Access to update.php'); + } + + // Display an error if a newly introduced dependency in a module is not + // resolved. + if ($phase === 'update' || $phase === 'runtime') { + $create_extension_incompatibility_list = function (array $extension_names, PluralTranslatableMarkup $description, PluralTranslatableMarkup $title, TranslatableMarkup|string $message = '', TranslatableMarkup|string $additional_description = '') { + if ($message === '') { + $message = new TranslatableMarkup('Review the <a href=":url"> suggestions for resolving this incompatibility</a> to repair your installation, and then re-run update.php.', [':url' => 'https://www.drupal.org/docs/updating-drupal/troubleshooting-database-updates']); + } + // Use an inline twig template to: + // - Concatenate MarkupInterface objects and preserve safeness. + // - Use the item_list theme for the extension list. + $template = [ + '#type' => 'inline_template', + '#template' => '{{ description }}{{ extensions }}{{ additional_description }}<br>', + '#context' => [ + 'extensions' => [ + '#theme' => 'item_list', + ], + ], + ]; + $template['#context']['extensions']['#items'] = $extension_names; + $template['#context']['description'] = $description; + $template['#context']['additional_description'] = $additional_description; + return [ + 'title' => $title, + 'value' => [ + 'list' => $template, + 'handbook_link' => [ + '#markup' => $message, + ], + ], + 'severity' => RequirementSeverity::Error, + ]; + }; + $profile = \Drupal::installProfile(); + $files = $module_extension_list->getList(); + $files += $theme_extension_list->getList(); + $core_incompatible_extensions = []; + $php_incompatible_extensions = []; + foreach ($files as $extension_name => $file) { + // Ignore uninstalled extensions and installation profiles. + if (!$file->status || $extension_name == $profile) { + continue; + } + + $name = $file->info['name']; + if (!empty($file->info['core_incompatible'])) { + $core_incompatible_extensions[$file->info['type']][] = $name; + } + + // Check the extension's PHP version. + $php = (string) $file->info['php']; + if (version_compare($php, PHP_VERSION, '>')) { + $php_incompatible_extensions[$file->info['type']][] = $name; + } + + // Check the module's required modules. + /** @var \Drupal\Core\Extension\Dependency $requirement */ + foreach ($file->requires as $requirement) { + $required_module = $requirement->getName(); + // Check if the module exists. + if (!isset($files[$required_module])) { + $requirements["$extension_name-$required_module"] = [ + 'title' => t('Unresolved dependency'), + 'description' => t('@name requires this module.', ['@name' => $name]), + 'value' => t('@required_name (Missing)', ['@required_name' => $required_module]), + 'severity' => RequirementSeverity::Error, + ]; + continue; + } + // Check for an incompatible version. + $required_file = $files[$required_module]; + $required_name = $required_file->info['name']; + // Remove CORE_COMPATIBILITY- only from the start of the string. + $version = preg_replace('/^(' . \Drupal::CORE_COMPATIBILITY . '\-)/', '', $required_file->info['version'] ?? ''); + if (!$requirement->isCompatible($version)) { + $requirements["$extension_name-$required_module"] = [ + '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' => RequirementSeverity::Error, + ]; + continue; + } + } + } + if (!empty($core_incompatible_extensions['module'])) { + $requirements['module_core_incompatible'] = $create_extension_incompatibility_list( + $core_incompatible_extensions['module'], + new PluralTranslatableMarkup( + count($core_incompatible_extensions['module']), + 'The following module is installed, but it is incompatible with Drupal @version:', + 'The following modules are installed, but they are incompatible with Drupal @version:', + ['@version' => \Drupal::VERSION] + ), + new PluralTranslatableMarkup( + count($core_incompatible_extensions['module']), + 'Incompatible module', + 'Incompatible modules' + ) + ); + } + if (!empty($core_incompatible_extensions['theme'])) { + $requirements['theme_core_incompatible'] = $create_extension_incompatibility_list( + $core_incompatible_extensions['theme'], + new PluralTranslatableMarkup( + count($core_incompatible_extensions['theme']), + 'The following theme is installed, but it is incompatible with Drupal @version:', + 'The following themes are installed, but they are incompatible with Drupal @version:', + ['@version' => \Drupal::VERSION] + ), + new PluralTranslatableMarkup( + count($core_incompatible_extensions['theme']), + 'Incompatible theme', + 'Incompatible themes' + ) + ); + } + if (!empty($php_incompatible_extensions['module'])) { + $requirements['module_php_incompatible'] = $create_extension_incompatibility_list( + $php_incompatible_extensions['module'], + new PluralTranslatableMarkup( + count($php_incompatible_extensions['module']), + 'The following module is installed, but it is incompatible with PHP @version:', + 'The following modules are installed, but they are incompatible with PHP @version:', + ['@version' => phpversion()] + ), + new PluralTranslatableMarkup( + count($php_incompatible_extensions['module']), + 'Incompatible module', + 'Incompatible modules' + ) + ); + } + if (!empty($php_incompatible_extensions['theme'])) { + $requirements['theme_php_incompatible'] = $create_extension_incompatibility_list( + $php_incompatible_extensions['theme'], + new PluralTranslatableMarkup( + count($php_incompatible_extensions['theme']), + 'The following theme is installed, but it is incompatible with PHP @version:', + 'The following themes are installed, but they are incompatible with PHP @version:', + ['@version' => phpversion()] + ), + new PluralTranslatableMarkup( + count($php_incompatible_extensions['theme']), + 'Incompatible theme', + 'Incompatible themes' + ) + ); + } + + $extension_config = \Drupal::configFactory()->get('core.extension'); + + // Look for removed core modules. + $is_removed_module = function ($extension_name) use ($module_extension_list) { + return !$module_extension_list->exists($extension_name) + && array_key_exists($extension_name, DRUPAL_CORE_REMOVED_MODULE_LIST); + }; + $removed_modules = array_filter(array_keys($extension_config->get('module')), $is_removed_module); + if (!empty($removed_modules)) { + $list = []; + foreach ($removed_modules as $removed_module) { + $list[] = t('<a href=":url">@module</a>', [ + ':url' => "https://www.drupal.org/project/$removed_module", + '@module' => DRUPAL_CORE_REMOVED_MODULE_LIST[$removed_module], + ]); + } + $requirements['removed_module'] = $create_extension_incompatibility_list( + $list, + new PluralTranslatableMarkup( + count($removed_modules), + 'You must add the following contributed module and reload this page.', + 'You must add the following contributed modules and reload this page.' + ), + new PluralTranslatableMarkup( + count($removed_modules), + 'Removed core module', + 'Removed core modules' + ), + new TranslatableMarkup( + 'For more information read the <a href=":url">documentation on deprecated modules.</a>', + [':url' => 'https://www.drupal.org/node/3223395#s-recommendations-for-deprecated-modules'] + ), + new PluralTranslatableMarkup( + count($removed_modules), + 'This module is installed on your site but is no longer provided by Core.', + 'These modules are installed on your site but are no longer provided by Core.' + ), + ); + } + + // Look for removed core themes. + $is_removed_theme = function ($extension_name) use ($theme_extension_list) { + return !$theme_extension_list->exists($extension_name) + && array_key_exists($extension_name, DRUPAL_CORE_REMOVED_THEME_LIST); + }; + $removed_themes = array_filter(array_keys($extension_config->get('theme')), $is_removed_theme); + if (!empty($removed_themes)) { + $list = []; + foreach ($removed_themes as $removed_theme) { + $list[] = t('<a href=":url">@theme</a>', [ + ':url' => "https://www.drupal.org/project/$removed_theme", + '@theme' => DRUPAL_CORE_REMOVED_THEME_LIST[$removed_theme], + ]); + } + $requirements['removed_theme'] = $create_extension_incompatibility_list( + $list, + new PluralTranslatableMarkup( + count($removed_themes), + 'You must add the following contributed theme and reload this page.', + 'You must add the following contributed themes and reload this page.' + ), + new PluralTranslatableMarkup( + count($removed_themes), + 'Removed core theme', + 'Removed core themes' + ), + new TranslatableMarkup( + 'For more information read the <a href=":url">documentation on deprecated themes.</a>', + [':url' => 'https://www.drupal.org/node/3223395#s-recommendations-for-deprecated-themes'] + ), + new PluralTranslatableMarkup( + count($removed_themes), + 'This theme is installed on your site but is no longer provided by Core.', + 'These themes are installed on your site but are no longer provided by Core.' + ), + ); + } + + // Look for missing modules. + $is_missing_module = function ($extension_name) use ($module_extension_list) { + return !$module_extension_list->exists($extension_name) && !in_array($extension_name, array_keys(DRUPAL_CORE_REMOVED_MODULE_LIST), TRUE); + }; + $invalid_modules = array_filter(array_keys($extension_config->get('module')), $is_missing_module); + + if (!empty($invalid_modules)) { + $requirements['invalid_module'] = $create_extension_incompatibility_list( + $invalid_modules, + new PluralTranslatableMarkup( + count($invalid_modules), + 'The following module is marked as installed in the core.extension configuration, but it is missing:', + 'The following modules are marked as installed in the core.extension configuration, but they are missing:' + ), + new PluralTranslatableMarkup( + count($invalid_modules), + 'Missing or invalid module', + 'Missing or invalid modules' + ) + ); + } + + // Look for invalid themes. + $is_missing_theme = function ($extension_name) use (&$theme_extension_list) { + return !$theme_extension_list->exists($extension_name) && !in_array($extension_name, array_keys(DRUPAL_CORE_REMOVED_THEME_LIST), TRUE); + }; + $invalid_themes = array_filter(array_keys($extension_config->get('theme')), $is_missing_theme); + if (!empty($invalid_themes)) { + $requirements['invalid_theme'] = $create_extension_incompatibility_list( + $invalid_themes, + new PluralTranslatableMarkup( + count($invalid_themes), + 'The following theme is marked as installed in the core.extension configuration, but it is missing:', + 'The following themes are marked as installed in the core.extension configuration, but they are missing:' + ), + new PluralTranslatableMarkup( + count($invalid_themes), + 'Missing or invalid theme', + 'Missing or invalid themes' + ) + ); + } + } + + // Returns Unicode library status and errors. + $libraries = [ + Unicode::STATUS_SINGLEBYTE => t('Standard PHP'), + Unicode::STATUS_MULTIBYTE => t('PHP Mbstring Extension'), + Unicode::STATUS_ERROR => t('Error'), + ]; + $severities = [ + Unicode::STATUS_SINGLEBYTE => RequirementSeverity::Warning, + Unicode::STATUS_MULTIBYTE => NULL, + Unicode::STATUS_ERROR => RequirementSeverity::Error, + ]; + $failed_check = Unicode::check(); + $library = Unicode::getStatus(); + + $requirements['unicode'] = [ + 'title' => t('Unicode library'), + 'value' => $libraries[$library], + 'severity' => $severities[$library], + ]; + switch ($failed_check) { + case 'mb_strlen': + $requirements['unicode']['description'] = t('Operations on Unicode strings are emulated on a best-effort basis. Install the <a href="http://php.net/mbstring">PHP mbstring extension</a> for improved Unicode support.'); + break; + + case 'mbstring.encoding_translation': + $requirements['unicode']['description'] = t('Multibyte string input conversion in PHP is active and must be disabled. Check the php.ini <em>mbstring.encoding_translation</em> setting. Refer to the <a href="http://php.net/mbstring">PHP mbstring documentation</a> for more information.'); + break; + } + + if ($phase == 'runtime') { + // Check for update status module. + if (!\Drupal::moduleHandler()->moduleExists('update')) { + $requirements['update status'] = [ + 'value' => t('Not enabled'), + '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(), + ]), + ]; + } + else { + $requirements['update status'] = [ + 'value' => t('Enabled'), + ]; + } + $requirements['update status']['title'] = t('Update notifications'); + + if (Settings::get('rebuild_access')) { + $requirements['rebuild access'] = [ + 'title' => t('Rebuild access'), + 'value' => t('Enabled'), + '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.'), + ]; + } + } + + // Check if the SameSite cookie attribute is set to a valid value. Since + // this involves checking whether we are using a secure connection this + // only makes sense inside an HTTP request, not on the command line. + if ($phase === 'runtime' && PHP_SAPI !== 'cli') { + $samesite = ini_get('session.cookie_samesite') ?: t('Not set'); + // Check if the SameSite attribute is set to a valid value. If it is set + // to 'None' the request needs to be done over HTTPS. + $valid = match ($samesite) { + 'Lax', 'Strict' => TRUE, + 'None' => $request_object->isSecure(), + default => FALSE, + }; + $requirements['php_session_samesite'] = [ + 'title' => t('SameSite cookie attribute'), + 'value' => $samesite, + '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', + ]), + ]; + } + + // See if trusted host names have been configured, and warn the user if they + // are not set. + if ($phase == 'runtime') { + $trusted_host_patterns = Settings::get('trusted_host_patterns'); + if (empty($trusted_host_patterns)) { + $requirements['trusted_host_patterns'] = [ + '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' => RequirementSeverity::Error, + ]; + } + else { + $requirements['trusted_host_patterns'] = [ + 'title' => t('Trusted Host Settings'), + 'value' => t('Enabled'), + 'description' => t('The trusted_host_patterns setting is set to allow %trusted_host_patterns', ['%trusted_host_patterns' => implode(', ', $trusted_host_patterns)]), + ]; + } + } + + // When the database driver is provided by a module, then check that the + // providing module is installed. + if ($phase === 'runtime' || $phase === 'update') { + $connection = Database::getConnection(); + $provider = $connection->getProvider(); + if ($provider !== 'core' && !\Drupal::moduleHandler()->moduleExists($provider)) { + $autoload = $connection->getConnectionOptions()['autoload'] ?? ''; + if (str_contains($autoload, 'src/Driver/Database/')) { + $post_update_registry = \Drupal::service('update.post_update_registry'); + $pending_updates = $post_update_registry->getPendingUpdateInformation(); + if (!in_array('enable_provider_database_driver', array_keys($pending_updates['system']['pending'] ?? []), TRUE)) { + // Only show the warning when the post update function has run and + // the module that is providing the database driver is not + // installed. + $requirements['database_driver_provided_by_module'] = [ + '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' => RequirementSeverity::Error, + ]; + } + } + } + } + + // Check xdebug.max_nesting_level, as some pages will not work if it is too + // low. + if (extension_loaded('xdebug')) { + // Setting this value to 256 was considered adequate on Xdebug 2.3 + // (see http://bugs.xdebug.org/bug_view_page.php?bug_id=00001100) + $minimum_nesting_level = 256; + $current_nesting_level = ini_get('xdebug.max_nesting_level'); + + if ($current_nesting_level < $minimum_nesting_level) { + $requirements['xdebug_max_nesting_level'] = [ + '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' => RequirementSeverity::Error, + ]; + } + } + + // Installations on Windows can run into limitations with MAX_PATH if the + // Drupal root directory is too deep in the filesystem. Generally this + // shows up in cached Twig templates and other public files with long + // directory or file names. There is no definite root directory depth below + // which Drupal is guaranteed to function correctly on Windows. Since + // problems are likely with more than 100 characters in the Drupal root + // path, show an error. + if (str_starts_with(PHP_OS, 'WIN')) { + $depth = strlen(realpath(DRUPAL_ROOT . '/' . PublicStream::basePath())); + if ($depth > 120) { + $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' => RequirementSeverity::Error, + ]; + } + } + // Check to see if dates will be limited to 1901-2038. + if (PHP_INT_SIZE <= 4) { + $requirements['limited_date_range'] = [ + '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' => RequirementSeverity::Warning, + ]; + } + + // During installs from configuration don't support install profiles that + // implement hook_install. + if ($phase == 'install' && !empty($install_state['config_install_path'])) { + $install_hook = $install_state['parameters']['profile'] . '_install'; + if (function_exists($install_hook)) { + $requirements['config_install'] = [ + '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' => RequirementSeverity::Error, + ]; + } + } + + if ($phase === 'runtime') { + $settings = Settings::getAll(); + if (array_key_exists('install_profile', $settings)) { + // The following message is only informational because not all site + // owners have access to edit their settings.php as it may be + // controlled by their hosting provider. + $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' => RequirementSeverity::Warning, + ]; + } + } + + // Ensure that no module has a current schema version that is lower than the + // one that was last removed. + if ($phase == 'update') { + $module_handler = \Drupal::moduleHandler(); + /** @var \Drupal\Core\Update\UpdateHookRegistry $update_registry */ + $update_registry = \Drupal::service('update.update_hook_registry'); + $module_list = []; + // hook_update_last_removed() is a procedural hook hook because we + // do not have classes loaded that would be needed. + // Simply inlining the old hook mechanism is better than making + // ModuleInstaller::invoke() public. + foreach ($module_handler->getModuleList() as $module => $extension) { + $function = $module . '_update_last_removed'; + if (function_exists($function)) { + $last_removed = $function(); + if ($last_removed && $last_removed > $update_registry->getInstalledVersion($module)) { + + /** @var \Drupal\Core\Extension\Extension $module_info */ + $module_info = $module_extension_list->get($module); + $module_list[$module] = [ + 'name' => $module_info->info['name'], + 'last_removed' => $last_removed, + 'installed_version' => $update_registry->getInstalledVersion($module), + ]; + } + } + } + + // If user module is in the list then only show a specific message for + // Drupal core. + if (isset($module_list['user'])) { + $requirements['user_update_last_removed'] = [ + 'title' => t('The version of Drupal you are trying to update from is too old'), + 'description' => t('Updating to Drupal @current_major is only supported from Drupal version @required_min_version or higher. If you are trying to update from an older version, first update to the latest version of Drupal @previous_major. (<a href=":url">Drupal upgrade guide</a>)', [ + '@current_major' => 10, + '@required_min_version' => '9.4.0', + '@previous_major' => 9, + ':url' => 'https://www.drupal.org/docs/upgrading-drupal/drupal-8-and-higher', + ]), + 'severity' => RequirementSeverity::Error, + ]; + } + else { + foreach ($module_list as $module => $data) { + $requirements[$module . '_update_last_removed'] = [ + 'title' => t('Unsupported schema version: @module', ['@module' => $data['name']]), + 'description' => t('The installed version of the %module module is too old to update. Update to an intermediate version first (last removed version: @last_removed_version, installed version: @installed_version).', [ + '%module' => $data['name'], + '@last_removed_version' => $data['last_removed'], + '@installed_version' => $data['installed_version'], + ]), + 'severity' => RequirementSeverity::Error, + ]; + } + } + // Also check post-updates. Only do this if we're not already showing an + // error for hook_update_N(). + $missing_updates = []; + if (empty($module_list)) { + $existing_updates = \Drupal::service('keyvalue')->get('post_update')->get('existing_updates', []); + $post_update_registry = \Drupal::service('update.post_update_registry'); + $modules = \Drupal::moduleHandler()->getModuleList(); + foreach ($modules as $module => $extension) { + $module_info = $module_extension_list->get($module); + $removed_post_updates = $post_update_registry->getRemovedPostUpdates($module); + if ($missing_updates = array_diff(array_keys($removed_post_updates), $existing_updates)) { + $versions = array_unique(array_intersect_key($removed_post_updates, array_flip($missing_updates))); + $description = new PluralTranslatableMarkup(count($versions), + 'The installed version of the %module module is too old to update. Update to a version prior to @versions first (missing updates: @missing_updates).', + 'The installed version of the %module module is too old to update. Update first to a version prior to all of the following: @versions (missing updates: @missing_updates).', + [ + '%module' => $module_info->info['name'], + '@missing_updates' => implode(', ', $missing_updates), + '@versions' => implode(', ', $versions), + ] + ); + $requirements[$module . '_post_update_removed'] = [ + 'title' => t('Missing updates for: @module', ['@module' => $module_info->info['name']]), + 'description' => $description, + 'severity' => RequirementSeverity::Error, + ]; + } + } + } + + if (empty($missing_updates)) { + foreach ($update_registry->getAllEquivalentUpdates() as $module => $equivalent_updates) { + $module_info = $module_extension_list->get($module); + foreach ($equivalent_updates as $future_update => $data) { + $future_update_function_name = $module . '_update_' . $future_update; + $ran_update_function_name = $module . '_update_' . $data['ran_update']; + // If an update was marked as an equivalent by a previous update, + // and both the previous update and the equivalent update are not + // found in the current code base, prevent updating. This indicates + // a site attempting to go 'backwards' in terms of database schema. + // @see \Drupal\Core\Update\UpdateHookRegistry::markFutureUpdateEquivalent() + if (!function_exists($ran_update_function_name) && !function_exists($future_update_function_name)) { + // If the module is provided by core prepend helpful text as the + // module does not exist in composer or Drupal.org. + if (str_starts_with($module_info->getPathname(), 'core/')) { + $future_version_string = 'Drupal Core ' . $data['future_version_string']; + } + else { + $future_version_string = $data['future_version_string']; + } + $requirements[$module . '_equivalent_update_missing'] = [ + 'title' => t('Missing updates for: @module', ['@module' => $module_info->info['name']]), + 'description' => t('The version of the %module module that you are attempting to update to is missing update @future_update (which was marked as an equivalent by @ran_update). Update to at least @future_version_string.', [ + '%module' => $module_info->info['name'], + '@ran_update' => $data['ran_update'], + '@future_update' => $future_update, + '@future_version_string' => $future_version_string, + ]), + 'severity' => RequirementSeverity::Error, + ]; + break; + } + } + } + } + } + + // Add warning when twig debug option is enabled. + if ($phase === 'runtime') { + $development_settings = \Drupal::keyValue('development_settings'); + $twig_debug = $development_settings->get('twig_debug', FALSE); + $twig_cache_disable = $development_settings->get('twig_cache_disable', FALSE); + if ($twig_debug || $twig_cache_disable) { + $requirements['twig_debug_enabled'] = [ + 'title' => t('Twig development mode'), + 'value' => t('Twig development mode settings are turned on. Go to @link to disable them.', [ + '@link' => Link::createFromRoute( + 'development settings page', + 'system.development_settings', + )->toString(), + ]), + 'severity' => RequirementSeverity::Warning, + ]; + } + $render_cache_disabled = $development_settings->get('disable_rendered_output_cache_bins', FALSE); + if ($render_cache_disabled) { + $requirements['render_cache_disabled'] = [ + 'title' => t('Markup caching disabled'), + 'value' => t('Render cache, dynamic page cache, and page cache are bypassed. Go to @link to enable them.', [ + '@link' => Link::createFromRoute( + 'development settings page', + 'system.development_settings', + )->toString(), + ]), + 'severity' => RequirementSeverity::Warning, + ]; + } + } + + return $requirements; + } + + /** + * Display requirements from security advisories. + * + * @param array[] $requirements + * The requirements array as specified in hook_requirements(). + */ + public static function systemAdvisoriesRequirements(array &$requirements): void { + if (!\Drupal::config('system.advisories')->get('enabled')) { + return; + } + + /** @var \Drupal\system\SecurityAdvisories\SecurityAdvisoriesFetcher $fetcher */ + $fetcher = \Drupal::service('system.sa_fetcher'); + try { + $advisories = $fetcher->getSecurityAdvisories(TRUE, 5); + } + catch (ClientExceptionInterface $exception) { + $requirements['system_advisories']['title'] = t('Critical security announcements'); + $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; + } + + if (!empty($advisories)) { + $advisory_links = []; + $severity = RequirementSeverity::Warning; + foreach ($advisories as $advisory) { + if (!$advisory->isPsa()) { + $severity = RequirementSeverity::Error; + } + $advisory_links[] = new Link($advisory->getTitle(), Url::fromUri($advisory->getUrl())); + } + $requirements['system_advisories']['title'] = t('Critical security announcements'); + $requirements['system_advisories']['severity'] = $severity; + $requirements['system_advisories']['description'] = [ + 'list' => [ + '#theme' => 'item_list', + '#items' => $advisory_links, + ], + ]; + if (\Drupal::moduleHandler()->moduleExists('help')) { + $requirements['system_advisories']['description']['help_link'] = Link::createFromRoute( + 'What are critical security announcements?', + 'help.page', ['name' => 'system'], + ['fragment' => 'security-advisories'] + )->toRenderable(); + } + } + } + +} diff --git a/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php b/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php index 670fbc06cf7..2ce434e0fd3 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) { @@ -564,7 +562,7 @@ class GDToolkit extends ImageToolkitBase { } $tempFile = fopen('php://memory', 'r+'); - $supported = imageavif(imagecreatetruecolor(1, 1), $tempFile, 0, 10) && fstat($tempFile)['size'] > 0; + $supported = function_exists('imageavif') && imageavif(imagecreatetruecolor(1, 1), $tempFile, 0, 10) && fstat($tempFile)['size'] > 0; fclose($tempFile); return $supported; @@ -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..5bbaa8a5436 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -5,30 +5,9 @@ * Install, update and uninstall functions for the system module. */ -use Drupal\Component\FileSystem\FileSystem as FileSystemComponent; -use Drupal\Component\Utility\Bytes; use Drupal\Component\Utility\Crypt; -use Drupal\Component\Utility\Environment; -use Drupal\Component\Utility\OpCodeCache; -use Drupal\Component\Utility\Unicode; -use Drupal\Core\Database\Database; -use Drupal\Core\DrupalKernel; -use Drupal\Core\Extension\ExtensionLifecycle; -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; -use Drupal\Core\StreamWrapper\PublicStream; -use Drupal\Core\StringTranslation\ByteSizeMarkup; -use Drupal\Core\StringTranslation\PluralTranslatableMarkup; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Update\EquivalentUpdate; -use Drupal\Core\Url; -use Drupal\Core\Utility\Error; -use Psr\Http\Client\ClientExceptionInterface; -use Symfony\Component\HttpFoundation\Request; // cspell:ignore quickedit @@ -61,1565 +40,6 @@ const DRUPAL_CORE_REMOVED_THEME_LIST = [ ]; /** - * Implements hook_requirements(). - */ -function system_requirements($phase): array { - global $install_state; - - // Get the current default PHP requirements for this version of Drupal. - $minimum_supported_php = PhpRequirements::getMinimumSupportedPhp(); - - // Reset the extension lists. - /** @var \Drupal\Core\Extension\ModuleExtensionList $module_extension_list */ - $module_extension_list = \Drupal::service('extension.list.module'); - $module_extension_list->reset(); - /** @var \Drupal\Core\Extension\ThemeExtensionList $theme_extension_list */ - $theme_extension_list = \Drupal::service('extension.list.theme'); - $theme_extension_list->reset(); - $requirements = []; - - // Report Drupal version - if ($phase == 'runtime') { - $requirements['drupal'] = [ - 'title' => t('Drupal'), - 'value' => \Drupal::VERSION, - 'severity' => REQUIREMENT_INFO, - 'weight' => -10, - ]; - - // Display the currently active installation profile, if the site - // is not running the default installation profile. - $profile = \Drupal::installProfile(); - if ($profile != 'standard' && !empty($profile)) { - $info = $module_extension_list->getExtensionInfo($profile); - $requirements['install_profile'] = [ - 'title' => t('Installation profile'), - 'value' => t('%profile_name (%profile%version)', [ - '%profile_name' => $info['name'], - '%profile' => $profile, - '%version' => !empty($info['version']) ? '-' . $info['version'] : '', - ]), - 'severity' => REQUIREMENT_INFO, - 'weight' => -9, - ]; - } - - // Gather all obsolete and experimental modules being enabled. - $obsolete_extensions = []; - $deprecated_modules = []; - $experimental_modules = []; - $enabled_modules = \Drupal::moduleHandler()->getModuleList(); - foreach ($enabled_modules as $module => $data) { - $info = $module_extension_list->getExtensionInfo($module); - if (isset($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER])) { - if ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::EXPERIMENTAL) { - $experimental_modules[$module] = $info['name']; - } - elseif ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::DEPRECATED) { - $deprecated_modules[] = ['name' => $info['name'], 'lifecycle_link' => $info['lifecycle_link']]; - } - elseif ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::OBSOLETE) { - $obsolete_extensions[$module] = ['name' => $info['name'], 'lifecycle_link' => $info['lifecycle_link']]; - } - } - } - - // Warn if any experimental modules are installed. - if (!empty($experimental_modules)) { - $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, - ]; - } - // Warn if any deprecated modules are installed. - if (!empty($deprecated_modules)) { - foreach ($deprecated_modules as $deprecated_module) { - $deprecated_modules_link_list[] = (string) Link::fromTextAndUrl($deprecated_module['name'], Url::fromUri($deprecated_module['lifecycle_link']))->toString(); - } - $requirements['deprecated_modules'] = [ - 'title' => t('Deprecated modules installed'), - 'value' => t('Deprecated modules found: %module_list.', [ - '%module_list' => Markup::create(implode(', ', $deprecated_modules_link_list)), - ]), - 'severity' => REQUIREMENT_WARNING, - ]; - } - - // Gather all obsolete and experimental themes being installed. - $experimental_themes = []; - $deprecated_themes = []; - $installed_themes = \Drupal::service('theme_handler')->listInfo(); - foreach ($installed_themes as $theme => $data) { - $info = $theme_extension_list->getExtensionInfo($theme); - if (isset($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER])) { - if ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::EXPERIMENTAL) { - $experimental_themes[$theme] = $info['name']; - } - elseif ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::DEPRECATED) { - $deprecated_themes[] = ['name' => $info['name'], 'lifecycle_link' => $info['lifecycle_link']]; - } - elseif ($info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::OBSOLETE) { - $obsolete_extensions[$theme] = ['name' => $info['name'], 'lifecycle_link' => $info['lifecycle_link']]; - } - } - } - - // Warn if any experimental themes are installed. - if (!empty($experimental_themes)) { - $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, - ]; - } - - // Warn if any deprecated themes are installed. - if (!empty($deprecated_themes)) { - foreach ($deprecated_themes as $deprecated_theme) { - $deprecated_themes_link_list[] = (string) Link::fromTextAndUrl($deprecated_theme['name'], Url::fromUri($deprecated_theme['lifecycle_link']))->toString(); - - } - $requirements['deprecated_themes'] = [ - 'title' => t('Deprecated themes installed'), - 'value' => t('Deprecated themes found: %theme_list.', [ - '%theme_list' => Markup::create(implode(', ', $deprecated_themes_link_list)), - ]), - 'severity' => REQUIREMENT_WARNING, - ]; - } - - // Warn if any obsolete extensions (themes or modules) are installed. - if (!empty($obsolete_extensions)) { - foreach ($obsolete_extensions as $obsolete_extension) { - $obsolete_extensions_link_list[] = (string) Link::fromTextAndUrl($obsolete_extension['name'], Url::fromUri($obsolete_extension['lifecycle_link']))->toString(); - } - $requirements['obsolete_extensions'] = [ - 'title' => t('Obsolete extensions installed'), - 'value' => t('Obsolete extensions found: %extensions. Obsolete extensions are provided only so that they can be uninstalled cleanly. You should immediately <a href=":uninstall_url">uninstall these extensions</a> since they may be removed in a future release.', [ - '%extensions' => Markup::create(implode(', ', $obsolete_extensions_link_list)), - ':uninstall_url' => Url::fromRoute('system.modules_uninstall')->toString(), - ]), - 'severity' => REQUIREMENT_WARNING, - ]; - } - _system_advisories_requirements($requirements); - } - - // Web server information. - $request_object = \Drupal::request(); - $software = $request_object->server->get('SERVER_SOFTWARE'); - $requirements['webserver'] = [ - 'title' => t('Web server'), - 'value' => $software, - ]; - - // Tests clean URL support. - if ($phase == 'install' && $install_state['interactive'] && !$request_object->query->has('rewrite') && str_contains($software, 'Apache')) { - // If the Apache rewrite module is not enabled, Apache version must be >= - // 2.2.16 because of the FallbackResource directive in the root .htaccess - // file. Since the Apache version reported by the server is dependent on the - // ServerTokens setting in httpd.conf, we may not be able to determine if a - // given config is valid. Thus we are unable to use version_compare() as we - // need have three possible outcomes: the version of Apache is greater than - // 2.2.16, is less than 2.2.16, or cannot be determined accurately. In the - // first case, we encourage the use of mod_rewrite; in the second case, we - // raise an error regarding the minimum Apache version; in the third case, - // we raise a warning that the current version of Apache may not be - // supported. - $rewrite_warning = FALSE; - $rewrite_error = FALSE; - $apache_version_string = 'Apache'; - - // Determine the Apache version number: major, minor and revision. - if (preg_match('/Apache\/(\d+)\.?(\d+)?\.?(\d+)?/', $software, $matches)) { - $apache_version_string = $matches[0]; - - // Major version number - if ($matches[1] < 2) { - $rewrite_error = TRUE; - } - elseif ($matches[1] == 2) { - if (!isset($matches[2])) { - $rewrite_warning = TRUE; - } - elseif ($matches[2] < 2) { - $rewrite_error = TRUE; - } - elseif ($matches[2] == 2) { - if (!isset($matches[3])) { - $rewrite_warning = TRUE; - } - elseif ($matches[3] < 16) { - $rewrite_error = TRUE; - } - } - } - } - else { - $rewrite_warning = TRUE; - } - - if ($rewrite_warning) { - $requirements['apache_version'] = [ - 'title' => t('Apache version'), - 'value' => $apache_version_string, - 'severity' => REQUIREMENT_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]), - ]; - } - - if ($rewrite_error) { - $requirements['Apache version'] = [ - 'title' => t('Apache version'), - 'value' => $apache_version_string, - 'severity' => REQUIREMENT_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']), - ]; - } - - if (!$rewrite_error && !$rewrite_warning) { - $requirements['rewrite_module'] = [ - 'title' => t('Clean URLs'), - 'value' => t('Disabled'), - 'severity' => REQUIREMENT_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']), - ]; - } - } - - // Verify the user is running a supported PHP version. - // If the site is running a recommended version of PHP, just display it - // as an informational message on the status report. This will be overridden - // with an error or warning if the site is running older PHP versions for - // which Drupal has already or will soon drop support. - $phpversion = $phpversion_label = phpversion(); - if ($phase === 'runtime') { - $phpversion_label = t('@phpversion (<a href=":url">more information</a>)', [ - '@phpversion' => $phpversion, - ':url' => (new Url('system.php'))->toString(), - ]); - } - $requirements['php'] = [ - 'title' => t('PHP'), - 'value' => $phpversion_label, - ]; - - // Check if the PHP version is below what Drupal supports. - if (version_compare($phpversion, $minimum_supported_php) < 0) { - $requirements['php']['description'] = t('Your PHP installation is too old. Drupal requires at least PHP %version. 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.', - [ - '%version' => $minimum_supported_php, - '%recommended' => \Drupal::RECOMMENDED_PHP, - ':php_requirements' => 'https://www.drupal.org/docs/system-requirements/php-requirements', - ] - ); - - // If the PHP version is also below the absolute minimum allowed, it's not - // 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; - 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; - } - // 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; - } - - // Test for PHP extensions. - $requirements['php_extensions'] = [ - 'title' => t('PHP extensions'), - ]; - - $missing_extensions = []; - $required_extensions = [ - 'date', - 'dom', - 'filter', - 'gd', - 'hash', - 'json', - 'pcre', - 'pdo', - 'session', - 'SimpleXML', - 'SPL', - 'tokenizer', - 'xml', - 'zlib', - ]; - foreach ($required_extensions as $extension) { - if (!extension_loaded($extension)) { - $missing_extensions[] = $extension; - } - } - - if (!empty($missing_extensions)) { - $description = t('Drupal requires you to enable the PHP extensions in the following list (see the <a href=":system_requirements">system requirements page</a> for more information):', [ - ':system_requirements' => 'https://www.drupal.org/docs/system-requirements', - ]); - - // We use twig inline_template to avoid twig's autoescape. - $description = [ - '#type' => 'inline_template', - '#template' => '{{ description }}{{ missing_extensions }}', - '#context' => [ - 'description' => $description, - 'missing_extensions' => [ - '#theme' => 'item_list', - '#items' => $missing_extensions, - ], - ], - ]; - - $requirements['php_extensions']['value'] = t('Disabled'); - $requirements['php_extensions']['severity'] = REQUIREMENT_ERROR; - $requirements['php_extensions']['description'] = $description; - } - else { - $requirements['php_extensions']['value'] = t('Enabled'); - } - - if ($phase == 'install' || $phase == 'runtime') { - // Check to see if OPcache is installed. - if (!OpCodeCache::isEnabled()) { - $requirements['php_opcache'] = [ - 'value' => t('Not enabled'), - 'severity' => REQUIREMENT_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.'), - ]; - } - else { - $requirements['php_opcache']['value'] = t('Enabled'); - } - $requirements['php_opcache']['title'] = t('PHP OPcode caching'); - } - - // Check to see if APCu is installed and configured correctly. - if ($phase == 'runtime' && PHP_SAPI != 'cli') { - $requirements['php_apcu_enabled']['title'] = t('PHP APCu caching'); - $requirements['php_apcu_available']['title'] = t('PHP APCu available caching'); - if (extension_loaded('apcu') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN)) { - $memory_info = apcu_sma_info(TRUE); - $apcu_actual_size = ByteSizeMarkup::create($memory_info['seg_size'] * $memory_info['num_seg']); - $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']['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, - ]); - } - 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']['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']['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']['value'] = t('Memory available: @available.', [ - '@available' => ByteSizeMarkup::create($memory_info['avail_mem']), - ]); - } - } - else { - $requirements['php_apcu_enabled'] += [ - 'value' => t('Not enabled'), - 'severity' => REQUIREMENT_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.'), - ]; - } - } - - if ($phase != 'update') { - // Test whether we have a good source of random bytes. - $requirements['php_random_bytes'] = [ - 'title' => t('Random number generation'), - ]; - try { - $bytes = random_bytes(10); - if (strlen($bytes) != 10) { - throw new \Exception("Tried to generate 10 random bytes, generated '" . strlen($bytes) . "'"); - } - $requirements['php_random_bytes']['value'] = t('Successful'); - } - catch (\Exception $e) { - // If /dev/urandom is not available on a UNIX-like system, check whether - // open_basedir restrictions are the cause. - $open_basedir_blocks_urandom = FALSE; - if (DIRECTORY_SEPARATOR === '/' && !@is_readable('/dev/urandom')) { - $open_basedir = ini_get('open_basedir'); - if ($open_basedir) { - $open_basedir_paths = explode(PATH_SEPARATOR, $open_basedir); - $open_basedir_blocks_urandom = !array_intersect(['/dev', '/dev/', '/dev/urandom'], $open_basedir_paths); - } - } - $args = [ - ':drupal-php' => 'https://www.drupal.org/docs/system-requirements/php-requirements', - '%exception_message' => $e->getMessage(), - ]; - if ($open_basedir_blocks_urandom) { - $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. The most likely cause is that open_basedir restrictions are in effect and /dev/urandom is not on the allowed list. See the <a href=":drupal-php">system requirements</a> page for more information. %exception_message', $args); - } - else { - $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; - } - } - - if ($phase === 'runtime' && PHP_SAPI !== 'cli') { - if (!function_exists('fastcgi_finish_request') && !function_exists('litespeed_finish_request') && !ob_get_status()) { - $requirements['output_buffering'] = [ - 'title' => t('Output Buffering'), - 'error_value' => t('Not enabled'), - 'severity' => REQUIREMENT_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>.'), - ]; - } - } - - if ($phase == 'install' || $phase == 'update') { - // Test for PDO (database). - $requirements['database_extensions'] = [ - 'title' => t('Database support'), - ]; - - // Make sure PDO is available. - $database_ok = extension_loaded('pdo'); - if (!$database_ok) { - $pdo_message = t('Your web server does not appear to support PDO (PHP Data Objects). Ask your hosting provider if they support the native PDO extension. See the <a href=":link">system requirements</a> page for more information.', [ - ':link' => 'https://www.drupal.org/docs/system-requirements/php-requirements#database', - ]); - } - else { - // Make sure at least one supported database driver exists. - if (empty(Database::getDriverList()->getInstallableList())) { - $database_ok = FALSE; - $pdo_message = t('Your web server does not appear to support any common PDO database extensions. Check with your hosting provider to see if they support PDO (PHP Data Objects) and offer any databases that <a href=":drupal-databases">Drupal supports</a>.', [ - ':drupal-databases' => 'https://www.drupal.org/docs/system-requirements/database-server-requirements', - ]); - } - // Make sure the native PDO extension is available, not the older PEAR - // version. (See install_verify_pdo() for details.) - if (!defined('PDO::ATTR_DEFAULT_FETCH_MODE')) { - $database_ok = FALSE; - $pdo_message = t('Your web server seems to have the wrong version of PDO installed. Drupal requires the PDO extension from PHP core. This system has the older PECL version. See the <a href=":link">system requirements</a> page for more information.', [ - ':link' => 'https://www.drupal.org/docs/system-requirements/php-requirements#database', - ]); - } - } - - if (!$database_ok) { - $requirements['database_extensions']['value'] = t('Disabled'); - $requirements['database_extensions']['severity'] = REQUIREMENT_ERROR; - $requirements['database_extensions']['description'] = $pdo_message; - } - else { - $requirements['database_extensions']['value'] = t('Enabled'); - } - } - - if ($phase === 'runtime' || $phase === 'update') { - // Database information. - $class = Database::getConnection()->getConnectionOptions()['namespace'] . '\\Install\\Tasks'; - /** @var \Drupal\Core\Database\Install\Tasks $tasks */ - $tasks = new $class(); - $requirements['database_system'] = [ - 'title' => t('Database system'), - 'value' => $tasks->name(), - ]; - $requirements['database_system_version'] = [ - 'title' => t('Database system version'), - 'value' => Database::getConnection()->version(), - ]; - - $errors = $tasks->engineVersionRequirementsCheck(); - $error_count = count($errors); - if ($error_count > 0) { - $error_message = [ - '#theme' => 'item_list', - '#items' => $errors, - // 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']['description'] = $error_message; - } - } - - if ($phase === 'runtime' || $phase === 'update') { - // Test database JSON support. - $requirements['database_support_json'] = [ - 'title' => t('Database support for JSON'), - 'severity' => REQUIREMENT_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; - } - } - - // Test PHP memory_limit - $memory_limit = ini_get('memory_limit'); - $requirements['php_memory_limit'] = [ - 'title' => t('PHP memory limit'), - 'value' => $memory_limit == -1 ? t('-1 (Unlimited)') : $memory_limit, - ]; - - if (!Environment::checkMemoryLimit(\Drupal::MINIMUM_PHP_MEMORY_LIMIT, $memory_limit)) { - $description = []; - if ($phase == 'install') { - $description['phase'] = t('Consider increasing your PHP memory limit to %memory_minimum_limit to help prevent errors in the installation process.', ['%memory_minimum_limit' => \Drupal::MINIMUM_PHP_MEMORY_LIMIT]); - } - elseif ($phase == 'update') { - $description['phase'] = t('Consider increasing your PHP memory limit to %memory_minimum_limit to help prevent errors in the update process.', ['%memory_minimum_limit' => \Drupal::MINIMUM_PHP_MEMORY_LIMIT]); - } - elseif ($phase == 'runtime') { - $description['phase'] = t('Depending on your configuration, Drupal can run with a %memory_limit PHP memory limit. However, a %memory_minimum_limit PHP memory limit or above is recommended, especially if your site uses additional custom or contributed modules.', ['%memory_limit' => $memory_limit, '%memory_minimum_limit' => \Drupal::MINIMUM_PHP_MEMORY_LIMIT]); - } - - if (!empty($description['phase'])) { - if ($php_ini_path = get_cfg_var('cfg_file_path')) { - $description['memory'] = t('Increase the memory limit by editing the memory_limit parameter in the file %configuration-file and then restart your web server (or contact your system administrator or hosting provider for assistance).', ['%configuration-file' => $php_ini_path]); - } - else { - $description['memory'] = t('Contact your system administrator or hosting provider for assistance with increasing your PHP memory limit.'); - } - - $handbook_link = t('For more information, see the online handbook entry for <a href=":memory-limit">increasing the PHP memory limit</a>.', [':memory-limit' => 'https://www.drupal.org/node/207036']); - - $description = [ - '#type' => 'inline_template', - '#template' => '{{ description_phase }} {{ description_memory }} {{ handbook }}', - '#context' => [ - 'description_phase' => $description['phase'], - 'description_memory' => $description['memory'], - 'handbook' => $handbook_link, - ], - ]; - - $requirements['php_memory_limit']['description'] = $description; - $requirements['php_memory_limit']['severity'] = REQUIREMENT_WARNING; - } - } - - // Test if configuration files and directory are writable. - if ($phase == 'runtime') { - $conf_errors = []; - // Find the site path. Kernel service is not always available at this point, - // but is preferred, when available. - if (\Drupal::hasService('kernel')) { - $site_path = \Drupal::getContainer()->getParameter('site.path'); - } - else { - $site_path = DrupalKernel::findSitePath(Request::createFromGlobals()); - } - // Allow system administrators to disable permissions hardening for the site - // directory. This allows additional files in the site directory to be - // updated when they are managed in a version control system. - if (Settings::get('skip_permissions_hardening')) { - $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; - } - else { - $error_value = t('Not protected'); - // In normal operation, writable files or directories are an error. - $file_protection_severity = REQUIREMENT_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]); - } - } - foreach (['settings.php', 'settings.local.php', 'services.yml'] as $conf_file) { - $full_path = $site_path . '/' . $conf_file; - if (file_exists($full_path) && !drupal_verify_install_file($full_path, FILE_EXIST | FILE_READABLE | FILE_NOT_WRITABLE, 'file', !Settings::get('skip_permissions_hardening'))) { - $conf_errors[] = t("The file %file is not protected from modifications and poses a security risk. You must change the file's permissions to be non-writable.", ['%file' => $full_path]); - } - } - if (!empty($conf_errors)) { - if (count($conf_errors) == 1) { - $description = $conf_errors[0]; - } - else { - // We use twig inline_template to avoid double escaping. - $description = [ - '#type' => 'inline_template', - '#template' => '{{ configuration_error_list }}', - '#context' => [ - 'configuration_error_list' => [ - '#theme' => 'item_list', - '#items' => $conf_errors, - ], - ], - ]; - } - $requirements['configuration_files'] = [ - 'value' => $error_value, - 'severity' => $file_protection_severity, - 'description' => $description, - ]; - } - else { - $requirements['configuration_files'] = [ - 'value' => t('Protected'), - ]; - } - $requirements['configuration_files']['title'] = t('Configuration files'); - } - - // Test the contents of the .htaccess files. - if ($phase == 'runtime') { - // Try to write the .htaccess files first, to prevent false alarms in case - // (for example) the /tmp directory was wiped. - /** @var \Drupal\Core\File\HtaccessWriterInterface $htaccessWriter */ - $htaccessWriter = \Drupal::service("file.htaccess_writer"); - $htaccessWriter->ensure(); - foreach ($htaccessWriter->defaultProtectedDirs() as $protected_dir) { - $htaccess_file = $protected_dir->getPath() . '/.htaccess'; - // Check for the string which was added to the recommended .htaccess file - // in the latest security update. - if (!file_exists($htaccess_file) || !($contents = @file_get_contents($htaccess_file)) || !str_contains($contents, 'Drupal_Security_Do_Not_Remove_See_SA_2013_003')) { - $url = 'https://www.drupal.org/SA-CORE-2013-003'; - $requirements[$htaccess_file] = [ - // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString - 'title' => new TranslatableMarkup($protected_dir->getTitle()), - 'value' => t('Not fully protected'), - 'severity' => REQUIREMENT_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()]), - ]; - } - } - } - - // Report cron status. - if ($phase == 'runtime') { - $cron_config = \Drupal::config('system.cron'); - // Cron warning threshold defaults to two days. - $threshold_warning = $cron_config->get('threshold.requirements_warning'); - // Cron error threshold defaults to two weeks. - $threshold_error = $cron_config->get('threshold.requirements_error'); - - // Determine when cron last ran. - $cron_last = \Drupal::state()->get('system.cron_last'); - if (!is_numeric($cron_last)) { - $cron_last = \Drupal::state()->get('install_time', 0); - } - - // Determine severity based on time since cron last ran. - $severity = REQUIREMENT_INFO; - $request_time = \Drupal::time()->getRequestTime(); - if ($request_time - $cron_last > $threshold_error) { - $severity = REQUIREMENT_ERROR; - } - elseif ($request_time - $cron_last > $threshold_warning) { - $severity = REQUIREMENT_WARNING; - } - - // Set summary and description based on values determined above. - $summary = t('Last run @time ago', ['@time' => \Drupal::service('date.formatter')->formatTimeDiffSince($cron_last)]); - - $requirements['cron'] = [ - 'title' => t('Cron maintenance tasks'), - 'severity' => $severity, - 'value' => $summary, - ]; - if ($severity != REQUIREMENT_INFO) { - $requirements['cron']['description'][] = [ - [ - '#markup' => t('Cron has not run recently.'), - '#suffix' => ' ', - ], - [ - '#markup' => t('For more information, see the online handbook entry for <a href=":cron-handbook">configuring cron jobs</a>.', [':cron-handbook' => 'https://www.drupal.org/docs/administering-a-drupal-site/cron-automated-tasks/cron-automated-tasks-overview']), - '#suffix' => ' ', - ], - ]; - } - $requirements['cron']['description'][] = [ - [ - '#type' => 'link', - '#prefix' => '(', - '#title' => t('more information'), - '#suffix' => ')', - '#url' => Url::fromRoute('system.cron_settings'), - ], - [ - '#prefix' => '<span class="cron-description__run-cron">', - '#suffix' => '</span>', - '#type' => 'link', - '#title' => t('Run cron'), - '#url' => Url::fromRoute('system.run_cron'), - ], - ]; - } - if ($phase != 'install') { - $directories = [ - PublicStream::basePath(), - // By default no private files directory is configured. For private files - // to be secure the admin needs to provide a path outside the webroot. - PrivateStream::basePath(), - \Drupal::service('file_system')->getTempDirectory(), - ]; - } - - // During an install we need to make assumptions about the file system - // unless overrides are provided in settings.php. - if ($phase == 'install') { - $directories = []; - if ($file_public_path = Settings::get('file_public_path')) { - $directories[] = $file_public_path; - } - else { - // If we are installing Drupal, the settings.php file might not exist yet - // in the intended site directory, so don't require it. - $request = Request::createFromGlobals(); - $site_path = DrupalKernel::findSitePath($request); - $directories[] = $site_path . '/files'; - } - if ($file_private_path = Settings::get('file_private_path')) { - $directories[] = $file_private_path; - } - if (Settings::get('file_temp_path')) { - $directories[] = Settings::get('file_temp_path'); - } - else { - // If the temporary directory is not overridden use an appropriate - // temporary path for the system. - $directories[] = FileSystemComponent::getOsTemporaryDirectory(); - } - } - - // Check the config directory if it is defined in settings.php. If it isn't - // defined, the installer will create a valid config directory later, but - // during runtime we must always display an error. - $config_sync_directory = Settings::get('config_sync_directory'); - if (!empty($config_sync_directory)) { - // If we're installing Drupal try and create the config sync directory. - if (!is_dir($config_sync_directory) && $phase == 'install') { - \Drupal::service('file_system')->prepareDirectory($config_sync_directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); - } - if (!is_dir($config_sync_directory)) { - if ($phase == 'install') { - $description = t('An automated attempt to create the directory %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>.', ['%directory' => $config_sync_directory, ':handbook_url' => 'https://www.drupal.org/server-permissions']); - } - else { - $description = t('The directory %directory does not exist.', ['%directory' => $config_sync_directory]); - } - $requirements['config sync directory'] = [ - 'title' => t('Configuration sync directory'), - 'description' => $description, - 'severity' => REQUIREMENT_ERROR, - ]; - } - } - if ($phase != 'install' && empty($config_sync_directory)) { - $requirements['config sync directory'] = [ - '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, - ]; - } - - $requirements['file system'] = [ - 'title' => t('File system'), - ]; - - $error = ''; - // For installer, create the directories if possible. - foreach ($directories as $directory) { - if (!$directory) { - continue; - } - if ($phase == 'install') { - \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); - } - $is_writable = is_writable($directory); - $is_directory = is_dir($directory); - if (!$is_writable || !$is_directory) { - $description = ''; - $requirements['file system']['value'] = t('Not writable'); - if (!$is_directory) { - $error = t('The directory %directory does not exist.', ['%directory' => $directory]); - } - else { - $error = t('The directory %directory is not writable.', ['%directory' => $directory]); - } - // The files directory requirement check is done only during install and - // runtime. - if ($phase == 'runtime') { - $description = t('You may need to set the correct directory at the <a href=":admin-file-system">file system settings page</a> or change the current directory\'s permissions so that it is writable.', [':admin-file-system' => Url::fromRoute('system.file_system_settings')->toString()]); - } - elseif ($phase == 'install') { - // For the installer UI, we need different wording. 'value' will - // be treated as version, so provide none there. - $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']); - $requirements['file system']['value'] = ''; - } - if (!empty($description)) { - $description = [ - '#type' => 'inline_template', - '#template' => '{{ error }} {{ description }}', - '#context' => [ - 'error' => $error, - 'description' => $description, - ], - ]; - $requirements['file system']['description'] = $description; - $requirements['file system']['severity'] = REQUIREMENT_ERROR; - } - } - else { - // This function can be called before the config_cache table has been - // created. - if ($phase == 'install' || \Drupal::config('system.file')->get('default_scheme') == 'public') { - $requirements['file system']['value'] = t('Writable (<em>public</em> download method)'); - } - else { - $requirements['file system']['value'] = t('Writable (<em>private</em> download method)'); - } - } - } - - // See if updates are available in update.php. - if ($phase == 'runtime') { - $requirements['update'] = [ - 'title' => t('Database updates'), - 'value' => t('Up to date'), - ]; - - // Check installed modules. - $has_pending_updates = FALSE; - /** @var \Drupal\Core\Update\UpdateHookRegistry $update_registry */ - $update_registry = \Drupal::service('update.update_hook_registry'); - foreach (\Drupal::moduleHandler()->getModuleList() as $module => $filename) { - $updates = $update_registry->getAvailableUpdates($module); - if ($updates) { - $default = $update_registry->getInstalledVersion($module); - if (max($updates) > $default) { - $has_pending_updates = TRUE; - break; - } - } - } - if (!$has_pending_updates) { - /** @var \Drupal\Core\Update\UpdateRegistry $post_update_registry */ - $post_update_registry = \Drupal::service('update.post_update_registry'); - $missing_post_update_functions = $post_update_registry->getPendingUpdateFunctions(); - if (!empty($missing_post_update_functions)) { - $has_pending_updates = TRUE; - } - } - - if ($has_pending_updates) { - $requirements['update']['severity'] = REQUIREMENT_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()]); - } - - $requirements['entity_update'] = [ - 'title' => t('Entity/field definitions'), - 'value' => t('Up to date'), - ]; - // Verify that no entity updates are pending. - if ($change_list = \Drupal::entityDefinitionUpdateManager()->getChangeSummary()) { - $build = []; - foreach ($change_list as $entity_type_id => $changes) { - $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id); - $build[] = [ - '#theme' => 'item_list', - '#title' => $entity_type->getLabel(), - '#items' => $changes, - ]; - } - - $entity_update_issues = \Drupal::service('renderer')->renderInIsolation($build); - $requirements['entity_update']['severity'] = REQUIREMENT_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]); - } - } - - // Display the deployment identifier if set. - if ($phase == 'runtime') { - if ($deployment_identifier = Settings::get('deployment_identifier')) { - $requirements['deployment identifier'] = [ - 'title' => t('Deployment identifier'), - 'value' => $deployment_identifier, - 'severity' => REQUIREMENT_INFO, - ]; - } - } - - // Verify the update.php access setting - if ($phase == 'runtime') { - if (Settings::get('update_free_access')) { - $requirements['update access'] = [ - 'value' => t('Not protected'), - 'severity' => REQUIREMENT_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\']']), - ]; - } - else { - $requirements['update access'] = [ - 'value' => t('Protected'), - ]; - } - $requirements['update access']['title'] = t('Access to update.php'); - } - - // Display an error if a newly introduced dependency in a module is not - // resolved. - if ($phase === 'update' || $phase === 'runtime') { - $create_extension_incompatibility_list = function (array $extension_names, PluralTranslatableMarkup $description, PluralTranslatableMarkup $title, TranslatableMarkup|string $message = '', TranslatableMarkup|string $additional_description = '') { - if ($message === '') { - $message = new TranslatableMarkup('Review the <a href=":url"> suggestions for resolving this incompatibility</a> to repair your installation, and then re-run update.php.', [':url' => 'https://www.drupal.org/docs/updating-drupal/troubleshooting-database-updates']); - } - // Use an inline twig template to: - // - Concatenate MarkupInterface objects and preserve safeness. - // - Use the item_list theme for the extension list. - $template = [ - '#type' => 'inline_template', - '#template' => '{{ description }}{{ extensions }}{{ additional_description }}<br>', - '#context' => [ - 'extensions' => [ - '#theme' => 'item_list', - ], - ], - ]; - $template['#context']['extensions']['#items'] = $extension_names; - $template['#context']['description'] = $description; - $template['#context']['additional_description'] = $additional_description; - return [ - 'title' => $title, - 'value' => [ - 'list' => $template, - 'handbook_link' => [ - '#markup' => $message, - ], - ], - 'severity' => REQUIREMENT_ERROR, - ]; - }; - $profile = \Drupal::installProfile(); - $files = $module_extension_list->getList(); - $files += $theme_extension_list->getList(); - $core_incompatible_extensions = []; - $php_incompatible_extensions = []; - foreach ($files as $extension_name => $file) { - // Ignore uninstalled extensions and installation profiles. - if (!$file->status || $extension_name == $profile) { - continue; - } - - $name = $file->info['name']; - if (!empty($file->info['core_incompatible'])) { - $core_incompatible_extensions[$file->info['type']][] = $name; - } - - // Check the extension's PHP version. - $php = $file->info['php']; - if (version_compare($php, PHP_VERSION, '>')) { - $php_incompatible_extensions[$file->info['type']][] = $name; - } - - // Check the module's required modules. - /** @var \Drupal\Core\Extension\Dependency $requirement */ - foreach ($file->requires as $requirement) { - $required_module = $requirement->getName(); - // Check if the module exists. - if (!isset($files[$required_module])) { - $requirements["$extension_name-$required_module"] = [ - 'title' => t('Unresolved dependency'), - 'description' => t('@name requires this module.', ['@name' => $name]), - 'value' => t('@required_name (Missing)', ['@required_name' => $required_module]), - 'severity' => REQUIREMENT_ERROR, - ]; - continue; - } - // Check for an incompatible version. - $required_file = $files[$required_module]; - $required_name = $required_file->info['name']; - // Remove CORE_COMPATIBILITY- only from the start of the string. - $version = preg_replace('/^(' . \Drupal::CORE_COMPATIBILITY . '\-)/', '', $required_file->info['version'] ?? ''); - if (!$requirement->isCompatible($version)) { - $requirements["$extension_name-$required_module"] = [ - '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, - ]; - continue; - } - } - } - if (!empty($core_incompatible_extensions['module'])) { - $requirements['module_core_incompatible'] = $create_extension_incompatibility_list( - $core_incompatible_extensions['module'], - new PluralTranslatableMarkup( - count($core_incompatible_extensions['module']), - 'The following module is installed, but it is incompatible with Drupal @version:', - 'The following modules are installed, but they are incompatible with Drupal @version:', - ['@version' => \Drupal::VERSION] - ), - new PluralTranslatableMarkup( - count($core_incompatible_extensions['module']), - 'Incompatible module', - 'Incompatible modules' - ) - ); - } - if (!empty($core_incompatible_extensions['theme'])) { - $requirements['theme_core_incompatible'] = $create_extension_incompatibility_list( - $core_incompatible_extensions['theme'], - new PluralTranslatableMarkup( - count($core_incompatible_extensions['theme']), - 'The following theme is installed, but it is incompatible with Drupal @version:', - 'The following themes are installed, but they are incompatible with Drupal @version:', - ['@version' => \Drupal::VERSION] - ), - new PluralTranslatableMarkup( - count($core_incompatible_extensions['theme']), - 'Incompatible theme', - 'Incompatible themes' - ) - ); - } - if (!empty($php_incompatible_extensions['module'])) { - $requirements['module_php_incompatible'] = $create_extension_incompatibility_list( - $php_incompatible_extensions['module'], - new PluralTranslatableMarkup( - count($php_incompatible_extensions['module']), - 'The following module is installed, but it is incompatible with PHP @version:', - 'The following modules are installed, but they are incompatible with PHP @version:', - ['@version' => phpversion()] - ), - new PluralTranslatableMarkup( - count($php_incompatible_extensions['module']), - 'Incompatible module', - 'Incompatible modules' - ) - ); - } - if (!empty($php_incompatible_extensions['theme'])) { - $requirements['theme_php_incompatible'] = $create_extension_incompatibility_list( - $php_incompatible_extensions['theme'], - new PluralTranslatableMarkup( - count($php_incompatible_extensions['theme']), - 'The following theme is installed, but it is incompatible with PHP @version:', - 'The following themes are installed, but they are incompatible with PHP @version:', - ['@version' => phpversion()] - ), - new PluralTranslatableMarkup( - count($php_incompatible_extensions['theme']), - 'Incompatible theme', - 'Incompatible themes' - ) - ); - } - - $extension_config = \Drupal::configFactory()->get('core.extension'); - - // Look for removed core modules. - $is_removed_module = function ($extension_name) use ($module_extension_list) { - return !$module_extension_list->exists($extension_name) - && array_key_exists($extension_name, DRUPAL_CORE_REMOVED_MODULE_LIST); - }; - $removed_modules = array_filter(array_keys($extension_config->get('module')), $is_removed_module); - if (!empty($removed_modules)) { - $list = []; - foreach ($removed_modules as $removed_module) { - $list[] = t('<a href=":url">@module</a>', [ - ':url' => "https://www.drupal.org/project/$removed_module", - '@module' => DRUPAL_CORE_REMOVED_MODULE_LIST[$removed_module], - ]); - } - $requirements['removed_module'] = $create_extension_incompatibility_list( - $list, - new PluralTranslatableMarkup( - count($removed_modules), - 'You must add the following contributed module and reload this page.', - 'You must add the following contributed modules and reload this page.' - ), - new PluralTranslatableMarkup( - count($removed_modules), - 'Removed core module', - 'Removed core modules' - ), - new TranslatableMarkup( - 'For more information read the <a href=":url">documentation on deprecated modules.</a>', - [':url' => 'https://www.drupal.org/node/3223395#s-recommendations-for-deprecated-modules'] - ), - new PluralTranslatableMarkup( - count($removed_modules), - 'This module is installed on your site but is no longer provided by Core.', - 'These modules are installed on your site but are no longer provided by Core.' - ), - ); - } - - // Look for removed core themes. - $is_removed_theme = function ($extension_name) use ($theme_extension_list) { - return !$theme_extension_list->exists($extension_name) - && array_key_exists($extension_name, DRUPAL_CORE_REMOVED_THEME_LIST); - }; - $removed_themes = array_filter(array_keys($extension_config->get('theme')), $is_removed_theme); - if (!empty($removed_themes)) { - $list = []; - foreach ($removed_themes as $removed_theme) { - $list[] = t('<a href=":url">@theme</a>', [ - ':url' => "https://www.drupal.org/project/$removed_theme", - '@theme' => DRUPAL_CORE_REMOVED_THEME_LIST[$removed_theme], - ]); - } - $requirements['removed_theme'] = $create_extension_incompatibility_list( - $list, - new PluralTranslatableMarkup( - count($removed_themes), - 'You must add the following contributed theme and reload this page.', - 'You must add the following contributed themes and reload this page.' - ), - new PluralTranslatableMarkup( - count($removed_themes), - 'Removed core theme', - 'Removed core themes' - ), - new TranslatableMarkup( - 'For more information read the <a href=":url">documentation on deprecated themes.</a>', - [':url' => 'https://www.drupal.org/node/3223395#s-recommendations-for-deprecated-themes'] - ), - new PluralTranslatableMarkup( - count($removed_themes), - 'This theme is installed on your site but is no longer provided by Core.', - 'These themes are installed on your site but are no longer provided by Core.' - ), - ); - } - - // Look for missing modules. - $is_missing_module = function ($extension_name) use ($module_extension_list) { - return !$module_extension_list->exists($extension_name) && !in_array($extension_name, array_keys(DRUPAL_CORE_REMOVED_MODULE_LIST), TRUE); - }; - $invalid_modules = array_filter(array_keys($extension_config->get('module')), $is_missing_module); - - if (!empty($invalid_modules)) { - $requirements['invalid_module'] = $create_extension_incompatibility_list( - $invalid_modules, - new PluralTranslatableMarkup( - count($invalid_modules), - 'The following module is marked as installed in the core.extension configuration, but it is missing:', - 'The following modules are marked as installed in the core.extension configuration, but they are missing:' - ), - new PluralTranslatableMarkup( - count($invalid_modules), - 'Missing or invalid module', - 'Missing or invalid modules' - ) - ); - } - - // Look for invalid themes. - $is_missing_theme = function ($extension_name) use (&$theme_extension_list) { - return !$theme_extension_list->exists($extension_name) && !in_array($extension_name, array_keys(DRUPAL_CORE_REMOVED_THEME_LIST), TRUE); - }; - $invalid_themes = array_filter(array_keys($extension_config->get('theme')), $is_missing_theme); - if (!empty($invalid_themes)) { - $requirements['invalid_theme'] = $create_extension_incompatibility_list( - $invalid_themes, - new PluralTranslatableMarkup( - count($invalid_themes), - 'The following theme is marked as installed in the core.extension configuration, but it is missing:', - 'The following themes are marked as installed in the core.extension configuration, but they are missing:' - ), - new PluralTranslatableMarkup( - count($invalid_themes), - 'Missing or invalid theme', - 'Missing or invalid themes' - ) - ); - } - } - - // Returns Unicode library status and errors. - $libraries = [ - Unicode::STATUS_SINGLEBYTE => t('Standard PHP'), - Unicode::STATUS_MULTIBYTE => t('PHP Mbstring Extension'), - Unicode::STATUS_ERROR => t('Error'), - ]; - $severities = [ - Unicode::STATUS_SINGLEBYTE => REQUIREMENT_WARNING, - Unicode::STATUS_MULTIBYTE => NULL, - Unicode::STATUS_ERROR => REQUIREMENT_ERROR, - ]; - $failed_check = Unicode::check(); - $library = Unicode::getStatus(); - - $requirements['unicode'] = [ - 'title' => t('Unicode library'), - 'value' => $libraries[$library], - 'severity' => $severities[$library], - ]; - switch ($failed_check) { - case 'mb_strlen': - $requirements['unicode']['description'] = t('Operations on Unicode strings are emulated on a best-effort basis. Install the <a href="http://php.net/mbstring">PHP mbstring extension</a> for improved Unicode support.'); - break; - - case 'mbstring.encoding_translation': - $requirements['unicode']['description'] = t('Multibyte string input conversion in PHP is active and must be disabled. Check the php.ini <em>mbstring.encoding_translation</em> setting. Refer to the <a href="http://php.net/mbstring">PHP mbstring documentation</a> for more information.'); - break; - } - - if ($phase == 'runtime') { - // Check for update status module. - if (!\Drupal::moduleHandler()->moduleExists('update')) { - $requirements['update status'] = [ - 'value' => t('Not enabled'), - 'severity' => REQUIREMENT_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(), - ]), - ]; - } - else { - $requirements['update status'] = [ - 'value' => t('Enabled'), - ]; - } - $requirements['update status']['title'] = t('Update notifications'); - - if (Settings::get('rebuild_access')) { - $requirements['rebuild access'] = [ - 'title' => t('Rebuild access'), - 'value' => t('Enabled'), - 'severity' => REQUIREMENT_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.'), - ]; - } - } - - // Check if the SameSite cookie attribute is set to a valid value. Since this - // involves checking whether we are using a secure connection this only makes - // sense inside an HTTP request, not on the command line. - if ($phase === 'runtime' && PHP_SAPI !== 'cli') { - $samesite = ini_get('session.cookie_samesite') ?: t('Not set'); - // Check if the SameSite attribute is set to a valid value. If it is set to - // 'None' the request needs to be done over HTTPS. - $valid = match ($samesite) { - 'Lax', 'Strict' => TRUE, - 'None' => $request_object->isSecure(), - default => FALSE, - }; - $requirements['php_session_samesite'] = [ - 'title' => t('SameSite cookie attribute'), - 'value' => $samesite, - 'severity' => $valid ? REQUIREMENT_OK : REQUIREMENT_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', - ]), - ]; - } - - // See if trusted host names have been configured, and warn the user if they - // are not set. - if ($phase == 'runtime') { - $trusted_host_patterns = Settings::get('trusted_host_patterns'); - if (empty($trusted_host_patterns)) { - $requirements['trusted_host_patterns'] = [ - '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, - ]; - } - else { - $requirements['trusted_host_patterns'] = [ - 'title' => t('Trusted Host Settings'), - 'value' => t('Enabled'), - 'description' => t('The trusted_host_patterns setting is set to allow %trusted_host_patterns', ['%trusted_host_patterns' => implode(', ', $trusted_host_patterns)]), - ]; - } - } - - // When the database driver is provided by a module, then check that the - // providing module is installed. - if ($phase === 'runtime' || $phase === 'update') { - $connection = Database::getConnection(); - $provider = $connection->getProvider(); - if ($provider !== 'core' && !\Drupal::moduleHandler()->moduleExists($provider)) { - $autoload = $connection->getConnectionOptions()['autoload'] ?? ''; - if (str_contains($autoload, 'src/Driver/Database/')) { - $post_update_registry = \Drupal::service('update.post_update_registry'); - $pending_updates = $post_update_registry->getPendingUpdateInformation(); - if (!in_array('enable_provider_database_driver', array_keys($pending_updates['system']['pending'] ?? []), TRUE)) { - // Only show the warning when the post update function has run and - // the module that is providing the database driver is not installed. - $requirements['database_driver_provided_by_module'] = [ - '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, - ]; - } - } - } - } - - // Check xdebug.max_nesting_level, as some pages will not work if it is too - // low. - if (extension_loaded('xdebug')) { - // Setting this value to 256 was considered adequate on Xdebug 2.3 - // (see http://bugs.xdebug.org/bug_view_page.php?bug_id=00001100) - $minimum_nesting_level = 256; - $current_nesting_level = ini_get('xdebug.max_nesting_level'); - - if ($current_nesting_level < $minimum_nesting_level) { - $requirements['xdebug_max_nesting_level'] = [ - '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, - ]; - } - } - - // Installations on Windows can run into limitations with MAX_PATH if the - // Drupal root directory is too deep in the filesystem. Generally this shows - // up in cached Twig templates and other public files with long directory or - // file names. There is no definite root directory depth below which Drupal is - // guaranteed to function correctly on Windows. Since problems are likely - // with more than 100 characters in the Drupal root path, show an error. - if (str_starts_with(PHP_OS, 'WIN')) { - $depth = strlen(realpath(DRUPAL_ROOT . '/' . PublicStream::basePath())); - if ($depth > 120) { - $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, - ]; - } - } - // Check to see if dates will be limited to 1901-2038. - if (PHP_INT_SIZE <= 4) { - $requirements['limited_date_range'] = [ - '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, - ]; - } - - // During installs from configuration don't support install profiles that - // implement hook_install. - if ($phase == 'install' && !empty($install_state['config_install_path'])) { - $install_hook = $install_state['parameters']['profile'] . '_install'; - if (function_exists($install_hook)) { - $requirements['config_install'] = [ - '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, - ]; - } - } - - if ($phase === 'runtime') { - $settings = Settings::getAll(); - if (array_key_exists('install_profile', $settings)) { - // The following message is only informational because not all site owners - // have access to edit their settings.php as it may be controlled by their - // hosting provider. - $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, - ]; - } - } - - // Ensure that no module has a current schema version that is lower than the - // one that was last removed. - if ($phase == 'update') { - $module_handler = \Drupal::moduleHandler(); - /** @var \Drupal\Core\Update\UpdateHookRegistry $update_registry */ - $update_registry = \Drupal::service('update.update_hook_registry'); - $module_list = []; - // hook_update_last_removed() is a procedural hook hook because we - // do not have classes loaded that would be needed. - // Simply inlining the old hook mechanism is better than making - // ModuleInstaller::invoke() public. - foreach ($module_handler->getModuleList() as $module => $extension) { - $function = $module . '_update_last_removed'; - if (function_exists($function)) { - $last_removed = $function(); - if ($last_removed && $last_removed > $update_registry->getInstalledVersion($module)) { - - /** @var \Drupal\Core\Extension\Extension $module_info */ - $module_info = $module_extension_list->get($module); - $module_list[$module] = [ - 'name' => $module_info->info['name'], - 'last_removed' => $last_removed, - 'installed_version' => $update_registry->getInstalledVersion($module), - ]; - } - } - } - - // If user module is in the list then only show a specific message for - // Drupal core. - if (isset($module_list['user'])) { - $requirements['user_update_last_removed'] = [ - 'title' => t('The version of Drupal you are trying to update from is too old'), - 'description' => t('Updating to Drupal @current_major is only supported from Drupal version @required_min_version or higher. If you are trying to update from an older version, first update to the latest version of Drupal @previous_major. (<a href=":url">Drupal upgrade guide</a>)', [ - '@current_major' => 10, - '@required_min_version' => '9.4.0', - '@previous_major' => 9, - ':url' => 'https://www.drupal.org/docs/upgrading-drupal/drupal-8-and-higher', - ]), - 'severity' => REQUIREMENT_ERROR, - ]; - } - else { - foreach ($module_list as $module => $data) { - $requirements[$module . '_update_last_removed'] = [ - 'title' => t('Unsupported schema version: @module', ['@module' => $data['name']]), - 'description' => t('The installed version of the %module module is too old to update. Update to an intermediate version first (last removed version: @last_removed_version, installed version: @installed_version).', [ - '%module' => $data['name'], - '@last_removed_version' => $data['last_removed'], - '@installed_version' => $data['installed_version'], - ]), - 'severity' => REQUIREMENT_ERROR, - ]; - } - } - // Also check post-updates. Only do this if we're not already showing an - // error for hook_update_N(). - $missing_updates = []; - if (empty($module_list)) { - $existing_updates = \Drupal::service('keyvalue')->get('post_update')->get('existing_updates', []); - $post_update_registry = \Drupal::service('update.post_update_registry'); - $modules = \Drupal::moduleHandler()->getModuleList(); - foreach ($modules as $module => $extension) { - $module_info = $module_extension_list->get($module); - $removed_post_updates = $post_update_registry->getRemovedPostUpdates($module); - if ($missing_updates = array_diff(array_keys($removed_post_updates), $existing_updates)) { - $versions = array_unique(array_intersect_key($removed_post_updates, array_flip($missing_updates))); - $description = new PluralTranslatableMarkup(count($versions), - 'The installed version of the %module module is too old to update. Update to a version prior to @versions first (missing updates: @missing_updates).', - 'The installed version of the %module module is too old to update. Update first to a version prior to all of the following: @versions (missing updates: @missing_updates).', - [ - '%module' => $module_info->info['name'], - '@missing_updates' => implode(', ', $missing_updates), - '@versions' => implode(', ', $versions), - ] - ); - $requirements[$module . '_post_update_removed'] = [ - 'title' => t('Missing updates for: @module', ['@module' => $module_info->info['name']]), - 'description' => $description, - 'severity' => REQUIREMENT_ERROR, - ]; - } - } - } - - if (empty($missing_updates)) { - foreach ($update_registry->getAllEquivalentUpdates() as $module => $equivalent_updates) { - $module_info = $module_extension_list->get($module); - foreach ($equivalent_updates as $future_update => $data) { - $future_update_function_name = $module . '_update_' . $future_update; - $ran_update_function_name = $module . '_update_' . $data['ran_update']; - // If an update was marked as an equivalent by a previous update, and - // both the previous update and the equivalent update are not found in - // the current code base, prevent updating. This indicates a site - // attempting to go 'backwards' in terms of database schema. - // @see \Drupal\Core\Update\UpdateHookRegistry::markFutureUpdateEquivalent() - if (!function_exists($ran_update_function_name) && !function_exists($future_update_function_name)) { - // If the module is provided by core prepend helpful text as the - // module does not exist in composer or Drupal.org. - if (str_starts_with($module_info->getPathname(), 'core/')) { - $future_version_string = 'Drupal Core ' . $data['future_version_string']; - } - else { - $future_version_string = $data['future_version_string']; - } - $requirements[$module . '_equivalent_update_missing'] = [ - 'title' => t('Missing updates for: @module', ['@module' => $module_info->info['name']]), - 'description' => t('The version of the %module module that you are attempting to update to is missing update @future_update (which was marked as an equivalent by @ran_update). Update to at least @future_version_string.', [ - '%module' => $module_info->info['name'], - '@ran_update' => $data['ran_update'], - '@future_update' => $future_update, - '@future_version_string' => $future_version_string, - ]), - 'severity' => REQUIREMENT_ERROR, - ]; - break; - } - } - } - } - } - - // Add warning when twig debug option is enabled. - if ($phase === 'runtime') { - $development_settings = \Drupal::keyValue('development_settings'); - $twig_debug = $development_settings->get('twig_debug', FALSE); - $twig_cache_disable = $development_settings->get('twig_cache_disable', FALSE); - if ($twig_debug || $twig_cache_disable) { - $requirements['twig_debug_enabled'] = [ - 'title' => t('Twig development mode'), - 'value' => t('Twig development mode settings are turned on. Go to @link to disable them.', [ - '@link' => Link::createFromRoute( - 'development settings page', - 'system.development_settings', - )->toString(), - ]), - 'severity' => REQUIREMENT_WARNING, - ]; - } - $render_cache_disabled = $development_settings->get('disable_rendered_output_cache_bins', FALSE); - if ($render_cache_disabled) { - $requirements['render_cache_disabled'] = [ - 'title' => t('Markup caching disabled'), - 'value' => t('Render cache, dynamic page cache, and page cache are bypassed. Go to @link to enable them.', [ - '@link' => Link::createFromRoute( - 'development settings page', - 'system.development_settings', - )->toString(), - ]), - 'severity' => REQUIREMENT_WARNING, - ]; - } - } - - return $requirements; -} - -/** * Implements hook_install(). */ function system_install(): void { @@ -1685,57 +105,6 @@ function system_update_11200(): void { } /** - * Display requirements from security advisories. - * - * @param array[] $requirements - * The requirements array as specified in hook_requirements(). - */ -function _system_advisories_requirements(array &$requirements): void { - if (!\Drupal::config('system.advisories')->get('enabled')) { - return; - } - - /** @var \Drupal\system\SecurityAdvisories\SecurityAdvisoriesFetcher $fetcher */ - $fetcher = \Drupal::service('system.sa_fetcher'); - try { - $advisories = $fetcher->getSecurityAdvisories(TRUE, 5); - } - catch (ClientExceptionInterface $exception) { - $requirements['system_advisories']['title'] = t('Critical security announcements'); - $requirements['system_advisories']['severity'] = REQUIREMENT_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; - } - - if (!empty($advisories)) { - $advisory_links = []; - $severity = REQUIREMENT_WARNING; - foreach ($advisories as $advisory) { - if (!$advisory->isPsa()) { - $severity = REQUIREMENT_ERROR; - } - $advisory_links[] = new Link($advisory->getTitle(), Url::fromUri($advisory->getUrl())); - } - $requirements['system_advisories']['title'] = t('Critical security announcements'); - $requirements['system_advisories']['severity'] = $severity; - $requirements['system_advisories']['description'] = [ - 'list' => [ - '#theme' => 'item_list', - '#items' => $advisory_links, - ], - ]; - if (\Drupal::moduleHandler()->moduleExists('help')) { - $requirements['system_advisories']['description']['help_link'] = Link::createFromRoute( - 'What are critical security announcements?', - 'help.page', ['name' => 'system'], - ['fragment' => 'security-advisories'] - )->toRenderable(); - } - } -} - -/** * Invalidate container because the module handler has changed. */ function system_update_11100(): void { diff --git a/core/modules/system/system.libraries.yml b/core/modules/system/system.libraries.yml index 03baf83d3bb..cd7165bffbb 100644 --- a/core/modules/system/system.libraries.yml +++ b/core/modules/system/system.libraries.yml @@ -7,10 +7,7 @@ base: css/components/container-inline.module.css: { weight: -10 } css/components/clearfix.module.css: { weight: -10 } 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: version: VERSION diff --git a/core/modules/system/system.module b/core/modules/system/system.module index dbda1e1b6b4..0fbcf9b6c1f 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -307,6 +307,10 @@ function system_authorized_batch_process() { } /** + * @} End of "defgroup authorize". + */ + +/** * Implements hook_preprocess_HOOK() for block templates. */ function system_preprocess_block(&$variables): void { diff --git a/core/modules/system/templates/details.html.twig b/core/modules/system/templates/details.html.twig index 20e4ea7193e..dcb1cf354ce 100644 --- a/core/modules/system/templates/details.html.twig +++ b/core/modules/system/templates/details.html.twig @@ -34,7 +34,11 @@ </div> {% endif %} - {{ description }} + {%- if description -%} + {% set description_attributes = create_attribute({id: attributes['aria-describedby']}) %} + <div{{ description_attributes }}>{{ description }}</div> + {%- endif -%} + {{ children }} {{ value }} </details> diff --git a/core/modules/system/templates/field-multiple-value-form.html.twig b/core/modules/system/templates/field-multiple-value-form.html.twig index 832b9f61794..ecd268690b4 100644 --- a/core/modules/system/templates/field-multiple-value-form.html.twig +++ b/core/modules/system/templates/field-multiple-value-form.html.twig @@ -16,7 +16,7 @@ * - attributes: HTML attributes to apply to the description container. * - button: "Add another item" button. * - * @see template_preprocess_field_multiple_value_form() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() * * @ingroup themeable */ diff --git a/core/modules/system/templates/field.html.twig b/core/modules/system/templates/field.html.twig index 1497678b50a..2bef0a02e6f 100644 --- a/core/modules/system/templates/field.html.twig +++ b/core/modules/system/templates/field.html.twig @@ -33,7 +33,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() * * @ingroup themeable */ diff --git a/core/modules/system/templates/image.html.twig b/core/modules/system/templates/image.html.twig index 6411eaa3d07..1f6d19d6c3e 100644 --- a/core/modules/system/templates/image.html.twig +++ b/core/modules/system/templates/image.html.twig @@ -7,7 +7,7 @@ * - attributes: HTML attributes for the img tag. * - style_name: (optional) The name of the image style applied. * - * @see template_preprocess_image() + * @see \Drupal\Core\Theme\ImagePreprocess::preprocessImage() * * @ingroup themeable */ diff --git a/core/modules/system/templates/install-page.html.twig b/core/modules/system/templates/install-page.html.twig index f6091fd3b95..d9144e6a154 100644 --- a/core/modules/system/templates/install-page.html.twig +++ b/core/modules/system/templates/install-page.html.twig @@ -6,7 +6,7 @@ * All available variables are mirrored in page.html.twig. * Some may be blank but they are provided for consistency. * - * @see template_preprocess_install_page() + * @see \Drupal\Core\Theme\ThemePreprocess::preprocessInstallPage() * * @ingroup themeable */ diff --git a/core/modules/system/templates/item-list.html.twig b/core/modules/system/templates/item-list.html.twig index 1462cf41ae0..c2babdab978 100644 --- a/core/modules/system/templates/item-list.html.twig +++ b/core/modules/system/templates/item-list.html.twig @@ -16,7 +16,7 @@ * - context: A list of contextual data associated with the list. May contain: * - list_style: The custom list style. * - * @see template_preprocess_item_list() + * @see \Drupal\Core\Theme\ThemePreprocess::preprocessItemList() * * @ingroup themeable */ diff --git a/core/modules/system/templates/maintenance-page.html.twig b/core/modules/system/templates/maintenance-page.html.twig index 748ed5a3aa4..06fb6065f7a 100644 --- a/core/modules/system/templates/maintenance-page.html.twig +++ b/core/modules/system/templates/maintenance-page.html.twig @@ -6,7 +6,7 @@ * All available variables are mirrored in page.html.twig. * Some may be blank but they are provided for consistency. * - * @see template_preprocess_maintenance_page() + * @see \Drupal\Core\Theme\ThemePreprocess::preprocessMaintenancePage() * * @ingroup themeable */ diff --git a/core/modules/system/templates/menu-local-action.html.twig b/core/modules/system/templates/menu-local-action.html.twig index 0eb03a9534a..e0280d5fcbc 100644 --- a/core/modules/system/templates/menu-local-action.html.twig +++ b/core/modules/system/templates/menu-local-action.html.twig @@ -7,7 +7,7 @@ * - attributes: HTML attributes for the wrapper element. * - link: A rendered link element. * - * @see template_preprocess_menu_local_action() + * @see \Drupal\Core\Menu\MenuPreprocess::preprocessMenuLocalAction() * * @ingroup themeable */ diff --git a/core/modules/system/templates/menu-local-task.html.twig b/core/modules/system/templates/menu-local-task.html.twig index ec02a8d530c..b2a743940a7 100644 --- a/core/modules/system/templates/menu-local-task.html.twig +++ b/core/modules/system/templates/menu-local-task.html.twig @@ -11,7 +11,7 @@ * Note: This template renders the content for each task item in * menu-local-tasks.html.twig. * - * @see template_preprocess_menu_local_task() + * @see \Drupal\Core\Menu\MenuPreprocess::preprocessMenuLocalTask() * * @ingroup themeable */ diff --git a/core/modules/system/templates/pager.html.twig b/core/modules/system/templates/pager.html.twig index 199f0578dbd..75047c1b95f 100644 --- a/core/modules/system/templates/pager.html.twig +++ b/core/modules/system/templates/pager.html.twig @@ -28,7 +28,7 @@ * at the first page. * - next: Present if the visible list of pages ends before the last page. * - * @see template_preprocess_pager() + * @see \Drupal\Core\Pager\PagerPreprocess::preprocessPager() * * @ingroup themeable */ diff --git a/core/modules/system/templates/region.html.twig b/core/modules/system/templates/region.html.twig index 219e14b0a4b..ddcaaa192df 100644 --- a/core/modules/system/templates/region.html.twig +++ b/core/modules/system/templates/region.html.twig @@ -9,7 +9,7 @@ * - region: The name of the region variable as defined in the theme's * .info.yml file. * - * @see template_preprocess_region() + * @see \Drupal\Core\Theme\ThemePreprocess::preprocessRegion() * * @ingroup themeable */ diff --git a/core/modules/system/templates/table.html.twig b/core/modules/system/templates/table.html.twig index cfcb0bf976c..6a73cc1152a 100644 --- a/core/modules/system/templates/table.html.twig +++ b/core/modules/system/templates/table.html.twig @@ -38,7 +38,7 @@ * - no_striping: A boolean indicating that the row should receive no striping. * - header_columns: The number of columns in the header. * - * @see template_preprocess_table() + * @see \Drupal\Core\Theme\ThemePreprocess::preprocessTable() * * @ingroup themeable */ 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/ajax_test/ajax_test.routing.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml index f9ca25544ab..95b7a1e4c0f 100644 --- a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml +++ b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml @@ -43,6 +43,13 @@ ajax_test.insert_links_inline_wrapper: requirements: _access: 'TRUE' +ajax_test.insert_links_table_wrapper: + path: '/ajax-test/insert-table-wrapper' + defaults: + _controller: '\Drupal\ajax_test\Controller\AjaxTestController::insertLinksTableWrapper' + requirements: + _access: 'TRUE' + ajax_test.dialog_close: path: '/ajax-test/dialog-close' defaults: diff --git a/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php b/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php index aea5322ce2d..b0adea8e7c7 100644 --- a/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php +++ b/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php @@ -141,6 +141,36 @@ class AjaxTestController { } /** + * Returns a render array of links that directly Drupal.ajax(). + * + * @return array + * Renderable array of AJAX response contents. + */ + public function insertLinksTableWrapper(): array { + $build['links'] = [ + 'ajax_target' => [ + '#markup' => '<div class="ajax-target-wrapper"><table><tbody id="ajax-target"></tbody></table></div>', + ], + 'links' => [ + '#theme' => 'links', + '#attached' => ['library' => ['ajax_test/ajax_insert']], + ], + ]; + + $build['links']['links']['#links']['table-row'] = [ + 'title' => 'Link table-row', + 'url' => Url::fromRoute('ajax_test.ajax_render_types', ['type' => 'table-row']), + 'attributes' => [ + 'class' => ['ajax-insert'], + 'data-method' => 'html', + 'data-effect' => 'none', + ], + ]; + + return $build; + } + + /** * Returns a render array that will be rendered by AjaxRenderer. * * Verifies that the response incorporates JavaScript settings generated @@ -336,6 +366,7 @@ class AjaxTestController { 'comment-not-wrapped' => '<!-- COMMENT --><div class="comment-not-wrapped">comment-not-wrapped</div>', 'svg' => '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect x="0" y="0" height="10" width="10" fill="green"></rect></svg>', 'empty' => '', + 'table-row' => '<tr><td>table-row</td></tr>', ]; $render_multiple_root = [ 'mixed' => ' foo <!-- COMMENT --> foo bar<div class="a class"><p>some string</p></div> additional not wrapped strings, <!-- ANOTHER COMMENT --> <p>final string</p>', diff --git a/core/modules/system/tests/modules/common_test/common_test.module b/core/modules/system/tests/modules/common_test/common_test.module index 5d7fdc3dc6b..5a17e7a6fcd 100644 --- a/core/modules/system/tests/modules/common_test/common_test.module +++ b/core/modules/system/tests/modules/common_test/common_test.module @@ -10,8 +10,9 @@ declare(strict_types=1); /** * Implements hook_TYPE_alter(). * - * Same as common_test_drupal_alter_alter(), but here, we verify that themes - * can also alter and come last. + * Same as CommonTestHooks::drupalAlterAlter(), but here, we verify that themes + * can also alter and come last. This file gets included by + * CommonTestHooks::includeThemeFunction(). */ function olivero_drupal_alter_alter(&$data, &$arg2 = NULL, &$arg3 = NULL): void { // Alter first argument. @@ -40,27 +41,3 @@ function olivero_drupal_alter_alter(&$data, &$arg2 = NULL, &$arg3 = NULL): void } } } - -/** - * Implements MODULE_preprocess(). - * - * @see RenderTest::testDrupalRenderThemePreprocessAttached() - */ -function common_test_preprocess(&$variables, $hook): void { - if (!\Drupal::state()->get('theme_preprocess_attached_test', FALSE)) { - return; - } - $variables['#attached']['library'][] = 'test/generic_preprocess'; -} - -/** - * Implements MODULE_preprocess_HOOK(). - * - * @see RenderTest::testDrupalRenderThemePreprocessAttached() - */ -function common_test_preprocess_common_test_render_element(&$variables): void { - if (!\Drupal::state()->get('theme_preprocess_attached_test', FALSE)) { - return; - } - $variables['#attached']['library'][] = 'test/specific_preprocess'; -} diff --git a/core/modules/system/tests/modules/common_test/src/Hook/CommonTestHooks.php b/core/modules/system/tests/modules/common_test/src/Hook/CommonTestHooks.php index a3e65453b04..aa93bfb5083 100644 --- a/core/modules/system/tests/modules/common_test/src/Hook/CommonTestHooks.php +++ b/core/modules/system/tests/modules/common_test/src/Hook/CommonTestHooks.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace Drupal\common_test\Hook; -use Drupal\Core\Language\LanguageInterface; -use Drupal\Core\Asset\AttachedAssetsInterface; use Drupal\Core\Hook\Attribute\Hook; /** @@ -59,53 +57,6 @@ class CommonTestHooks { } /** - * Implements hook_theme(). - */ - #[Hook('theme')] - public function theme() : array { - return [ - 'common_test_foo' => [ - 'variables' => [ - 'foo' => 'foo', - 'bar' => 'bar', - ], - ], - 'common_test_render_element' => [ - 'render element' => 'foo', - ], - ]; - } - - /** - * Implements hook_library_info_build(). - */ - #[Hook('library_info_build')] - public function libraryInfoBuild(): array { - $libraries = []; - if (\Drupal::state()->get('common_test.library_info_build_test')) { - $libraries['dynamic_library'] = ['version' => '1.0', 'css' => ['base' => ['common_test.css' => []]]]; - } - return $libraries; - } - - /** - * Implements hook_library_info_alter(). - */ - #[Hook('library_info_alter')] - public function libraryInfoAlter(&$libraries, $module): void { - if ($module === 'core' && isset($libraries['loadjs'])) { - // Change the version of loadjs to 0.0. - $libraries['loadjs']['version'] = '0.0'; - // Make loadjs depend on jQuery Form to test library dependencies. - $libraries['loadjs']['dependencies'][] = 'core/internal.jquery.form'; - } - // Alter the dynamically registered library definition. - if ($module === 'common_test' && isset($libraries['dynamic_library'])) { - $libraries['dynamic_library']['dependencies'] = ['core/jquery']; - } - } - - /** * Implements hook_cron(). * * System module should handle if a module does not catch an exception and @@ -118,80 +69,4 @@ class CommonTestHooks { throw new \Exception('Uncaught exception'); } - /** - * Implements hook_page_attachments(). - * - * @see \Drupal\system\Tests\Common\PageRenderTest::assertPageRenderHookExceptions() - */ - #[Hook('page_attachments')] - public function pageAttachments(array &$page): void { - $page['#attached']['library'][] = 'core/foo'; - $page['#attached']['library'][] = 'core/bar'; - $page['#cache']['tags'] = ['example']; - $page['#cache']['contexts'] = ['user.permissions']; - if (\Drupal::state()->get('common_test.hook_page_attachments.descendant_attached', FALSE)) { - $page['content']['#attached']['library'][] = 'core/jquery'; - } - if (\Drupal::state()->get('common_test.hook_page_attachments.render_array', FALSE)) { - $page['something'] = ['#markup' => 'test']; - } - if (\Drupal::state()->get('common_test.hook_page_attachments.early_rendering', FALSE)) { - // Do some early rendering. - $element = ['#markup' => '123']; - \Drupal::service('renderer')->render($element); - } - } - - /** - * Implements hook_page_attachments_alter(). - * - * @see \Drupal\system\Tests\Common\PageRenderTest::assertPageRenderHookExceptions() - */ - #[Hook('page_attachments_alter')] - public function pageAttachmentsAlter(array &$page): void { - // Remove a library that was added in common_test_page_attachments(), to - // test that this hook can do what it claims to do. - if (isset($page['#attached']['library']) && ($index = array_search('core/bar', $page['#attached']['library'])) && $index !== FALSE) { - unset($page['#attached']['library'][$index]); - } - $page['#attached']['library'][] = 'core/baz'; - $page['#cache']['tags'] = ['example']; - $page['#cache']['contexts'] = ['user.permissions']; - if (\Drupal::state()->get('common_test.hook_page_attachments_alter.descendant_attached', FALSE)) { - $page['content']['#attached']['library'][] = 'core/jquery'; - } - if (\Drupal::state()->get('common_test.hook_page_attachments_alter.render_array', FALSE)) { - $page['something'] = ['#markup' => 'test']; - } - } - - /** - * Implements hook_js_alter(). - * - * @see \Drupal\KernelTests\Core\Asset\AttachedAssetsTest::testAlter() - */ - #[Hook('js_alter')] - public function jsAlter(&$javascript, AttachedAssetsInterface $assets, LanguageInterface $language): void { - // Attach alter.js above tableselect.js. - $alter_js = \Drupal::service('extension.list.module')->getPath('common_test') . '/alter.js'; - if (array_key_exists($alter_js, $javascript) && array_key_exists('core/misc/tableselect.js', $javascript)) { - $javascript[$alter_js]['weight'] = $javascript['core/misc/tableselect.js']['weight'] - 1; - } - } - - /** - * Implements hook_js_settings_alter(). - * - * @see \Drupal\system\Tests\Common\JavaScriptTest::testHeaderSetting() - */ - #[Hook('js_settings_alter')] - public function jsSettingsAlter(&$settings, AttachedAssetsInterface $assets): void { - // Modify an existing setting. - if (array_key_exists('pluralDelimiter', $settings)) { - $settings['pluralDelimiter'] = '☃'; - } - // Add a setting. - $settings['foo'] = 'bar'; - } - } diff --git a/core/modules/system/tests/modules/common_test/src/Hook/CommonTestThemeHooks.php b/core/modules/system/tests/modules/common_test/src/Hook/CommonTestThemeHooks.php new file mode 100644 index 00000000000..f47116e8920 --- /dev/null +++ b/core/modules/system/tests/modules/common_test/src/Hook/CommonTestThemeHooks.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\common_test\Hook; + +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Asset\AttachedAssetsInterface; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Hook implementations for common_test. + */ +class CommonTestThemeHooks { + + /** + * Implements hook_theme(). + */ + #[Hook('theme')] + public function theme() : array { + return [ + 'common_test_foo' => [ + 'variables' => [ + 'foo' => 'foo', + 'bar' => 'bar', + ], + ], + 'common_test_render_element' => [ + 'render element' => 'foo', + ], + ]; + } + + /** + * Implements hook_library_info_build(). + */ + #[Hook('library_info_build')] + public function libraryInfoBuild(): array { + $libraries = []; + if (\Drupal::state()->get('common_test.library_info_build_test')) { + $libraries['dynamic_library'] = ['version' => '1.0', 'css' => ['base' => ['common_test.css' => []]]]; + } + return $libraries; + } + + /** + * Implements hook_library_info_alter(). + */ + #[Hook('library_info_alter')] + public function libraryInfoAlter(&$libraries, $module): void { + if ($module === 'core' && isset($libraries['loadjs'])) { + // Change the version of loadjs to 0.0. + $libraries['loadjs']['version'] = '0.0'; + // Make loadjs depend on jQuery Form to test library dependencies. + $libraries['loadjs']['dependencies'][] = 'core/internal.jquery.form'; + } + // Alter the dynamically registered library definition. + if ($module === 'common_test' && isset($libraries['dynamic_library'])) { + $libraries['dynamic_library']['dependencies'] = ['core/jquery']; + } + } + + /** + * Implements hook_page_attachments(). + * + * @see \Drupal\system\Tests\Common\PageRenderTest::assertPageRenderHookExceptions() + */ + #[Hook('page_attachments')] + public function pageAttachments(array &$page): void { + $page['#attached']['library'][] = 'core/foo'; + $page['#attached']['library'][] = 'core/bar'; + $page['#cache']['tags'] = ['example']; + $page['#cache']['contexts'] = ['user.permissions']; + if (\Drupal::state()->get('common_test.hook_page_attachments.descendant_attached', FALSE)) { + $page['content']['#attached']['library'][] = 'core/jquery'; + } + if (\Drupal::state()->get('common_test.hook_page_attachments.render_array', FALSE)) { + $page['something'] = ['#markup' => 'test']; + } + if (\Drupal::state()->get('common_test.hook_page_attachments.early_rendering', FALSE)) { + // Do some early rendering. + $element = ['#markup' => '123']; + \Drupal::service('renderer')->render($element); + } + } + + /** + * Implements hook_page_attachments_alter(). + * + * @see \Drupal\system\Tests\Common\PageRenderTest::assertPageRenderHookExceptions() + */ + #[Hook('page_attachments_alter')] + public function pageAttachmentsAlter(array &$page): void { + // Remove a library that was added in common_test_page_attachments(), to + // test that this hook can do what it claims to do. + if (isset($page['#attached']['library']) && ($index = array_search('core/bar', $page['#attached']['library'])) && $index !== FALSE) { + unset($page['#attached']['library'][$index]); + } + $page['#attached']['library'][] = 'core/baz'; + $page['#cache']['tags'] = ['example']; + $page['#cache']['contexts'] = ['user.permissions']; + if (\Drupal::state()->get('common_test.hook_page_attachments_alter.descendant_attached', FALSE)) { + $page['content']['#attached']['library'][] = 'core/jquery'; + } + if (\Drupal::state()->get('common_test.hook_page_attachments_alter.render_array', FALSE)) { + $page['something'] = ['#markup' => 'test']; + } + } + + /** + * Implements hook_js_alter(). + * + * @see \Drupal\KernelTests\Core\Asset\AttachedAssetsTest::testAlter() + */ + #[Hook('js_alter')] + public function jsAlter(&$javascript, AttachedAssetsInterface $assets, LanguageInterface $language): void { + // Attach alter.js above tableselect.js. + $alter_js = \Drupal::service('extension.list.module')->getPath('common_test') . '/alter.js'; + if (array_key_exists($alter_js, $javascript) && array_key_exists('core/misc/tableselect.js', $javascript)) { + $javascript[$alter_js]['weight'] = $javascript['core/misc/tableselect.js']['weight'] - 1; + } + } + + /** + * Implements hook_js_settings_alter(). + * + * @see \Drupal\system\Tests\Common\JavaScriptTest::testHeaderSetting() + */ + #[Hook('js_settings_alter')] + public function jsSettingsAlter(&$settings, AttachedAssetsInterface $assets): void { + // Modify an existing setting. + if (array_key_exists('pluralDelimiter', $settings)) { + $settings['pluralDelimiter'] = '☃'; + } + // Add a setting. + $settings['foo'] = 'bar'; + } + + /** + * Implements hook_preprocess(). + * + * @see RenderTest::testDrupalRenderThemePreprocessAttached() + */ + #[Hook('preprocess')] + public function preprocess(&$variables, $hook): void { + if (!\Drupal::state()->get('theme_preprocess_attached_test', FALSE)) { + return; + } + $variables['#attached']['library'][] = 'test/generic_preprocess'; + } + + /** + * Implements hook_preprocess_HOOK(). + * + * @see RenderTest::testDrupalRenderThemePreprocessAttached() + */ + #[Hook('preprocess_common_test_render_element')] + public function commonTestRenderElement(&$variables): void { + if (!\Drupal::state()->get('theme_preprocess_attached_test', FALSE)) { + return; + } + $variables['#attached']['library'][] = 'test/specific_preprocess'; + } + +} diff --git a/core/modules/system/tests/modules/container_initialize/container_initialize.info.yml b/core/modules/system/tests/modules/container_initialize/container_initialize.info.yml new file mode 100644 index 00000000000..46411d2ea54 --- /dev/null +++ b/core/modules/system/tests/modules/container_initialize/container_initialize.info.yml @@ -0,0 +1,5 @@ +name: 'Container initialize' +type: module +description: 'Support module for HookCollectorPass testing.' +package: Testing +version: VERSION diff --git a/core/modules/system/tests/modules/container_initialize/container_initialize.module b/core/modules/system/tests/modules/container_initialize/container_initialize.module new file mode 100644 index 00000000000..5c8e0aff74e --- /dev/null +++ b/core/modules/system/tests/modules/container_initialize/container_initialize.module @@ -0,0 +1,10 @@ +<?php + +/** + * @file + * Used to test bare container calls in .module files. + */ + +declare(strict_types=1); + +\Drupal::getContainer()->getParameter('site.path'); diff --git a/core/modules/system/tests/modules/delay_cache_tags_invalidation/src/Hook/DelayCacheTagsInvalidationHooks.php b/core/modules/system/tests/modules/delay_cache_tags_invalidation/src/Hook/DelayCacheTagsInvalidationHooks.php index c543a1d3e53..f444b69a427 100644 --- a/core/modules/system/tests/modules/delay_cache_tags_invalidation/src/Hook/DelayCacheTagsInvalidationHooks.php +++ b/core/modules/system/tests/modules/delay_cache_tags_invalidation/src/Hook/DelayCacheTagsInvalidationHooks.php @@ -25,11 +25,11 @@ class DelayCacheTagsInvalidationHooks { } // Read the pre-transaction cache writes. // @see \Drupal\KernelTests\Core\Cache\EndOfTransactionQueriesTest::testEntitySave() - \Drupal::state()->set('delay_cache_tags_invalidation_entity_test_insert' . '__pre-transaction_foobar', \Drupal::cache()->get('test_cache_pre-transaction_foobar')); - \Drupal::state()->set('delay_cache_tags_invalidation_entity_test_insert' . '__pre-transaction_entity_test_list', \Drupal::cache()->get('test_cache_pre-transaction_entity_test_list')); + \Drupal::state()->set('delay_cache_tags_invalidation_entity_test_insert__pre-transaction_foobar', \Drupal::cache()->get('test_cache_pre-transaction_foobar')); + \Drupal::state()->set('delay_cache_tags_invalidation_entity_test_insert__pre-transaction_entity_test_list', \Drupal::cache()->get('test_cache_pre-transaction_entity_test_list')); // Write during the transaction. - \Drupal::cache()->set('delay_cache_tags_invalidation_entity_test_insert' . '__during_transaction_foobar', 'something', Cache::PERMANENT, ['foobar']); - \Drupal::cache()->set('delay_cache_tags_invalidation_entity_test_insert' . '__during_transaction_entity_test_list', 'something', Cache::PERMANENT, ['entity_test_list']); + \Drupal::cache()->set('delay_cache_tags_invalidation_entity_test_insert__during_transaction_foobar', 'something', Cache::PERMANENT, ['foobar']); + \Drupal::cache()->set('delay_cache_tags_invalidation_entity_test_insert__during_transaction_entity_test_list', 'something', Cache::PERMANENT, ['entity_test_list']); // Trigger a nested entity save and hence a nested transaction. User::create(['name' => 'john doe', 'status' => 1])->save(); } @@ -42,8 +42,8 @@ class DelayCacheTagsInvalidationHooks { if ($entity->getAccountName() === 'john doe') { // Read the in-transaction cache writes. // @see delay_cache_tags_invalidation_entity_test_insert() - \Drupal::state()->set('delay_cache_tags_invalidation_user_insert' . '__during_transaction_foobar', \Drupal::cache()->get('delay_cache_tags_invalidation_entity_test_insert__during_transaction_foobar')); - \Drupal::state()->set('delay_cache_tags_invalidation_user_insert' . '__during_transaction_entity_test_list', \Drupal::cache()->get('delay_cache_tags_invalidation_entity_test_insert__during_transaction_entity_test_list')); + \Drupal::state()->set('delay_cache_tags_invalidation_user_insert__during_transaction_foobar', \Drupal::cache()->get('delay_cache_tags_invalidation_entity_test_insert__during_transaction_foobar')); + \Drupal::state()->set('delay_cache_tags_invalidation_user_insert__during_transaction_entity_test_list', \Drupal::cache()->get('delay_cache_tags_invalidation_entity_test_insert__during_transaction_entity_test_list')); } } diff --git a/core/modules/system/tests/modules/element_info_test/src/Element/Deprecated.php b/core/modules/system/tests/modules/element_info_test/src/Element/Deprecated.php index f54ad8f9a9a..c052c5a488f 100644 --- a/core/modules/system/tests/modules/element_info_test/src/Element/Deprecated.php +++ b/core/modules/system/tests/modules/element_info_test/src/Element/Deprecated.php @@ -17,7 +17,8 @@ class Deprecated extends RenderElementBase { * {@inheritdoc} */ public function __construct(array $configuration, $plugin_id, $plugin_definition) { - parent::__construct($configuration, $plugin_id, $plugin_definition); + $elementInfoManager = \Drupal::service('plugin.manager.element_info'); + parent::__construct($configuration, $plugin_id, $plugin_definition, $elementInfoManager); @trigger_error(__CLASS__ . ' is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. See https://www.drupal.org/node/3068104', E_USER_DEPRECATED); } diff --git a/core/modules/system/tests/modules/element_info_test/src/Hook/ElementInfoTestHooks.php b/core/modules/system/tests/modules/element_info_test/src/Hook/ElementInfoTestHooks.php index ea72bd033fb..53150495cb9 100644 --- a/core/modules/system/tests/modules/element_info_test/src/Hook/ElementInfoTestHooks.php +++ b/core/modules/system/tests/modules/element_info_test/src/Hook/ElementInfoTestHooks.php @@ -4,8 +4,9 @@ declare(strict_types=1); namespace Drupal\element_info_test\Hook; -use Drupal\element_info_test\ElementInfoTestNumberBuilder; use Drupal\Core\Hook\Attribute\Hook; +use Drupal\element_info_test\ElementInfoTestNumberBuilder; +use Drupal\element_info_test\Render\Element\Details; /** * Hook implementations for element_info_test. @@ -30,6 +31,9 @@ class ElementInfoTestHooks { if (\Drupal::state()->get('hook_element_plugin_alter:remove_weight', FALSE)) { unset($definitions['weight']); } + + $definitions['details']['class'] = Details::class; + $definitions['details']['provider'] = 'element_info_test'; } } diff --git a/core/modules/system/tests/modules/element_info_test/src/Render/Element/Details.php b/core/modules/system/tests/modules/element_info_test/src/Render/Element/Details.php new file mode 100644 index 00000000000..8e4d84558ca --- /dev/null +++ b/core/modules/system/tests/modules/element_info_test/src/Render/Element/Details.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\element_info_test\Render\Element; + +use Drupal\Core\Render\Attribute\RenderElement; +use Drupal\Core\Render\Element; +use Drupal\Core\Render\Element\RenderElementBase; + +/** + * Provides a render element for a details element. + * + * Properties: + * + * @property $title + * The title of the details container. Defaults to "Details". + * @property $open + * Indicates whether the container should be open by default. + * Defaults to FALSE. + * @property $custom + * Confirm that this class has been swapped properly. + * @property $summary_attributes + * An array of attributes to apply to the <summary> + * element. + */ +#[RenderElement('details')] +class Details extends RenderElementBase { + + /** + * {@inheritdoc} + */ + public function getInfo(): array { + return [ + '#open' => FALSE, + '#summary_attributes' => [], + '#custom' => 'Custom', + ]; + } + + /** + * Adds form element theming to details. + * + * @param array $element + * An associative array containing the properties and children of the + * details. + * + * @return array + * The modified element. + */ + public static function preRenderDetails($element): array { + Element::setAttributes($element, ['custom']); + + return $element; + } + +} diff --git a/core/modules/system/tests/modules/entity_crud_hook_test/src/Hook/EntityCrudHookTestHooks.php b/core/modules/system/tests/modules/entity_crud_hook_test/src/Hook/EntityCrudHookTestHooks.php index c552a268952..8e16b55b4c6 100644 --- a/core/modules/system/tests/modules/entity_crud_hook_test/src/Hook/EntityCrudHookTestHooks.php +++ b/core/modules/system/tests/modules/entity_crud_hook_test/src/Hook/EntityCrudHookTestHooks.php @@ -17,7 +17,7 @@ class EntityCrudHookTestHooks { */ #[Hook('entity_create')] public function entityCreate(EntityInterface $entity): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_create' . ' called for type ' . $entity->getEntityTypeId(); + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_create called for type ' . $entity->getEntityTypeId(); } /** @@ -25,7 +25,7 @@ class EntityCrudHookTestHooks { */ #[Hook('block_create')] public function blockCreate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_create' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_create called'; } /** @@ -33,7 +33,7 @@ class EntityCrudHookTestHooks { */ #[Hook('comment_create')] public function commentCreate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_create' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_create called'; } /** @@ -41,7 +41,7 @@ class EntityCrudHookTestHooks { */ #[Hook('file_create')] public function fileCreate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_create' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_create called'; } /** @@ -49,7 +49,7 @@ class EntityCrudHookTestHooks { */ #[Hook('node_create')] public function nodeCreate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_create' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_create called'; } /** @@ -57,7 +57,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_term_create')] public function taxonomyTermCreate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_create' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_create called'; } /** @@ -65,7 +65,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_vocabulary_create')] public function taxonomyVocabularyCreate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_create' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_create called'; } /** @@ -73,7 +73,7 @@ class EntityCrudHookTestHooks { */ #[Hook('user_create')] public function userCreate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_create' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_create called'; } /** @@ -81,7 +81,7 @@ class EntityCrudHookTestHooks { */ #[Hook('entity_presave')] public function entityPresave(EntityInterface $entity): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_presave' . ' called for type ' . $entity->getEntityTypeId(); + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_presave called for type ' . $entity->getEntityTypeId(); } /** @@ -89,7 +89,7 @@ class EntityCrudHookTestHooks { */ #[Hook('block_presave')] public function blockPresave(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_presave' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_presave called'; } /** @@ -97,7 +97,7 @@ class EntityCrudHookTestHooks { */ #[Hook('comment_presave')] public function commentPresave(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_presave' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_presave called'; } /** @@ -105,7 +105,7 @@ class EntityCrudHookTestHooks { */ #[Hook('file_presave')] public function filePresave(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_presave' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_presave called'; } /** @@ -113,7 +113,7 @@ class EntityCrudHookTestHooks { */ #[Hook('node_presave')] public function nodePresave(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_presave' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_presave called'; } /** @@ -121,7 +121,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_term_presave')] public function taxonomyTermPresave(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_presave' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_presave called'; } /** @@ -129,7 +129,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_vocabulary_presave')] public function taxonomyVocabularyPresave(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_presave' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_presave called'; } /** @@ -137,7 +137,7 @@ class EntityCrudHookTestHooks { */ #[Hook('user_presave')] public function userPresave(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_presave' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_presave called'; } /** @@ -145,7 +145,7 @@ class EntityCrudHookTestHooks { */ #[Hook('entity_insert')] public function entityInsert(EntityInterface $entity): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_insert' . ' called for type ' . $entity->getEntityTypeId(); + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_insert called for type ' . $entity->getEntityTypeId(); } /** @@ -153,7 +153,7 @@ class EntityCrudHookTestHooks { */ #[Hook('block_insert')] public function blockInsert(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_insert' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_insert called'; } /** @@ -161,7 +161,7 @@ class EntityCrudHookTestHooks { */ #[Hook('comment_insert')] public function commentInsert(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_insert' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_insert called'; } /** @@ -169,7 +169,7 @@ class EntityCrudHookTestHooks { */ #[Hook('file_insert')] public function fileInsert(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_insert' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_insert called'; } /** @@ -177,7 +177,7 @@ class EntityCrudHookTestHooks { */ #[Hook('node_insert')] public function nodeInsert(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_insert' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_insert called'; } /** @@ -185,7 +185,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_term_insert')] public function taxonomyTermInsert(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_insert' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_insert called'; } /** @@ -193,7 +193,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_vocabulary_insert')] public function taxonomyVocabularyInsert(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_insert' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_insert called'; } /** @@ -201,7 +201,7 @@ class EntityCrudHookTestHooks { */ #[Hook('user_insert')] public function userInsert(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_insert' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_insert called'; } /** @@ -209,7 +209,7 @@ class EntityCrudHookTestHooks { */ #[Hook('entity_preload')] public function entityPreload(array $entities, $type): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_preload' . ' called for type ' . $type; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_preload called for type ' . $type; } /** @@ -217,7 +217,7 @@ class EntityCrudHookTestHooks { */ #[Hook('entity_load')] public function entityLoad(array $entities, $type): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_load' . ' called for type ' . $type; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_load called for type ' . $type; } /** @@ -225,7 +225,7 @@ class EntityCrudHookTestHooks { */ #[Hook('block_load')] public function blockLoad(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_load' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_load called'; } /** @@ -233,7 +233,7 @@ class EntityCrudHookTestHooks { */ #[Hook('comment_load')] public function commentLoad(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_load' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_load called'; } /** @@ -241,7 +241,7 @@ class EntityCrudHookTestHooks { */ #[Hook('file_load')] public function fileLoad(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_load' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_load called'; } /** @@ -249,7 +249,7 @@ class EntityCrudHookTestHooks { */ #[Hook('node_load')] public function nodeLoad(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_load' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_load called'; } /** @@ -257,7 +257,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_term_load')] public function taxonomyTermLoad(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_load' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_load called'; } /** @@ -265,7 +265,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_vocabulary_load')] public function taxonomyVocabularyLoad(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_load' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_load called'; } /** @@ -273,7 +273,7 @@ class EntityCrudHookTestHooks { */ #[Hook('user_load')] public function userLoad(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_load' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_load called'; } /** @@ -281,7 +281,7 @@ class EntityCrudHookTestHooks { */ #[Hook('entity_update')] public function entityUpdate(EntityInterface $entity): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_update' . ' called for type ' . $entity->getEntityTypeId(); + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_update called for type ' . $entity->getEntityTypeId(); } /** @@ -289,7 +289,7 @@ class EntityCrudHookTestHooks { */ #[Hook('block_update')] public function blockUpdate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_update' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_update called'; } /** @@ -297,7 +297,7 @@ class EntityCrudHookTestHooks { */ #[Hook('comment_update')] public function commentUpdate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_update' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_update called'; } /** @@ -305,7 +305,7 @@ class EntityCrudHookTestHooks { */ #[Hook('file_update')] public function fileUpdate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_update' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_update called'; } /** @@ -313,7 +313,7 @@ class EntityCrudHookTestHooks { */ #[Hook('node_update')] public function nodeUpdate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_update' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_update called'; } /** @@ -321,7 +321,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_term_update')] public function taxonomyTermUpdate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_update' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_update called'; } /** @@ -329,7 +329,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_vocabulary_update')] public function taxonomyVocabularyUpdate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_update' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_update called'; } /** @@ -337,7 +337,7 @@ class EntityCrudHookTestHooks { */ #[Hook('user_update')] public function userUpdate(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_update' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_update called'; } /** @@ -345,7 +345,7 @@ class EntityCrudHookTestHooks { */ #[Hook('entity_predelete')] public function entityPredelete(EntityInterface $entity): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_predelete' . ' called for type ' . $entity->getEntityTypeId(); + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_predelete called for type ' . $entity->getEntityTypeId(); } /** @@ -353,7 +353,7 @@ class EntityCrudHookTestHooks { */ #[Hook('block_predelete')] public function blockPredelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_predelete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_predelete called'; } /** @@ -361,7 +361,7 @@ class EntityCrudHookTestHooks { */ #[Hook('comment_predelete')] public function commentPredelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_predelete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_predelete called'; } /** @@ -369,7 +369,7 @@ class EntityCrudHookTestHooks { */ #[Hook('file_predelete')] public function filePredelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_predelete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_predelete called'; } /** @@ -377,7 +377,7 @@ class EntityCrudHookTestHooks { */ #[Hook('node_predelete')] public function nodePredelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_predelete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_predelete called'; } /** @@ -385,7 +385,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_term_predelete')] public function taxonomyTermPredelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_predelete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_predelete called'; } /** @@ -393,7 +393,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_vocabulary_predelete')] public function taxonomyVocabularyPredelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_predelete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_predelete called'; } /** @@ -401,7 +401,7 @@ class EntityCrudHookTestHooks { */ #[Hook('user_predelete')] public function userPredelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_predelete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_predelete called'; } /** @@ -409,7 +409,7 @@ class EntityCrudHookTestHooks { */ #[Hook('entity_delete')] public function entityDelete(EntityInterface $entity): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_delete' . ' called for type ' . $entity->getEntityTypeId(); + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_entity_delete called for type ' . $entity->getEntityTypeId(); } /** @@ -417,7 +417,7 @@ class EntityCrudHookTestHooks { */ #[Hook('block_delete')] public function blockDelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_delete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_block_delete called'; } /** @@ -425,7 +425,7 @@ class EntityCrudHookTestHooks { */ #[Hook('comment_delete')] public function commentDelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_delete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_comment_delete called'; } /** @@ -433,7 +433,7 @@ class EntityCrudHookTestHooks { */ #[Hook('file_delete')] public function fileDelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_delete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_file_delete called'; } /** @@ -441,7 +441,7 @@ class EntityCrudHookTestHooks { */ #[Hook('node_delete')] public function nodeDelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_delete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_node_delete called'; } /** @@ -449,7 +449,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_term_delete')] public function taxonomyTermDelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_delete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_term_delete called'; } /** @@ -457,7 +457,7 @@ class EntityCrudHookTestHooks { */ #[Hook('taxonomy_vocabulary_delete')] public function taxonomyVocabularyDelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_delete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_taxonomy_vocabulary_delete called'; } /** @@ -465,7 +465,7 @@ class EntityCrudHookTestHooks { */ #[Hook('user_delete')] public function userDelete(): void { - $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_delete' . ' called'; + $GLOBALS['entity_crud_hook_test'][] = 'entity_crud_hook_test_user_delete called'; } } 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 deleted file mode 100644 index beaa3cd15b7..00000000000 --- a/core/modules/system/tests/modules/experimental_module_requirements_test/experimental_module_requirements_test.install +++ /dev/null @@ -1,22 +0,0 @@ -<?php - -/** - * @file - * Experimental Test Requirements module to test hook_requirements(). - */ - -declare(strict_types=1); - -/** - * Implements hook_requirements(). - */ -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, - 'description' => t('The Experimental Test Requirements module can not be installed.'), - ]; - } - return $requirements; -} diff --git a/core/modules/system/tests/modules/experimental_module_requirements_test/src/Install/Requirements/ExperimentalModuleRequirementsTestRequirements.php b/core/modules/system/tests/modules/experimental_module_requirements_test/src/Install/Requirements/ExperimentalModuleRequirementsTestRequirements.php new file mode 100644 index 00000000000..53834f77c0e --- /dev/null +++ b/core/modules/system/tests/modules/experimental_module_requirements_test/src/Install/Requirements/ExperimentalModuleRequirementsTestRequirements.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\experimental_module_requirements_test\Install\Requirements; + +use Drupal\Core\Extension\InstallRequirementsInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; + +/** + * Install time requirements for the Experimental Requirements Test module. + */ +class ExperimentalModuleRequirementsTestRequirements implements InstallRequirementsInterface { + + /** + * {@inheritdoc} + */ + public static function getRequirements(): array { + $requirements = []; + if (\Drupal::state()->get('experimental_module_requirements_test_requirements', FALSE)) { + $requirements['experimental_module_requirements_test_requirements'] = [ + 'severity' => RequirementSeverity::Error, + 'description' => t('The Experimental Test Requirements module can not be installed.'), + ]; + } + return $requirements; + } + +} 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/form_test/src/Form/FormTestGroupDetailsForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestGroupDetailsForm.php index 9babda83ddc..226eb705802 100644 --- a/core/modules/system/tests/modules/form_test/src/Form/FormTestGroupDetailsForm.php +++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestGroupDetailsForm.php @@ -48,6 +48,11 @@ class FormTestGroupDetailsForm extends FormBase { 'data-summary-attribute' => 'test', ], ]; + $form['description_attributes'] = [ + '#type' => 'details', + '#title' => 'Details element with description', + '#description' => 'I am a details description', + ]; return $form; } diff --git a/core/modules/system/tests/modules/form_test/src/Hook/FormTestHooks.php b/core/modules/system/tests/modules/form_test/src/Hook/FormTestHooks.php index cda2b92b347..441ebaa1d12 100644 --- a/core/modules/system/tests/modules/form_test/src/Hook/FormTestHooks.php +++ b/core/modules/system/tests/modules/form_test/src/Hook/FormTestHooks.php @@ -6,6 +6,8 @@ namespace Drupal\form_test\Hook; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Render\Element\Submit; +use Drupal\Core\Render\ElementInfoManagerInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\form_test\Callbacks; @@ -16,6 +18,8 @@ class FormTestHooks { use StringTranslationTrait; + public function __construct(protected ElementInfoManagerInterface $elementInfoManager) {} + /** * Implements hook_form_FORM_ID_alter(). */ @@ -55,13 +59,10 @@ class FormTestHooks { */ #[Hook('form_user_register_form_alter')] public function formUserRegisterFormAlter(&$form, FormStateInterface $form_state) : void { - $form['test_rebuild'] = [ - '#type' => 'submit', - '#value' => $this->t('Rebuild'), - '#submit' => [ - [Callbacks::class, 'userRegisterFormRebuild'], - ], - ]; + $submit = $this->elementInfoManager->fromRenderable($form) + ->createChild('test_rebuild', Submit::class); + $submit->value = $this->t('Rebuild'); + $submit->submit = [[Callbacks::class, 'userRegisterFormRebuild']]; } /** @@ -69,11 +70,12 @@ class FormTestHooks { */ #[Hook('form_form_test_vertical_tabs_access_form_alter')] public function formFormTestVerticalTabsAccessFormAlter(&$form, &$form_state, $form_id) : void { - $form['vertical_tabs1']['#access'] = FALSE; - $form['vertical_tabs2']['#access'] = FALSE; - $form['tabs3']['#access'] = TRUE; - $form['fieldset1']['#access'] = FALSE; - $form['container']['#access'] = FALSE; + $element_object = $this->elementInfoManager->fromRenderable($form); + $element_object->getChild('vertical_tabs1')->access = FALSE; + $element_object->getChild('vertical_tabs2')->access = FALSE; + $element_object->getChild('tab3')->access = FALSE; + $element_object->getChild('fieldset1')->access = FALSE; + $element_object->getChild('container')->access = FALSE; } } 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/js_displace/js_displace.module b/core/modules/system/tests/modules/js_displace/js_displace.module deleted file mode 100644 index 8b34072bd65..00000000000 --- a/core/modules/system/tests/modules/js_displace/js_displace.module +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -/** - * @file - * Functions to support testing Drupal.displace() JavaScript API. - */ - -declare(strict_types=1); - -/** - * Implements hook_preprocess_html(). - */ -function js_displace_preprocess_html(&$variables): void { - $variables['#attached']['library'][] = 'core/drupal.displace'; -} diff --git a/core/modules/system/tests/modules/js_displace/src/Hook/JsDisplaceThemeHooks.php b/core/modules/system/tests/modules/js_displace/src/Hook/JsDisplaceThemeHooks.php new file mode 100644 index 00000000000..d9b37274d46 --- /dev/null +++ b/core/modules/system/tests/modules/js_displace/src/Hook/JsDisplaceThemeHooks.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\js_displace\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for js_displace module. + */ +class JsDisplaceThemeHooks { + + /** + * Implements hook_preprocess_HOOK(). + */ + #[Hook('preprocess_html')] + public function preprocessHtml(&$variables): void { + $variables['#attached']['library'][] = 'core/drupal.displace'; + } + +} 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/module_test.file.inc b/core/modules/system/tests/modules/module_test/module_test.file.inc index c0cee6f1905..5ba5f5614ca 100644 --- a/core/modules/system/tests/modules/module_test/module_test.file.inc +++ b/core/modules/system/tests/modules/module_test/module_test.file.inc @@ -16,3 +16,17 @@ declare(strict_types=1); function module_test_test_hook(): array { return ['module_test' => 'success!']; } + +/** + * Implements hook_test_reset_implementations_hook(). + */ +function module_test_test_reset_implementations_hook(): string { + return __FUNCTION__; +} + +/** + * Implements hook_test_reset_implementations_alter(). + */ +function module_test_test_reset_implementations_alter(array &$data): void { + $data[] = __FUNCTION__; +} 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/sdc_test/components/my-banner/my-banner.component.yml b/core/modules/system/tests/modules/sdc_test/components/my-banner/my-banner.component.yml index 195903e9ee7..0c89a0740a6 100644 --- a/core/modules/system/tests/modules/sdc_test/components/my-banner/my-banner.component.yml +++ b/core/modules/system/tests/modules/sdc_test/components/my-banner/my-banner.component.yml @@ -29,6 +29,10 @@ props: enum: - '' - _blank + meta:enum: + '': 'Open in same window' + _blank: 'Open in a new window' + x-translation-context: Banner link target image: title: Media Image description: Background image for the banner. diff --git a/core/modules/system/tests/modules/sdc_test/components/my-button/my-button.component.yml b/core/modules/system/tests/modules/sdc_test/components/my-button/my-button.component.yml index 65b3c472096..d0d4f8c73b4 100644 --- a/core/modules/system/tests/modules/sdc_test/components/my-button/my-button.component.yml +++ b/core/modules/system/tests/modules/sdc_test/components/my-button/my-button.component.yml @@ -24,3 +24,7 @@ props: - power - like - external + meta:enum: + power: 'Power' + like: 'Like' + external: 'External' diff --git a/core/modules/system/tests/modules/sdc_test/components/my-cta/my-cta.component.yml b/core/modules/system/tests/modules/sdc_test/components/my-cta/my-cta.component.yml index 889dfe88520..6d16be49cf7 100644 --- a/core/modules/system/tests/modules/sdc_test/components/my-cta/my-cta.component.yml +++ b/core/modules/system/tests/modules/sdc_test/components/my-cta/my-cta.component.yml @@ -17,12 +17,23 @@ props: type: string title: URL format: uri + examples: + - https://drupal.org target: type: string title: Target + description: The target for opening the link. enum: - '' - - _blank + - '_blank' + meta:enum: + '': 'Open in same window' + _blank: 'Open in a new window' + x-translation-context: CTA link target + default: '' + examples: + - '' + - '_blank' attributes: type: Drupal\Core\Template\Attribute name: Attributes diff --git a/core/modules/system/tests/modules/sdc_test_replacements/components/my-button/my-button.component.yml b/core/modules/system/tests/modules/sdc_test_replacements/components/my-button/my-button.component.yml index b87f1180111..053387cf9c4 100644 --- a/core/modules/system/tests/modules/sdc_test_replacements/components/my-button/my-button.component.yml +++ b/core/modules/system/tests/modules/sdc_test_replacements/components/my-button/my-button.component.yml @@ -24,3 +24,7 @@ props: - power - like - external + meta:enum: + power: 'Power' + like: 'Like' + external: 'External' 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/system_test/src/Controller/OptionalServiceSystemTestController.php b/core/modules/system/tests/modules/system_test/src/Controller/OptionalServiceSystemTestController.php new file mode 100644 index 00000000000..b57e4c88397 --- /dev/null +++ b/core/modules/system/tests/modules/system_test/src/Controller/OptionalServiceSystemTestController.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\system_test\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\dblog\Logger\DbLog; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +/** + * A controller that specifies an optional dependency. + */ +class OptionalServiceSystemTestController extends ControllerBase { + + public function __construct( + #[Autowire('logger.dblog')] + public readonly ?DbLog $dbLog, + ) {} + +} 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..9027509a19c --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php @@ -0,0 +1,116 @@ +<?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 a response with a `beforebegin` swap. + * + * @return mixed[] + * A render array. + */ + public function before(): array { + return self::generateHtmxButton('beforebegin'); + } + + /** + * Builds a response with an `afterend` swap.. + * + * @return mixed[] + * A render array. + */ + public function after(): array { + return self::generateHtmxButton('afterend'); + } + + /** + * 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; + } + + /** + * We need a static callback that ignores callback parameters. + * + * @return array + * The render array. + */ + public static function replaceWithAjax(): array { + return static::generateHtmxButton(); + } + + /** + * Static helper to for reusable render array. + * + * @return array + * The render array. + */ + public static function generateHtmxButton(string $swap = ''): 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', + ], + ], + ]; + if ($swap !== '') { + $build['replace']['#attributes']['data-hx-swap'] = $swap; + } + + $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..f812a99582d --- /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, + 'replaceWithAjax', + ], + '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..33dca377c71 --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml @@ -0,0 +1,39 @@ +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.before: + path: '/htmx-test-attachments/before' + defaults: + _title: 'Page' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::before' + requirements: + _permission: 'access content' + +test_htmx.attachments.after: + path: '/htmx-test-attachments/after' + defaults: + _title: 'Page' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::after' + 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_suggestions_test/src/Hook/ThemeSuggestionsTestHooks.php b/core/modules/system/tests/modules/theme_suggestions_test/src/Hook/ThemeSuggestionsTestHooks.php index 8f5d5ee18a8..ff4086a0c2a 100644 --- a/core/modules/system/tests/modules/theme_suggestions_test/src/Hook/ThemeSuggestionsTestHooks.php +++ b/core/modules/system/tests/modules/theme_suggestions_test/src/Hook/ThemeSuggestionsTestHooks.php @@ -16,7 +16,7 @@ class ThemeSuggestionsTestHooks { */ #[Hook('theme_suggestions_alter')] public function themeSuggestionsAlter(array &$suggestions, array &$variables, $hook): void { - \Drupal::messenger()->addStatus('theme_suggestions_test_theme_suggestions_alter' . '() executed.'); + \Drupal::messenger()->addStatus('theme_suggestions_test_theme_suggestions_alter() executed.'); if ($hook == 'theme_test_general_suggestions') { $suggestions[] = $hook . '__module_override'; $variables['module_hook'] = 'theme_suggestions_test_theme_suggestions_alter'; @@ -28,7 +28,7 @@ class ThemeSuggestionsTestHooks { */ #[Hook('theme_suggestions_theme_test_suggestions_alter')] public function themeSuggestionsThemeTestSuggestionsAlter(array &$suggestions, array $variables): void { - \Drupal::messenger()->addStatus('theme_suggestions_test_theme_suggestions_theme_test_suggestions_alter' . '() executed.'); + \Drupal::messenger()->addStatus('theme_suggestions_test_theme_suggestions_theme_test_suggestions_alter() executed.'); $suggestions[] = 'theme_test_suggestions__module_override'; } diff --git a/core/modules/system/tests/modules/theme_test/src/EventSubscriber/ThemeTestSubscriber.php b/core/modules/system/tests/modules/theme_test/src/EventSubscriber/ThemeTestSubscriber.php index 00b51bc72b5..1d953ff8c33 100644 --- a/core/modules/system/tests/modules/theme_test/src/EventSubscriber/ThemeTestSubscriber.php +++ b/core/modules/system/tests/modules/theme_test/src/EventSubscriber/ThemeTestSubscriber.php @@ -19,10 +19,11 @@ class ThemeTestSubscriber implements EventSubscriberInterface { /** * The used container. * + * @var object + * * @todo This variable is never initialized, so we don't know what it is. * See https://www.drupal.org/node/2721315 */ - // phpcs:ignore Drupal.Commenting.VariableComment.Missing, Drupal.Commenting.VariableComment.MissingVar protected $container; /** @@ -78,27 +79,10 @@ class ThemeTestSubscriber implements EventSubscriberInterface { } /** - * Ensures that the theme registry was not initialized. - */ - public function onView(RequestEvent $event) { - $current_route = $this->currentRouteMatch->getRouteName(); - $entity_autocomplete_route = [ - 'system.entity_autocomplete', - ]; - - if (in_array($current_route, $entity_autocomplete_route)) { - if ($this->container->initialized('theme.registry')) { - throw new \Exception('registry initialized'); - } - } - } - - /** * {@inheritdoc} */ public static function getSubscribedEvents(): array { $events[KernelEvents::REQUEST][] = ['onRequest']; - $events[KernelEvents::VIEW][] = ['onView', -1000]; return $events; } diff --git a/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestHooks.php b/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestHooks.php index 8e27524449f..9b3fe6cd66a 100644 --- a/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestHooks.php +++ b/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestHooks.php @@ -85,7 +85,7 @@ class ThemeTestHooks { */ #[Hook('theme_suggestions_alter')] public function themeSuggestionsAlter(array &$suggestions, array $variables, $hook): void { - \Drupal::messenger()->addStatus('theme_test_theme_suggestions_alter' . '() executed for ' . $hook . '.'); + \Drupal::messenger()->addStatus('theme_test_theme_suggestions_alter() executed for ' . $hook . '.'); } /** @@ -93,7 +93,7 @@ class ThemeTestHooks { */ #[Hook('theme_suggestions_theme_test_suggestions_alter')] public function themeSuggestionsThemeTestSuggestionsAlter(array &$suggestions, array $variables): void { - \Drupal::messenger()->addStatus('theme_test_theme_suggestions_theme_test_suggestions_alter' . '() executed.'); + \Drupal::messenger()->addStatus('theme_test_theme_suggestions_theme_test_suggestions_alter() executed.'); } /** 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/Datetime/DrupalDateTimeTest.php b/core/modules/system/tests/src/Functional/Datetime/DrupalDateTimeTest.php deleted file mode 100644 index cbef10d726e..00000000000 --- a/core/modules/system/tests/src/Functional/Datetime/DrupalDateTimeTest.php +++ /dev/null @@ -1,108 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\system\Functional\Datetime; - -use Drupal\Core\Datetime\DrupalDateTime; -use Drupal\Tests\BrowserTestBase; -use Drupal\user\Entity\User; - -/** - * Tests DrupalDateTime functionality. - * - * @group Datetime - */ -class DrupalDateTimeTest extends BrowserTestBase { - - /** - * Set up required modules. - * - * @var string[] - */ - protected static $modules = []; - - /** - * {@inheritdoc} - */ - protected $defaultTheme = 'stark'; - - /** - * Tests that DrupalDateTime can detect the right timezone to use. - * - * Test with a variety of less commonly used timezone names to - * help ensure that the system timezone will be different than the - * stated timezones. - */ - public function testDateTimezone(): void { - $date_string = '2007-01-31 21:00:00'; - - // Make sure no site timezone has been set. - $this->config('system.date') - ->set('timezone.user.configurable', 0) - ->set('timezone.default', NULL) - ->save(); - - // Detect the system timezone. - $system_timezone = date_default_timezone_get(); - - // Create a date object with an unspecified timezone, which should - // end up using the system timezone. - $date = new DrupalDateTime($date_string); - $timezone = $date->getTimezone()->getName(); - $this->assertSame($system_timezone, $timezone, 'DrupalDateTime uses the system timezone when there is no site timezone.'); - - // Create a date object with a specified timezone. - $date = new DrupalDateTime($date_string, 'America/Yellowknife'); - $timezone = $date->getTimezone()->getName(); - $this->assertSame('America/Yellowknife', $timezone, 'DrupalDateTime uses the specified timezone if provided.'); - - // Set a site timezone. - $this->config('system.date')->set('timezone.default', 'Europe/Warsaw')->save(); - - // Create a date object with an unspecified timezone, which should - // end up using the site timezone. - $date = new DrupalDateTime($date_string); - $timezone = $date->getTimezone()->getName(); - $this->assertSame('Europe/Warsaw', $timezone, 'DrupalDateTime uses the site timezone if provided.'); - - // Create user. - $this->config('system.date')->set('timezone.user.configurable', 1)->save(); - $test_user = $this->drupalCreateUser([]); - $this->drupalLogin($test_user); - - // Set up the user with a different timezone than the site. - $edit = ['mail' => $test_user->getEmail(), 'timezone' => 'Asia/Manila']; - $this->drupalGet('user/' . $test_user->id() . '/edit'); - $this->submitForm($edit, 'Save'); - - // Reload the user and reset the timezone in AccountProxy::setAccount(). - \Drupal::entityTypeManager()->getStorage('user')->resetCache(); - $this->container->get('current_user')->setAccount(User::load($test_user->id())); - - // Create a date object with an unspecified timezone, which should - // end up using the user timezone. - $date = new DrupalDateTime($date_string); - $timezone = $date->getTimezone()->getName(); - $this->assertSame('Asia/Manila', $timezone, 'DrupalDateTime uses the user timezone, if configurable timezones are used and it is set.'); - } - - /** - * Tests the ability to override the time zone in the format method. - */ - public function testTimezoneFormat(): void { - // Create a date in UTC - $date = DrupalDateTime::createFromTimestamp(87654321, 'UTC'); - - // Verify that the date format method displays the default time zone. - $this->assertEquals('1972/10/11 12:25:21 UTC', $date->format('Y/m/d H:i:s e'), 'Date has default UTC time zone and correct date/time.'); - - // Verify that the format method can override the time zone. - $this->assertEquals('1972/10/11 08:25:21 America/New_York', $date->format('Y/m/d H:i:s e', ['timezone' => 'America/New_York']), 'Date displayed overridden time zone and correct date/time'); - - // Verify that the date format method still displays the default time zone - // for the date object. - $this->assertEquals('1972/10/11 12:25:21 UTC', $date->format('Y/m/d H:i:s e'), 'Date still has default UTC time zone and correct date/time'); - } - -} diff --git a/core/modules/system/tests/src/Functional/Entity/EntityAddUITest.php b/core/modules/system/tests/src/Functional/Entity/EntityAddUITest.php index 77eaa48575b..f570d803176 100644 --- a/core/modules/system/tests/src/Functional/Entity/EntityAddUITest.php +++ b/core/modules/system/tests/src/Functional/Entity/EntityAddUITest.php @@ -62,20 +62,23 @@ class EntityAddUITest extends BrowserTestBase { $this->drupalGet('/entity_test_with_bundle/add'); $this->assertSession()->addressEquals('/entity_test_with_bundle/add/test'); - // Two bundles exist, confirm both are shown. + // Two bundles exist. Confirm both are shown and that they are ordered + // alphabetically by their labels, not by their IDs. EntityTestBundle::create([ 'id' => 'test2', - 'label' => 'Test2 label', + 'label' => 'Aaa Test2 label', 'description' => 'My test2 description', ])->save(); $this->drupalGet('/entity_test_with_bundle/add'); $this->assertSession()->linkExists('Test label'); - $this->assertSession()->linkExists('Test2 label'); + $this->assertSession()->linkExists('Aaa Test2 label'); $this->assertSession()->pageTextContains('My test description'); $this->assertSession()->pageTextContains('My test2 description'); - $this->clickLink('Test2 label'); + $this->assertSession()->pageTextMatches('/Aaa Test2 label(.*)Test label/'); + + $this->clickLink('Aaa Test2 label'); $this->drupalGet('/entity_test_with_bundle/add/test2'); $this->submitForm(['name[0][value]' => 'test name'], 'Save'); @@ -106,7 +109,7 @@ class EntityAddUITest extends BrowserTestBase { $this->drupalGet('/entity_test_with_bundle/add'); $this->assertSession()->statusCodeEquals(200); $this->assertSession()->linkExists('Test label'); - $this->assertSession()->linkExists('Test2 label'); + $this->assertSession()->linkExists('Aaa Test2 label'); $this->assertSession()->linkNotExists('Forbidden to create bundle'); $this->assertSession()->linkNotExists('Test3 label'); $this->clickLink('Test label'); @@ -129,7 +132,7 @@ class EntityAddUITest extends BrowserTestBase { $this->drupalGet('/entity_test_with_bundle/add'); $this->assertSession()->linkNotExists('Forbidden to create bundle'); $this->assertSession()->linkNotExists('Test label'); - $this->assertSession()->linkNotExists('Test2 label'); + $this->assertSession()->linkNotExists('Aaa Test2 label'); $this->assertSession()->linkNotExists('Test3 label'); $this->assertSession()->linkExists('Add a new test entity bundle.'); } diff --git a/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php b/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php index e130b89bf32..94221d8165f 100644 --- a/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php +++ b/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php @@ -24,7 +24,7 @@ trait EntityDefinitionTestTrait { * (optional) Applies changes only for the specified entity type ID. * Defaults to NULL. */ - protected function applyEntityUpdates($entity_type_id = NULL) { + protected function applyEntityUpdates($entity_type_id = NULL): void { $complete_change_list = \Drupal::entityDefinitionUpdateManager()->getChangeList(); if ($complete_change_list) { // In case there are changes, explicitly invalidate caches. @@ -68,7 +68,7 @@ trait EntityDefinitionTestTrait { * @param string $entity_type_id * The entity type ID. */ - protected function doEntityUpdate($op, $entity_type_id) { + protected function doEntityUpdate($op, $entity_type_id): void { $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id); $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions($entity_type_id); switch ($op) { @@ -96,7 +96,7 @@ trait EntityDefinitionTestTrait { * @param array|null $original_storage_definition * The original field storage definition. */ - protected function doFieldUpdate($op, $storage_definition = NULL, $original_storage_definition = NULL) { + protected function doFieldUpdate($op, $storage_definition = NULL, $original_storage_definition = NULL): void { switch ($op) { case EntityDefinitionUpdateManagerInterface::DEFINITION_CREATED: \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionCreate($storage_definition); @@ -115,7 +115,7 @@ trait EntityDefinitionTestTrait { /** * Enables a new entity type definition. */ - protected function enableNewEntityType() { + protected function enableNewEntityType(): void { $this->state->set('entity_test_new', TRUE); $this->applyEntityUpdates('entity_test_new'); } @@ -123,7 +123,7 @@ trait EntityDefinitionTestTrait { /** * Resets the entity type definition. */ - protected function resetEntityType() { + protected function resetEntityType(): void { $updated_entity_type = $this->getUpdatedEntityTypeDefinition(FALSE, FALSE); $updated_field_storage_definitions = $this->getUpdatedFieldStorageDefinitions(FALSE, FALSE); $this->entityDefinitionUpdateManager->updateFieldableEntityType($updated_entity_type, $updated_field_storage_definitions); @@ -136,7 +136,7 @@ trait EntityDefinitionTestTrait { * (optional) Whether the change should be performed by the entity * definition update manager. */ - protected function updateEntityTypeToRevisionable($perform_update = FALSE) { + protected function updateEntityTypeToRevisionable($perform_update = FALSE): void { $translatable = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update')->isTranslatable(); $updated_entity_type = $this->getUpdatedEntityTypeDefinition(TRUE, $translatable); @@ -154,7 +154,7 @@ trait EntityDefinitionTestTrait { * (optional) Whether the change should be performed by the entity * definition update manager. */ - protected function updateEntityTypeToNotRevisionable($perform_update = FALSE) { + protected function updateEntityTypeToNotRevisionable($perform_update = FALSE): void { $translatable = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update')->isTranslatable(); $updated_entity_type = $this->getUpdatedEntityTypeDefinition(FALSE, $translatable); @@ -172,7 +172,7 @@ trait EntityDefinitionTestTrait { * (optional) Whether the change should be performed by the entity * definition update manager. */ - protected function updateEntityTypeToTranslatable($perform_update = FALSE) { + protected function updateEntityTypeToTranslatable($perform_update = FALSE): void { $revisionable = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update')->isRevisionable(); $updated_entity_type = $this->getUpdatedEntityTypeDefinition($revisionable, TRUE); @@ -190,7 +190,7 @@ trait EntityDefinitionTestTrait { * (optional) Whether the change should be performed by the entity * definition update manager. */ - protected function updateEntityTypeToNotTranslatable($perform_update = FALSE) { + protected function updateEntityTypeToNotTranslatable($perform_update = FALSE): void { $revisionable = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update')->isRevisionable(); $updated_entity_type = $this->getUpdatedEntityTypeDefinition($revisionable, FALSE); @@ -208,7 +208,7 @@ trait EntityDefinitionTestTrait { * (optional) Whether the change should be performed by the entity * definition update manager. */ - protected function updateEntityTypeToRevisionableAndTranslatable($perform_update = FALSE) { + protected function updateEntityTypeToRevisionableAndTranslatable($perform_update = FALSE): void { $updated_entity_type = $this->getUpdatedEntityTypeDefinition(TRUE, TRUE); $updated_field_storage_definitions = $this->getUpdatedFieldStorageDefinitions(TRUE, TRUE); @@ -235,7 +235,7 @@ trait EntityDefinitionTestTrait { * (optional) If the base field should be translatable or not. Defaults to * FALSE. */ - protected function addBaseField($type = 'string', $entity_type_id = 'entity_test_update', $is_revisionable = FALSE, $set_label = TRUE, $is_translatable = FALSE) { + protected function addBaseField($type = 'string', $entity_type_id = 'entity_test_update', $is_revisionable = FALSE, $set_label = TRUE, $is_translatable = FALSE): void { $definitions['new_base_field'] = BaseFieldDefinition::create($type) ->setName('new_base_field') ->setRevisionable($is_revisionable) @@ -251,7 +251,7 @@ trait EntityDefinitionTestTrait { /** * Adds a long-named base field to the 'entity_test_update' entity type. */ - protected function addLongNameBaseField() { + protected function addLongNameBaseField(): void { $key = 'entity_test_update.additional_base_field_definitions'; $definitions = $this->state->get($key, []); $definitions['new_long_named_entity_reference_base_field'] = BaseFieldDefinition::create('entity_reference') @@ -268,7 +268,7 @@ trait EntityDefinitionTestTrait { * @param string $type * (optional) The field type for the new field. Defaults to 'string'. */ - protected function addRevisionableBaseField($type = 'string') { + protected function addRevisionableBaseField($type = 'string'): void { $definitions['new_base_field'] = BaseFieldDefinition::create($type) ->setName('new_base_field') ->setLabel(t('A new revisionable base field')) @@ -279,14 +279,14 @@ trait EntityDefinitionTestTrait { /** * Modifies the new base field from 'string' to 'text'. */ - protected function modifyBaseField() { + protected function modifyBaseField(): void { $this->addBaseField('text'); } /** * Promotes a field to an entity key. */ - protected function makeBaseFieldEntityKey() { + protected function makeBaseFieldEntityKey(): void { $entity_type = clone \Drupal::entityTypeManager()->getDefinition('entity_test_update'); $entity_keys = $entity_type->getKeys(); $entity_keys['new_base_field'] = 'new_base_field'; @@ -300,21 +300,21 @@ trait EntityDefinitionTestTrait { * @param string $entity_type_id * (optional) The entity type ID the base field should be attached to. */ - protected function removeBaseField($entity_type_id = 'entity_test_update') { + protected function removeBaseField($entity_type_id = 'entity_test_update'): void { $this->state->delete($entity_type_id . '.additional_base_field_definitions'); } /** * Adds a single-field index to the base field. */ - protected function addBaseFieldIndex() { + protected function addBaseFieldIndex(): void { $this->state->set('entity_test_update.additional_field_index.entity_test_update.new_base_field', TRUE); } /** * Removes the index added in addBaseFieldIndex(). */ - protected function removeBaseFieldIndex() { + protected function removeBaseFieldIndex(): void { $this->state->delete('entity_test_update.additional_field_index.entity_test_update.new_base_field'); } @@ -328,7 +328,7 @@ trait EntityDefinitionTestTrait { * @param bool $translatable * (optional) Whether the field should be translatable. Defaults to FALSE. */ - protected function addBundleField($type = 'string', $revisionable = FALSE, $translatable = FALSE) { + protected function addBundleField($type = 'string', $revisionable = FALSE, $translatable = FALSE): void { $definitions['new_bundle_field'] = FieldStorageDefinition::create($type) ->setName('new_bundle_field') ->setLabel(t('A new bundle field')) @@ -342,14 +342,14 @@ trait EntityDefinitionTestTrait { /** * Modifies the new bundle field from 'string' to 'text'. */ - protected function modifyBundleField() { + protected function modifyBundleField(): void { $this->addBundleField('text'); } /** * Removes the new bundle field from the 'entity_test_update' entity type. */ - protected function removeBundleField() { + protected function removeBundleField(): void { $this->state->delete('entity_test_update.additional_field_storage_definitions'); $this->state->delete('entity_test_update.additional_bundle_field_definitions.test_bundle'); } @@ -359,7 +359,7 @@ trait EntityDefinitionTestTrait { * * @see \Drupal\entity_test\EntityTestStorageSchema::getEntitySchema() */ - protected function addEntityIndex() { + protected function addEntityIndex(): void { $indexes = [ 'entity_test_update__new_index' => ['name', 'test_single_property'], ]; @@ -369,14 +369,14 @@ trait EntityDefinitionTestTrait { /** * Removes the index added in addEntityIndex(). */ - protected function removeEntityIndex() { + protected function removeEntityIndex(): void { $this->state->delete('entity_test_update.additional_entity_indexes'); } /** * Renames the base table to 'entity_test_update_new'. */ - protected function renameBaseTable() { + protected function renameBaseTable(): void { $entity_type = clone \Drupal::entityTypeManager()->getDefinition('entity_test_update'); $entity_type->set('base_table', 'entity_test_update_new'); @@ -387,7 +387,7 @@ trait EntityDefinitionTestTrait { /** * Renames the data table to 'entity_test_update_data_new'. */ - protected function renameDataTable() { + protected function renameDataTable(): void { $entity_type = clone \Drupal::entityTypeManager()->getDefinition('entity_test_update'); $entity_type->set('data_table', 'entity_test_update_data_new'); @@ -398,7 +398,7 @@ trait EntityDefinitionTestTrait { /** * Renames the revision table to 'entity_test_update_revision_new'. */ - protected function renameRevisionBaseTable() { + protected function renameRevisionBaseTable(): void { $entity_type = clone \Drupal::entityTypeManager()->getDefinition('entity_test_update'); $entity_type->set('revision_table', 'entity_test_update_revision_new'); @@ -409,7 +409,7 @@ trait EntityDefinitionTestTrait { /** * Renames the revision data table to 'entity_test_update_revision_data_new'. */ - protected function renameRevisionDataTable() { + protected function renameRevisionDataTable(): void { $entity_type = clone \Drupal::entityTypeManager()->getDefinition('entity_test_update'); $entity_type->set('revision_data_table', 'entity_test_update_revision_data_new'); @@ -420,7 +420,7 @@ trait EntityDefinitionTestTrait { /** * Removes the entity type. */ - protected function deleteEntityType() { + protected function deleteEntityType(): void { $this->state->set('entity_test_update.entity_type', 'null'); } diff --git a/core/modules/system/tests/src/Functional/FileTransfer/FileTransferTest.php b/core/modules/system/tests/src/Functional/FileTransfer/FileTransferTest.php index 2a088655019..fa6bda652de 100644 --- a/core/modules/system/tests/src/Functional/FileTransfer/FileTransferTest.php +++ b/core/modules/system/tests/src/Functional/FileTransfer/FileTransferTest.php @@ -57,10 +57,7 @@ class FileTransferTest extends BrowserTestBase { public function _buildFakeModule() { $location = 'temporary://fake'; if (is_dir($location)) { - $ret = 0; - $output = []; - exec('rm -Rf ' . escapeshellarg($location), $output, $ret); - if ($ret != 0) { + if (!\Drupal::service('file_system')->deleteRecursive($location)) { throw new \Exception('Error removing fake module directory.'); } } @@ -91,26 +88,10 @@ class FileTransferTest extends BrowserTestBase { */ public function testJail(): void { $source = $this->_buildFakeModule(); - - // This convoluted piece of code is here because our testing framework does - // not support expecting exceptions. - $got_it = FALSE; - try { - $this->testConnection->copyDirectory($source, sys_get_temp_dir()); - } - catch (FileTransferException) { - $got_it = TRUE; - } - $this->assertTrue($got_it, 'Was not able to copy a directory outside of the jailed area.'); - - $got_it = TRUE; - try { - $this->testConnection->copyDirectory($source, $this->root . '/' . PublicStream::basePath()); - } - catch (FileTransferException) { - $got_it = FALSE; - } - $this->assertTrue($got_it, 'Was able to copy a directory inside of the jailed area'); + $this->testConnection->copyDirectory($source, $this->root . '/' . PublicStream::basePath()); + $this->expectException(FileTransferException::class); + $this->expectExceptionMessage('@directory is outside of the @jail'); + $this->testConnection->copyDirectory($source, sys_get_temp_dir()); } } diff --git a/core/modules/system/tests/src/Functional/Form/ElementTest.php b/core/modules/system/tests/src/Functional/Form/ElementTest.php index 4a9755fac7f..0ebf9e4ce77 100644 --- a/core/modules/system/tests/src/Functional/Form/ElementTest.php +++ b/core/modules/system/tests/src/Functional/Form/ElementTest.php @@ -38,6 +38,7 @@ class ElementTest extends BrowserTestBase { $this->testFormAutocomplete(); $this->testFormElementErrors(); $this->testDetailsSummaryAttributes(); + $this->testDetailsDescriptionAttributes(); } /** @@ -230,4 +231,13 @@ class ElementTest extends BrowserTestBase { $this->assertSession()->elementExists('css', 'summary[data-summary-attribute="test"]'); } + /** + * Tests description attributes of details. + */ + protected function testDetailsDescriptionAttributes(): void { + $this->drupalGet('form-test/group-details'); + $this->assertSession()->elementExists('css', 'details[aria-describedby="edit-description-attributes--description"]'); + $this->assertSession()->elementExists('css', 'div[id="edit-description-attributes--description"]'); + } + } 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/Hook/HookCollectorPassTest.php b/core/modules/system/tests/src/Functional/Hook/HookCollectorPassTest.php new file mode 100644 index 00000000000..59d69d1242c --- /dev/null +++ b/core/modules/system/tests/src/Functional/Hook/HookCollectorPassTest.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Functional\Hook; + +use Drupal\Tests\BrowserTestBase; +use Drupal\Core\Url; + +/** + * Tests services in .module files. + * + * @group Hook + */ +class HookCollectorPassTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['container_initialize']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests installing a module with a Drupal container call outside functions. + * + * If this is removed then it needs to be moved to a test that installs modules through + * admin/modules. + */ + public function testContainerOutsideFunction(): void { + $settings['settings']['rebuild_access'] = (object) [ + 'value' => TRUE, + 'required' => TRUE, + ]; + + // This simulates installing the module and running a cache rebuild in a + // separate request. + $this->writeSettings($settings); + $this->rebuildAll(); + $this->drupalGet(Url::fromUri('base:core/rebuild.php')); + $this->assertSession()->pageTextNotContains('ContainerNotInitializedException'); + // Successful response from rebuild.php should redirect to the front page. + $this->assertSession()->addressEquals('/'); + + // If this file is removed then this test needs to be updated to trigger + // the container rebuild error from https://www.drupal.org/i/3505049 + $config_module_file = $this->root . '/core/modules/system/tests/modules/container_initialize/container_initialize.module'; + $this->assertFileExists($config_module_file, 'This test depends on a container call in a .module file'); + // Confirm that the file still has a bare container call. + $bare_container = "declare(strict_types=1); + +\Drupal::getContainer()->getParameter('site.path'); +"; + $file_content = file_get_contents($config_module_file); + $this->assertStringContainsString($bare_container, $file_content, 'container_initialize.module container test feature is missing.'); + } + +} diff --git a/core/modules/system/tests/src/Functional/Menu/LinksetControllerMultiLingualTest.php b/core/modules/system/tests/src/Functional/Menu/LinksetControllerMultiLingualTest.php index d6b44d4bb9d..b5e20847466 100644 --- a/core/modules/system/tests/src/Functional/Menu/LinksetControllerMultiLingualTest.php +++ b/core/modules/system/tests/src/Functional/Menu/LinksetControllerMultiLingualTest.php @@ -151,7 +151,7 @@ final class LinksetControllerMultiLingualTest extends LinksetControllerTestBase ]); foreach (['aa', 'bb', 'cc'] as $language_code) { $multi_lingual_menu_item->addTranslation($language_code, [ - 'title' => $language_code . '|' . 'A multi-lingual-node', + 'title' => $language_code . '|A multi-lingual-node', ]); $multi_lingual_menu_item->save(); } @@ -170,7 +170,7 @@ final class LinksetControllerMultiLingualTest extends LinksetControllerTestBase ]); foreach (['aa', 'bb'] as $language_code) { $multi_lingual_menu_item->addTranslation($language_code, [ - 'title' => $language_code . '|' . 'Second multi-lingual-node', + 'title' => $language_code . '|Second multi-lingual-node', ]); $multi_lingual_menu_item->save(); } @@ -189,7 +189,7 @@ final class LinksetControllerMultiLingualTest extends LinksetControllerTestBase ]); foreach (['aa', 'bb'] as $language_code) { $multi_lingual_menu_item->addTranslation($language_code, [ - 'title' => $language_code . '|' . 'Third multi-lingual-node', + 'title' => $language_code . '|Third multi-lingual-node', ]); $multi_lingual_menu_item->save(); } diff --git a/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php b/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php index 7b3754bc34c..d22f433a414 100644 --- a/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php +++ b/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Functional\Module; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Tests\BrowserTestBase; /** @@ -50,9 +51,12 @@ abstract class GenericModuleTestBase extends BrowserTestBase { if (empty($info['required'])) { $connection = Database::getConnection(); - // When the database driver is provided by a module, then that module - // cannot be uninstalled. - if ($module !== $connection->getProvider()) { + // The module that provides the database driver, or is a dependency of + // the database driver, cannot be uninstalled. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get($connection->getProvider()); + $database_modules_required = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; + $database_modules_required[] = $connection->getProvider(); + if (!in_array($module, $database_modules_required)) { // Check that the module can be uninstalled and then re-installed again. $this->preUnInstallSteps(); $this->assertTrue(\Drupal::service('module_installer')->uninstall([$module]), "Failed to uninstall '$module' module"); diff --git a/core/modules/system/tests/src/Functional/Module/UninstallTest.php b/core/modules/system/tests/src/Functional/Module/UninstallTest.php index aeace5bb488..6d1b93cc50d 100644 --- a/core/modules/system/tests/src/Functional/Module/UninstallTest.php +++ b/core/modules/system/tests/src/Functional/Module/UninstallTest.php @@ -22,7 +22,15 @@ class UninstallTest extends BrowserTestBase { /** * {@inheritdoc} */ - protected static $modules = ['module_test', 'user', 'views', 'node']; + protected static $modules = [ + 'ckeditor5', + 'filter', + 'module_test', + 'node', + 'user', + 'views', + 'views_ui', + ]; /** * {@inheritdoc} @@ -118,6 +126,13 @@ class UninstallTest extends BrowserTestBase { // Delete the node to allow node to be uninstalled. $node->delete(); + // Ensure dependent module full names are shown. + $this->assertSession()->pageTextContains('Required by: Views UI'); + // Ensure matching machine names do not display. + $this->assertSession()->pageTextNotContains('Required by: Views UI (views_ui)'); + // Ensure machine names that do not match do display. + $this->assertSession()->pageTextContains('Text Editor (editor)'); + // Uninstall module_test. $edit = []; $edit['uninstall[module_test]'] = TRUE; diff --git a/core/modules/system/tests/src/Functional/Module/VersionTest.php b/core/modules/system/tests/src/Functional/Module/VersionTest.php index 7f3af598cfb..1a98b7f246f 100644 --- a/core/modules/system/tests/src/Functional/Module/VersionTest.php +++ b/core/modules/system/tests/src/Functional/Module/VersionTest.php @@ -8,6 +8,7 @@ namespace Drupal\Tests\system\Functional\Module; * Tests module version dependencies. * * @group Module + * @group #slow */ class VersionTest extends ModuleTestBase { 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..32487fa8604 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); @@ -91,8 +92,7 @@ class SitesDirectoryHardeningTest extends BrowserTestBase { * An array of system requirements. */ protected function checkSystemRequirements() { - \Drupal::moduleHandler()->loadInclude('system', 'install'); - return system_requirements('runtime'); + return \Drupal::moduleHandler()->invoke('system', 'runtime_requirements'); } /** diff --git a/core/modules/system/tests/src/Functional/System/StatusTest.php b/core/modules/system/tests/src/Functional/System/StatusTest.php index 972ac14fb2c..b5e32759a73 100644 --- a/core/modules/system/tests/src/Functional/System/StatusTest.php +++ b/core/modules/system/tests/src/Functional/System/StatusTest.php @@ -97,6 +97,8 @@ class StatusTest extends BrowserTestBase { $this->drupalGet('admin/reports/status/php'); $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('PHP'); + $this->assertSession()->pageTextNotContains('$_COOKIE'); $settings['settings']['sa_core_2023_004_phpinfo_flags'] = (object) [ 'value' => INFO_ALL, diff --git a/core/modules/system/tests/src/Functional/Theme/FastTest.php b/core/modules/system/tests/src/Functional/Theme/FastTest.php deleted file mode 100644 index 5cbb7d8f277..00000000000 --- a/core/modules/system/tests/src/Functional/Theme/FastTest.php +++ /dev/null @@ -1,52 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\system\Functional\Theme; - -use Drupal\Tests\BrowserTestBase; -use Drupal\user\Entity\User; - -/** - * Tests autocompletion not loading registry. - * - * @group Theme - */ -class FastTest extends BrowserTestBase { - - /** - * {@inheritdoc} - */ - protected static $modules = ['theme_test']; - - /** - * {@inheritdoc} - */ - protected $defaultTheme = 'stark'; - - /** - * User allowed to access use profiles. - * - * @var \Drupal\user\Entity\User - */ - protected User $account; - - /** - * {@inheritdoc} - */ - protected function setUp(): void { - parent::setUp(); - $this->account = $this->drupalCreateUser(['access user profiles']); - } - - /** - * Tests access to user autocompletion and verify the correct results. - */ - public function testUserAutocomplete(): void { - $this->drupalLogin($this->account); - $this->drupalGet('user/autocomplete', ['query' => ['q' => $this->account->getAccountName()]]); - $this->assertSession()->responseContains($this->account->getAccountName()); - $this->assertSession()->pageTextNotContains('registry initialized'); - } - -} diff --git a/core/modules/system/tests/src/Functional/UpdateSystem/UpdatePathTestJavaScriptTest.php b/core/modules/system/tests/src/Functional/UpdateSystem/UpdatePathTestJavaScriptTest.php index ffaaba1119a..32ca94f100b 100644 --- a/core/modules/system/tests/src/Functional/UpdateSystem/UpdatePathTestJavaScriptTest.php +++ b/core/modules/system/tests/src/Functional/UpdateSystem/UpdatePathTestJavaScriptTest.php @@ -50,7 +50,7 @@ class UpdatePathTestJavaScriptTest extends BrowserTestBase { } // Source is a root-relative URL. Transform it to an absolute URL to allow // file_get_contents() to access the file. - $src = preg_replace('#^' . $GLOBALS['base_path'] . '(.*)#i', $GLOBALS['base_url'] . '/' . '${1}', $script->getAttribute('src')); + $src = preg_replace('#^' . $GLOBALS['base_path'] . '(.*)#i', $GLOBALS['base_url'] . '/${1}', $script->getAttribute('src')); $file_content = file_get_contents($src); if (str_contains($file_content, 'window.drupalSettings =')) { 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/FunctionalJavascript/ActiveLinkTest.php b/core/modules/system/tests/src/FunctionalJavascript/ActiveLinkTest.php index 5d2a2848aa0..4767c4d1b98 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/ActiveLinkTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/ActiveLinkTest.php @@ -5,14 +5,14 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests active link JS behavior. * * @see Drupal.behaviors.activeLinks - * - * @group system */ +#[Group('system')] class ActiveLinkTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/Batch/ProcessingTest.php b/core/modules/system/tests/src/FunctionalJavascript/Batch/ProcessingTest.php index 03952729d54..9fbe8a05be0 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/Batch/ProcessingTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/Batch/ProcessingTest.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript\Batch; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** - * @group Batch + * Tests Processing. */ +#[Group('Batch')] class ProcessingTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/CopyFieldValueTest.php b/core/modules/system/tests/src/FunctionalJavascript/CopyFieldValueTest.php index 38728c38455..5ed27a9c42a 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/CopyFieldValueTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/CopyFieldValueTest.php @@ -5,14 +5,14 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests copy field value functionality. * * @see Drupal.behaviors.copyFieldValue. - * - * @group system */ +#[Group('system')] class CopyFieldValueTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/Form/ConfigTargetTest.php b/core/modules/system/tests/src/FunctionalJavascript/Form/ConfigTargetTest.php index e9e092f23ee..5bb3f54f7d5 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/Form/ConfigTargetTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/Form/ConfigTargetTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript\Form; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests forms using #config_target and #ajax together. - * - * @group Form */ +#[Group('Form')] class ConfigTargetTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/Form/DevelopmentSettingsFormTest.php b/core/modules/system/tests/src/FunctionalJavascript/Form/DevelopmentSettingsFormTest.php index 30f50b7bd0a..f2feffa7fa8 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/Form/DevelopmentSettingsFormTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/Form/DevelopmentSettingsFormTest.php @@ -7,13 +7,14 @@ namespace Drupal\Tests\system\FunctionalJavascript\Form; use Drupal\Core\Cache\NullBackend; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\HttpFoundation\Request; /** * Tests development settings form items for expected behavior. - * - * @group Form */ +#[Group('Form')] class DevelopmentSettingsFormTest extends WebDriverTestBase { /** @@ -40,9 +41,8 @@ class DevelopmentSettingsFormTest extends WebDriverTestBase { /** * Tests turning on Twig development mode. - * - * @dataProvider twigDevelopmentData */ + #[DataProvider('twigDevelopmentData')] public function testTwigDevelopmentMode(bool $twig_development_mode, ?bool $twig_debug, ?bool $twig_cache_disable): void { $twig_debug = $twig_debug ?? $twig_development_mode; $twig_cache_disable = $twig_cache_disable ?? $twig_development_mode; diff --git a/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsTableSelectTest.php b/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsTableSelectTest.php index 379856a5a1b..a41129b9cff 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsTableSelectTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsTableSelectTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript\Form; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the tableselect form element for expected behavior. - * - * @group Form */ +#[Group('Form')] class ElementsTableSelectTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsVerticalTabsWithSummaryTest.php b/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsVerticalTabsWithSummaryTest.php index 376a9f96170..9fbe3c27995 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsVerticalTabsWithSummaryTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/Form/ElementsVerticalTabsWithSummaryTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript\Form; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests that titles and summaries in vertical-tabs form elements are set correctly. - * - * @group Form */ +#[Group('Form')] class ElementsVerticalTabsWithSummaryTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/Form/RebuildTest.php b/core/modules/system/tests/src/FunctionalJavascript/Form/RebuildTest.php index 3858006154c..1ae041121d0 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/Form/RebuildTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/Form/RebuildTest.php @@ -9,13 +9,14 @@ use Drupal\Core\Url; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests functionality of \Drupal\Core\Form\FormBuilderInterface::rebuildForm(). * - * @group Form * @todo Add tests for other aspects of form rebuilding. */ +#[Group('Form')] class RebuildTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/Form/TriggeringElementTest.php b/core/modules/system/tests/src/FunctionalJavascript/Form/TriggeringElementTest.php index d07aea57549..dec0ace7075 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/Form/TriggeringElementTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/Form/TriggeringElementTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript\Form; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests that FAPI correctly determines the triggering element. - * - * @group Form */ +#[Group('Form')] class TriggeringElementTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/FrameworkTest.php b/core/modules/system/tests/src/FunctionalJavascript/FrameworkTest.php index 0fd5f468fb1..780b772932f 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/FrameworkTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/FrameworkTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the off-canvas dialog functionality. - * - * @group system */ +#[Group('system')] class FrameworkTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/ModalRendererTest.php b/core/modules/system/tests/src/FunctionalJavascript/ModalRendererTest.php index ab5735efc57..5d6e8a469ba 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/ModalRendererTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/ModalRendererTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests that dialog links use different renderer services. - * - * @group system */ +#[Group('system')] class ModalRendererTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/ModuleFilterTest.php b/core/modules/system/tests/src/FunctionalJavascript/ModuleFilterTest.php index 96f877179e3..cb809502830 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/ModuleFilterTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/ModuleFilterTest.php @@ -5,13 +5,13 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript functionality of the module filter. - * - * @group system - * @group #slow */ +#[Group('system')] +#[Group('#slow')] class ModuleFilterTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/ModuleUninstallFilterTest.php b/core/modules/system/tests/src/FunctionalJavascript/ModuleUninstallFilterTest.php index 2652b322578..d8b3bf0eac1 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/ModuleUninstallFilterTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/ModuleUninstallFilterTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript functionality of the module uninstall filter. - * - * @group system */ +#[Group('system')] class ModuleUninstallFilterTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/OffCanvasTest.php b/core/modules/system/tests/src/FunctionalJavascript/OffCanvasTest.php index aef99eb3901..9839fc3edd4 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/OffCanvasTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/OffCanvasTest.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace Drupal\Tests\system\FunctionalJavascript; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + /** * Tests the off-canvas dialog functionality. - * - * @group system */ +#[Group('system')] class OffCanvasTest extends OffCanvasTestBase { /** @@ -34,9 +36,8 @@ class OffCanvasTest extends OffCanvasTestBase { /** * Tests that non-contextual links will work with the off-canvas dialog. - * - * @dataProvider themeDataProvider */ + #[DataProvider('themeDataProvider')] public function testOffCanvasLinks($theme): void { $this->enableTheme($theme); $this->drupalGet('/off-canvas-test-links'); diff --git a/core/modules/system/tests/src/FunctionalJavascript/System/DateFormatTest.php b/core/modules/system/tests/src/FunctionalJavascript/System/DateFormatTest.php index d2ab2f4a744..1bbb86d92e6 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/System/DateFormatTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/System/DateFormatTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\system\FunctionalJavascript\System; use Drupal\Core\Datetime\Entity\DateFormat; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests that date formats UI with JavaScript enabled. - * - * @group system */ +#[Group('system')] class DateFormatTest extends WebDriverTestBase { /** diff --git a/core/modules/system/tests/src/FunctionalJavascript/ThemeSettingsFormTest.php b/core/modules/system/tests/src/FunctionalJavascript/ThemeSettingsFormTest.php index 41fc94d4f1e..9c94c6040b0 100644 --- a/core/modules/system/tests/src/FunctionalJavascript/ThemeSettingsFormTest.php +++ b/core/modules/system/tests/src/FunctionalJavascript/ThemeSettingsFormTest.php @@ -7,12 +7,13 @@ namespace Drupal\Tests\system\FunctionalJavascript; use Drupal\file\Entity\File; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\TestFileCreationTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * Tests that theme form settings works correctly. - * - * @group system */ +#[Group('system')] class ThemeSettingsFormTest extends WebDriverTestBase { use TestFileCreationTrait; @@ -39,9 +40,8 @@ class ThemeSettingsFormTest extends WebDriverTestBase { /** * Tests that submission handler works correctly. - * - * @dataProvider providerTestFormSettingsSubmissionHandler */ + #[DataProvider('providerTestFormSettingsSubmissionHandler')] public function testFormSettingsSubmissionHandler($theme): void { \Drupal::service('theme_installer')->install([$theme]); 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/CronQueueTest.php b/core/modules/system/tests/src/Kernel/System/CronQueueTest.php index 96a02f8f164..069a26c3eb5 100644 --- a/core/modules/system/tests/src/Kernel/System/CronQueueTest.php +++ b/core/modules/system/tests/src/Kernel/System/CronQueueTest.php @@ -70,7 +70,6 @@ class CronQueueTest extends KernelTestBase { parent::setUp(); $this->connection = Database::getConnection(); - $this->cron = \Drupal::service('cron'); $time = $this->prophesize('Drupal\Component\Datetime\TimeInterface'); $time->getCurrentTime()->willReturn($this->currentTime); @@ -91,6 +90,8 @@ class CronQueueTest extends KernelTestBase { }); $this->container->set('queue', $queue_factory->reveal()); + // Instantiate the `cron` service after the mock queue factory is set. + $this->cron = \Drupal::service('cron'); } /** 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/system/tests/src/Unit/Event/SecurityFileUploadEventSubscriberTest.php b/core/modules/system/tests/src/Unit/Event/SecurityFileUploadEventSubscriberTest.php index 79b9f52812e..2cca7450089 100644 --- a/core/modules/system/tests/src/Unit/Event/SecurityFileUploadEventSubscriberTest.php +++ b/core/modules/system/tests/src/Unit/Event/SecurityFileUploadEventSubscriberTest.php @@ -85,12 +85,17 @@ class SecurityFileUploadEventSubscriberTest extends UnitTestCase { 'no extension produces no errors' => ['foo', '', 'foo'], 'filename is munged' => ['foo.phar.png.php.jpg', 'jpg png', 'foo.phar_.png_.php_.jpg'], 'filename is munged regardless of case' => ['FOO.pHAR.PNG.PhP.jpg', 'jpg png', 'FOO.pHAR_.PNG_.PhP_.jpg'], - 'null bytes are removed' => ['foo' . chr(0) . '.txt' . chr(0), '', 'foo.txt'], + 'null bytes are removed even if some extensions are allowed' => [ + 'foo' . chr(0) . '.html' . chr(0), + 'txt', + 'foo.html', + ], 'dot files are renamed' => ['.git', '', 'git'], - 'htaccess files are renamed even if allowed' => ['.htaccess', 'htaccess txt', '.htaccess_.txt', '.htaccess'], + 'htaccess files are renamed even if allowed' => ['.htaccess', 'htaccess txt', 'htaccess'], '.phtml extension allowed with .phtml file' => ['foo.phtml', 'phtml', 'foo.phtml'], '.phtml, .txt extension allowed with .phtml file' => ['foo.phtml', 'phtml txt', 'foo.phtml_.txt', 'foo.phtml'], 'All extensions allowed with .phtml file' => ['foo.phtml', '', 'foo.phtml_.txt', 'foo.phtml'], + 'dot files are renamed even if allowed and not in security list' => ['.git', 'git', 'git'], ]; } @@ -147,18 +152,10 @@ class SecurityFileUploadEventSubscriberTest extends UnitTestCase { // The following filename would be rejected by 'FileExtension' constraint // and therefore remains unchanged. '.php is not munged when it would be rejected' => ['foo.php.php', 'jpg'], - '.php is not munged when it would be rejected and filename contains null byte character' => [ - 'foo.' . chr(0) . 'php.php', - 'jpg', - ], 'extension less files are not munged when they would be rejected' => [ 'foo', 'jpg', ], - 'dot files are not munged when they would be rejected' => [ - '.htaccess', - 'jpg png', - ], ]; } diff --git a/core/modules/system/tests/src/Unit/Pager/PreprocessPagerTest.php b/core/modules/system/tests/src/Unit/Pager/PreprocessPagerTest.php index ab42b418125..8a2cfe22f03 100644 --- a/core/modules/system/tests/src/Unit/Pager/PreprocessPagerTest.php +++ b/core/modules/system/tests/src/Unit/Pager/PreprocessPagerTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Unit\Pager; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Pager\PagerPreprocess; use Drupal\Core\Template\AttributeString; use Drupal\Tests\UnitTestCase; @@ -12,10 +13,17 @@ use Drupal\Tests\UnitTestCase; * Tests pager preprocessing. * * @group system + * + * @coversDefaultClass \Drupal\Core\Pager\PagerPreprocess */ class PreprocessPagerTest extends UnitTestCase { /** + * Pager preprocess instance. + */ + protected PagerPreprocess $pagerPreprocess; + + /** * {@inheritdoc} */ protected function setUp(): void { @@ -39,21 +47,19 @@ class PreprocessPagerTest extends UnitTestCase { $pager_manager->method('getPager')->willReturn($pager); $pager_manager->method('getUpdatedParameters')->willReturn(''); + $this->pagerPreprocess = new PagerPreprocess($pager_manager); + $container = new ContainerBuilder(); - $container->set('pager.manager', $pager_manager); $container->set('url_generator', $url_generator); - // template_preprocess_pager() renders translatable attribute values. - $container->set('string_translation', $this->getStringTranslationStub()); \Drupal::setContainer($container); } /** - * Tests template_preprocess_pager() when an empty #quantity is passed. + * Tests when an empty #quantity is passed. * - * @covers ::template_preprocess_pager + * @covers ::preprocessPager */ public function testQuantityNotSet(): void { - require_once $this->root . '/core/includes/theme.inc'; $variables = [ 'pager' => [ '#element' => '', @@ -63,18 +69,17 @@ class PreprocessPagerTest extends UnitTestCase { '#tags' => '', ], ]; - template_preprocess_pager($variables); + $this->pagerPreprocess->preprocessPager($variables); $this->assertEquals(['first', 'previous'], array_keys($variables['items'])); } /** - * Tests template_preprocess_pager() when a #quantity value is passed. + * Tests when a #quantity value is passed. * - * @covers ::template_preprocess_pager + * @covers ::preprocessPager */ public function testQuantitySet(): void { - require_once $this->root . '/core/includes/theme.inc'; $variables = [ 'pager' => [ '#element' => '2', @@ -84,7 +89,7 @@ class PreprocessPagerTest extends UnitTestCase { '#tags' => '', ], ]; - template_preprocess_pager($variables); + $this->pagerPreprocess->preprocessPager($variables); $this->assertEquals(['first', 'previous', 'pages'], array_keys($variables['items'])); /** @var \Drupal\Core\Template\AttributeString $attribute */ @@ -94,12 +99,11 @@ class PreprocessPagerTest extends UnitTestCase { } /** - * Tests template_preprocess_pager() when an empty #pagination_heading_level value is passed. + * Tests when an empty #pagination_heading_level value is passed. * - * @covers ::template_preprocess_pager + * @covers ::preprocessPager */ public function testEmptyPaginationHeadingLevelSet(): void { - require_once $this->root . '/core/includes/theme.inc'; $variables = [ 'pager' => [ '#element' => '2', @@ -110,18 +114,17 @@ class PreprocessPagerTest extends UnitTestCase { '#tags' => '', ], ]; - template_preprocess_pager($variables); + $this->pagerPreprocess->preprocessPager($variables); $this->assertEquals('h4', $variables['pagination_heading_level']); } /** - * Tests template_preprocess_pager() when no #pagination_heading_level is passed. + * Tests when no #pagination_heading_level is passed. * - * @covers ::template_preprocess_pager + * @covers ::preprocessPager */ public function testPaginationHeadingLevelNotSet(): void { - require_once $this->root . '/core/includes/theme.inc'; $variables = [ 'pager' => [ '#element' => '', @@ -131,18 +134,17 @@ class PreprocessPagerTest extends UnitTestCase { '#tags' => '', ], ]; - template_preprocess_pager($variables); + $this->pagerPreprocess->preprocessPager($variables); $this->assertEquals('h4', $variables['pagination_heading_level']); } /** - * Tests template_preprocess_pager() when a #pagination_heading_level value is passed. + * Tests when a #pagination_heading_level value is passed. * - * @covers ::template_preprocess_pager + * @covers ::preprocessPager */ public function testPaginationHeadingLevelSet(): void { - require_once $this->root . '/core/includes/theme.inc'; $variables = [ 'pager' => [ '#element' => '2', @@ -153,18 +155,17 @@ class PreprocessPagerTest extends UnitTestCase { '#tags' => '', ], ]; - template_preprocess_pager($variables); + $this->pagerPreprocess->preprocessPager($variables); $this->assertEquals('h5', $variables['pagination_heading_level']); } /** - * Test template_preprocess_pager() with an invalid #pagination_heading_level. + * Test with an invalid #pagination_heading_level. * - * @covers ::template_preprocess_pager + * @covers ::preprocessPager */ public function testPaginationHeadingLevelInvalid(): void { - require_once $this->root . '/core/includes/theme.inc'; $variables = [ 'pager' => [ '#element' => '2', @@ -175,7 +176,7 @@ class PreprocessPagerTest extends UnitTestCase { '#tags' => '', ], ]; - template_preprocess_pager($variables); + $this->pagerPreprocess->preprocessPager($variables); $this->assertEquals('h4', $variables['pagination_heading_level']); } diff --git a/core/modules/system/tests/themes/test_theme/test_theme.theme b/core/modules/system/tests/themes/test_theme/test_theme.theme index 80214af02d0..d0b3b2b71bf 100644 --- a/core/modules/system/tests/themes/test_theme/test_theme.theme +++ b/core/modules/system/tests/themes/test_theme/test_theme.theme @@ -54,7 +54,7 @@ function test_theme_theme_suggestions_alter(array &$suggestions, array &$variabl // the theme_suggestions_test module can be picked up when that module is // enabled. if ($hook == 'theme_test_general_suggestions') { - array_unshift($suggestions, 'theme_test_general_suggestions__' . 'theme_override'); + array_unshift($suggestions, 'theme_test_general_suggestions__theme_override'); $variables['theme_hook'] = 'test_theme_theme_suggestions_alter'; } } @@ -68,7 +68,7 @@ function test_theme_theme_suggestions_theme_test_suggestions_alter(array &$sugge // suggestion to the beginning of the array so that the suggestion added by // the theme_suggestions_test module can be picked up when that module is // enabled. - array_unshift($suggestions, 'theme_test_suggestions__' . 'theme_override'); + array_unshift($suggestions, 'theme_test_suggestions__theme_override'); } /** diff --git a/core/modules/taxonomy/src/Hook/TaxonomyHooks.php b/core/modules/taxonomy/src/Hook/TaxonomyHooks.php index 4cc0a6f6eb6..82a88ce8a19 100644 --- a/core/modules/taxonomy/src/Hook/TaxonomyHooks.php +++ b/core/modules/taxonomy/src/Hook/TaxonomyHooks.php @@ -180,4 +180,9 @@ class TaxonomyHooks { } } + // phpcs:ignore Drupal.Commenting.InlineComment.DocBlock + /** + * @} End of "defgroup taxonomy_index". + */ + } diff --git a/core/modules/taxonomy/src/Plugin/views/field/TaxonomyIndexTid.php b/core/modules/taxonomy/src/Plugin/views/field/TaxonomyIndexTid.php index bf83b7067c5..b41086d059e 100644 --- a/core/modules/taxonomy/src/Plugin/views/field/TaxonomyIndexTid.php +++ b/core/modules/taxonomy/src/Plugin/views/field/TaxonomyIndexTid.php @@ -174,10 +174,10 @@ class TaxonomyIndexTid extends PrerenderList { * {@inheritdoc} */ protected function documentSelfTokens(&$tokens) { - $tokens['{{ ' . $this->options['id'] . '__tid' . ' }}'] = $this->t('The taxonomy term ID for the term.'); - $tokens['{{ ' . $this->options['id'] . '__name' . ' }}'] = $this->t('The taxonomy term name for the term.'); - $tokens['{{ ' . $this->options['id'] . '__vocabulary_vid' . ' }}'] = $this->t('The machine name for the vocabulary the term belongs to.'); - $tokens['{{ ' . $this->options['id'] . '__vocabulary' . ' }}'] = $this->t('The name for the vocabulary the term belongs to.'); + $tokens['{{ ' . $this->options['id'] . '__tid }}'] = $this->t('The taxonomy term ID for the term.'); + $tokens['{{ ' . $this->options['id'] . '__name }}'] = $this->t('The taxonomy term name for the term.'); + $tokens['{{ ' . $this->options['id'] . '__vocabulary_vid }}'] = $this->t('The machine name for the vocabulary the term belongs to.'); + $tokens['{{ ' . $this->options['id'] . '__vocabulary }}'] = $this->t('The name for the vocabulary the term belongs to.'); } /** diff --git a/core/modules/taxonomy/taxonomy.module b/core/modules/taxonomy/taxonomy.module index 04c00e36b6c..28e54b11f71 100644 --- a/core/modules/taxonomy/taxonomy.module +++ b/core/modules/taxonomy/taxonomy.module @@ -90,6 +90,11 @@ function taxonomy_term_is_page(Term $term) { } /** + * @addtogroup taxonomy_index + * @{ + */ + +/** * Builds and inserts taxonomy index entries for a given node. * * The index lists all terms that are related to a given node entity, and is @@ -152,5 +157,5 @@ function taxonomy_delete_node_index(EntityInterface $node): void { } /** - * @} End of "defgroup taxonomy_index". + * @} End of "addtogroup taxonomy_index". */ 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/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTranslationTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTranslationTest.php index 1fb8b819fdc..3499a404ac9 100644 --- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTranslationTest.php +++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTranslationTest.php @@ -12,6 +12,7 @@ use Drupal\taxonomy\TermInterface; * Test migration of translated taxonomy terms. * * @group migrate_drupal_7 + * @group #slow */ class MigrateTaxonomyTermTranslationTest extends MigrateDrupal7TestBase { diff --git a/core/modules/text/src/Plugin/Field/FieldWidget/TextfieldWidget.php b/core/modules/text/src/Plugin/Field/FieldWidget/TextfieldWidget.php index 16b7549681c..289fbbd332c 100644 --- a/core/modules/text/src/Plugin/Field/FieldWidget/TextfieldWidget.php +++ b/core/modules/text/src/Plugin/Field/FieldWidget/TextfieldWidget.php @@ -6,7 +6,10 @@ use Drupal\Core\Field\Attribute\FieldWidget; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldWidget\StringTextfieldWidget; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\Element\ElementInterface; +use Drupal\Core\Render\Element\Widget; use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\filter\Element\TextFormat; use Symfony\Component\Validator\ConstraintViolationInterface; /** @@ -22,20 +25,20 @@ class TextfieldWidget extends StringTextfieldWidget { /** * {@inheritdoc} */ - public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { - $main_widget = parent::formElement($items, $delta, $element, $form, $form_state); + public function singleElementObject(FieldItemListInterface $items, $delta, Widget $widget, ElementInterface $form, FormStateInterface $form_state): ElementInterface { + $widget = parent::singleElementObject($items, $delta, $widget, $form, $form_state); $allowed_formats = $this->getFieldSetting('allowed_formats'); - $element = $main_widget['value']; - $element['#type'] = 'text_format'; - $element['#format'] = $items[$delta]->format ?? NULL; - $element['#base_type'] = $main_widget['value']['#type']; - + $widget = $widget->getChild('value'); + $type = $widget->type; + $widget = $widget->changeType(TextFormat::class); + $widget->format = $items[$delta]->format ?? NULL; + $widget->base_type = $type; if ($allowed_formats && !$this->isDefaultValueWidget($form_state)) { - $element['#allowed_formats'] = $allowed_formats; + $widget->allowed_formats = $allowed_formats; } - return $element; + return $widget; } /** diff --git a/core/modules/text/tests/src/FunctionalJavascript/TextareaWithSummaryTest.php b/core/modules/text/tests/src/FunctionalJavascript/TextareaWithSummaryTest.php index 957b7ae5e9d..92576dcb95b 100644 --- a/core/modules/text/tests/src/FunctionalJavascript/TextareaWithSummaryTest.php +++ b/core/modules/text/tests/src/FunctionalJavascript/TextareaWithSummaryTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\text\FunctionalJavascript; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript functionality of the text_textarea_with_summary widget. - * - * @group text */ +#[Group('text')] class TextareaWithSummaryTest extends WebDriverTestBase { /** diff --git a/core/modules/toolbar/css/toolbar.theme.css b/core/modules/toolbar/css/toolbar.theme.css index ea108f65809..d6def473940 100644 --- a/core/modules/toolbar/css/toolbar.theme.css +++ b/core/modules/toolbar/css/toolbar.theme.css @@ -5,10 +5,10 @@ font-family: "Source Sans Pro", "Lucida Grande", Verdana, sans-serif; /* Set base font size to 13px based on root ems. */ font-size: 0.8125rem; - -moz-tap-highlight-color: rgba(0, 0, 0, 0); - -o-tap-highlight-color: rgba(0, 0, 0, 0); - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); - tap-highlight-color: rgba(0, 0, 0, 0); + -moz-tap-highlight-color: rgb(0, 0, 0, 0); + -o-tap-highlight-color: rgb(0, 0, 0, 0); + -webkit-tap-highlight-color: rgb(0, 0, 0, 0); + tap-highlight-color: rgb(0, 0, 0, 0); -moz-touch-callout: none; -o-touch-callout: none; -webkit-touch-callout: none; @@ -31,10 +31,10 @@ .toolbar .toolbar-bar { color: #ddd; background-color: #0f0f0f; - box-shadow: -1px 0 3px 1px rgba(0, 0, 0, 0.3333); /* LTR */ + box-shadow: -1px 0 3px 1px rgb(0, 0, 0, 0.3333); /* LTR */ } [dir="rtl"] .toolbar .toolbar-bar { - box-shadow: 1px 0 3px 1px rgba(0, 0, 0, 0.3333); + box-shadow: 1px 0 3px 1px rgb(0, 0, 0, 0.3333); } .toolbar .toolbar-bar .toolbar-item { color: #fff; @@ -44,7 +44,7 @@ } .toolbar .toolbar-bar .toolbar-tab > .toolbar-item:hover, .toolbar .toolbar-bar .toolbar-tab > .toolbar-item:focus { - background-image: linear-gradient(rgba(255, 255, 255, 0.125) 20%, transparent 200%); + background-image: linear-gradient(rgb(255, 255, 255, 0.125) 20%, transparent 200%); } .toolbar .toolbar-bar .toolbar-tab > .toolbar-item.is-active { color: #000; @@ -68,19 +68,19 @@ .toolbar .toolbar-tray-vertical { border-right: 1px solid #aaa; /* LTR */ background-color: #f5f5f5; - box-shadow: -1px 0 5px 2px rgba(0, 0, 0, 0.3333); /* LTR */ + box-shadow: -1px 0 5px 2px rgb(0, 0, 0, 0.3333); /* LTR */ } [dir="rtl"] .toolbar .toolbar-tray-vertical { border-right: 0 none; border-left: 1px solid #aaa; - box-shadow: 1px 0 5px 2px rgba(0, 0, 0, 0.3333); + box-shadow: 1px 0 5px 2px rgb(0, 0, 0, 0.3333); } .toolbar-horizontal .toolbar-tray { border-bottom: 1px solid #aaa; - box-shadow: -2px 1px 3px 1px rgba(0, 0, 0, 0.3333); /* LTR */ + box-shadow: -2px 1px 3px 1px rgb(0, 0, 0, 0.3333); /* LTR */ } [dir="rtl"] .toolbar-horizontal .toolbar-tray { - box-shadow: 2px 1px 3px 1px rgba(0, 0, 0, 0.3333); + box-shadow: 2px 1px 3px 1px rgb(0, 0, 0, 0.3333); } .toolbar .toolbar-tray-horizontal .toolbar-tray { background-color: #f5f5f5; 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/ToolbarActiveTrailTest.php b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarActiveTrailTest.php index b12ce9ef54f..73f495ae850 100644 --- a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarActiveTrailTest.php +++ b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarActiveTrailTest.php @@ -5,12 +5,13 @@ declare(strict_types=1); namespace Drupal\Tests\toolbar\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\TestWith; /** * Tests that the active trail is maintained in the toolbar. - * - * @group toolbar */ +#[Group('toolbar')] class ToolbarActiveTrailTest extends WebDriverTestBase { /** @@ -43,11 +44,10 @@ class ToolbarActiveTrailTest extends WebDriverTestBase { * @param string $orientation * The toolbar orientation. * - * @testWith ["vertical"] - * ["horizontal"] - * * @throws \Behat\Mink\Exception\ElementNotFoundException */ + #[TestWith(["vertical"])] + #[TestWith(["horizontal"])] public function testToolbarActiveTrail(string $orientation): void { $page = $this->getSession()->getPage(); $assert_session = $this->assertSession(); diff --git a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php index c315f9f6ebb..639129786b3 100644 --- a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php +++ b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace Drupal\Tests\toolbar\FunctionalJavascript; +use Behat\Mink\Element\NodeElement; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript functionality of the toolbar. - * - * @group toolbar */ +#[Group('toolbar')] class ToolbarIntegrationTest extends 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/FunctionalJavascript/ToolbarStoredStateTest.php b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarStoredStateTest.php index 432dd8c8522..9fc82f8f007 100644 --- a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarStoredStateTest.php +++ b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarStoredStateTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\toolbar\FunctionalJavascript; use Drupal\Component\Serialization\Json; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the sessionStorage state set by the toolbar. - * - * @group toolbar */ +#[Group('toolbar')] class ToolbarStoredStateTest extends WebDriverTestBase { /** 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..6577f7f1fc2 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; @@ -77,14 +78,6 @@ class UpdateHooks { $verbose = TRUE; break; } - // This loadInclude() is to ensure that the install API is available. - // Since we're loading an include of type 'install', this will also - // include core/includes/install.inc for us, which is where the - // REQUIREMENTS* constants are currently defined. - // @todo Remove this once those constants live in a better place. - // @see https://www.drupal.org/project/drupal/issues/2909480 - // @see https://www.drupal.org/project/drupal/issues/3410938 - \Drupal::moduleHandler()->loadInclude('update', 'install'); $status = \Drupal::moduleHandler()->invoke('update', 'runtime_requirements'); foreach (['core', 'contrib'] as $report_type) { $type = 'update_' . $report_type; @@ -96,10 +89,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/tests/src/Functional/UpdateSemverContribTestBase.php b/core/modules/update/tests/src/Functional/UpdateSemverContribTestBase.php index 2f5c7c038b9..bd554c0e850 100644 --- a/core/modules/update/tests/src/Functional/UpdateSemverContribTestBase.php +++ b/core/modules/update/tests/src/Functional/UpdateSemverContribTestBase.php @@ -10,7 +10,7 @@ namespace Drupal\Tests\update\Functional; * This wires up the protected data from UpdateSemverTestBase for a contrib * module with semantic version releases. */ -class UpdateSemverContribTestBase extends UpdateSemverTestBase { +abstract class UpdateSemverContribTestBase extends UpdateSemverTestBase { /** * {@inheritdoc} diff --git a/core/modules/update/tests/src/Functional/UpdateSemverCoreTestBase.php b/core/modules/update/tests/src/Functional/UpdateSemverCoreTestBase.php index b9d3a46a68e..f336562c479 100644 --- a/core/modules/update/tests/src/Functional/UpdateSemverCoreTestBase.php +++ b/core/modules/update/tests/src/Functional/UpdateSemverCoreTestBase.php @@ -10,7 +10,7 @@ namespace Drupal\Tests\update\Functional; * This wires up the protected data from UpdateSemverTestBase for Drupal core * with semantic version releases. */ -class UpdateSemverCoreTestBase extends UpdateSemverTestBase { +abstract class UpdateSemverCoreTestBase extends UpdateSemverTestBase { /** * {@inheritdoc} diff --git a/core/modules/update/update.fetch.inc b/core/modules/update/update.fetch.inc index a0d2a22e562..12295b97d98 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; /** @@ -19,21 +20,13 @@ use Drupal\update\UpdateManagerInterface; #[ProceduralHookScanStop] function _update_cron_notify(): void { $update_config = \Drupal::config('update.settings'); - // This loadInclude() is to ensure that the install API is available. - // Since we're loading an include of type 'install', this will also - // include core/includes/install.inc for us, which is where the - // REQUIREMENTS* constants are currently defined. - // @todo Remove this once those constants live in a better place. - // @see https://www.drupal.org/project/drupal/issues/2909480 - // @see https://www.drupal.org/project/drupal/issues/3410938 - \Drupal::moduleHandler()->loadInclude('update', 'install'); $status = \Drupal::moduleHandler()->invoke('update', 'runtime_requirements'); $params = []; $notify_all = ($update_config->get('notification.threshold') == 'all'); 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/config/schema/user.schema.yml b/core/modules/user/config/schema/user.schema.yml index ac54b6986d7..d58cb3dc4ea 100644 --- a/core/modules/user/config/schema/user.schema.yml +++ b/core/modules/user/config/schema/user.schema.yml @@ -105,6 +105,8 @@ user.mail: user.flood: type: config_object label: 'User flood settings' + constraints: + FullyValidatable: ~ mapping: uid_only: type: boolean @@ -112,15 +114,27 @@ user.flood: ip_limit: type: integer label: 'IP limit' + constraints: + Range: + min: 0 ip_window: type: integer label: 'IP window' + constraints: + Range: + min: 0 user_limit: type: integer label: 'User limit' + constraints: + Range: + min: 0 user_window: type: integer label: 'User window' + constraints: + Range: + min: 0 user.role.*: type: config_entity diff --git a/core/modules/user/src/AccountForm.php b/core/modules/user/src/AccountForm.php index 881244f6f53..d0c5e8d2d9b 100644 --- a/core/modules/user/src/AccountForm.php +++ b/core/modules/user/src/AccountForm.php @@ -402,6 +402,7 @@ abstract class AccountForm extends ContentEntityForm implements TrustedCallbackI 'name', 'pass', 'mail', + 'roles', 'timezone', 'langcode', 'preferred_langcode', @@ -420,6 +421,7 @@ abstract class AccountForm extends ContentEntityForm implements TrustedCallbackI 'name', 'pass', 'mail', + 'roles', 'timezone', 'langcode', 'preferred_langcode', diff --git a/core/modules/user/src/Hook/UserRequirements.php b/core/modules/user/src/Hook/UserRequirements.php index 186ce12285f..46155e55e3c 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, ]; } @@ -48,6 +49,7 @@ class UserRequirements { $query->addExpression('LOWER(mail)', 'lower_mail'); $query->isNotNull('mail'); $query->groupBy('lower_mail'); + $query->groupBy('langcode'); $query->having('COUNT(uid) > :matches', [':matches' => 1]); $conflicts = $query->countQuery()->execute()->fetchField(); @@ -57,7 +59,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/user/src/Plugin/views/field/Roles.php b/core/modules/user/src/Plugin/views/field/Roles.php index 096e161a3f9..bc1ab4fb284 100644 --- a/core/modules/user/src/Plugin/views/field/Roles.php +++ b/core/modules/user/src/Plugin/views/field/Roles.php @@ -108,8 +108,8 @@ class Roles extends PrerenderList { * {@inheritdoc} */ protected function documentSelfTokens(&$tokens) { - $tokens['{{ ' . $this->options['id'] . '__role' . ' }}'] = $this->t('The name of the role.'); - $tokens['{{ ' . $this->options['id'] . '__rid' . ' }}'] = $this->t('The role machine-name of the role.'); + $tokens['{{ ' . $this->options['id'] . '__role }}'] = $this->t('The name of the role.'); + $tokens['{{ ' . $this->options['id'] . '__rid }}'] = $this->t('The role machine-name of the role.'); } /** diff --git a/core/modules/user/tests/modules/user_form_test/src/Hook/UserFormTestHooks.php b/core/modules/user/tests/modules/user_form_test/src/Hook/UserFormTestHooks.php index f49008597f1..106199f413f 100644 --- a/core/modules/user/tests/modules/user_form_test/src/Hook/UserFormTestHooks.php +++ b/core/modules/user/tests/modules/user_form_test/src/Hook/UserFormTestHooks.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\user_form_test\Hook; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Hook\Attribute\Hook; /** @@ -20,4 +21,14 @@ class UserFormTestHooks { $form['access']['#value'] = \Drupal::currentUser()->hasPermission('cancel other accounts'); } + /** + * Implements hook_entity_base_field_info_alter(). + */ + #[Hook('entity_base_field_info_alter')] + public function entityBaseFieldInfoAlter(&$fields, EntityTypeInterface $entity_type): void { + if ($entity_type->id() === 'user' && \Drupal::keyvalue('user_form_test')->get('user_form_test_constraint_roles_edit')) { + $fields['roles']->addConstraint('FieldWidgetConstraint'); + } + } + } diff --git a/core/modules/user/tests/src/Functional/UserEditTest.php b/core/modules/user/tests/src/Functional/UserEditTest.php index f9a8dfb1a75..ff86fa24908 100644 --- a/core/modules/user/tests/src/Functional/UserEditTest.php +++ b/core/modules/user/tests/src/Functional/UserEditTest.php @@ -283,4 +283,21 @@ class UserEditTest extends BrowserTestBase { $this->assertSession()->fieldEnabled('edit-status-1'); } + /** + * Tests constraint violations are triggered on the user account form. + */ + public function testRolesValidation(): void { + $admin_user = $this->drupalCreateUser(['administer users']); + $this->drupalLogin($admin_user); + $this->drupalGet("user/" . $admin_user->id() . "/edit"); + $this->submitForm([], 'Save'); + $this->assertSession()->pageTextContains('The changes have been saved.'); + \Drupal::keyvalue('user_form_test')->set('user_form_test_constraint_roles_edit', TRUE); + \Drupal::service('module_installer')->install(['entity_test', 'user_form_test']); + $this->drupalGet("user/" . $admin_user->id() . "/edit"); + $this->submitForm([], 'Save'); + $this->assertSession()->pageTextContains('Widget constraint has failed.'); + $this->assertSession()->pageTextNotContains('The changes have been saved.'); + } + } diff --git a/core/modules/user/tests/src/FunctionalJavascript/PasswordConfirmWidgetTest.php b/core/modules/user/tests/src/FunctionalJavascript/PasswordConfirmWidgetTest.php index 7f63bec926e..c7441b55126 100644 --- a/core/modules/user/tests/src/FunctionalJavascript/PasswordConfirmWidgetTest.php +++ b/core/modules/user/tests/src/FunctionalJavascript/PasswordConfirmWidgetTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\user\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JS components added to the PasswordConfirm render element. - * - * @group user */ +#[Group('user')] class PasswordConfirmWidgetTest extends WebDriverTestBase { /** diff --git a/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php b/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php index f961061c2c4..2a56dcb2d7c 100644 --- a/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php +++ b/core/modules/user/tests/src/FunctionalJavascript/PermissionFilterTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\user\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript functionality of the permission filter. - * - * @group user */ +#[Group('user')] class PermissionFilterTest extends WebDriverTestBase { /** diff --git a/core/modules/user/tests/src/FunctionalJavascript/RegistrationWithUserFieldsTest.php b/core/modules/user/tests/src/FunctionalJavascript/RegistrationWithUserFieldsTest.php index 7361ea0698c..120bf2e44f1 100644 --- a/core/modules/user/tests/src/FunctionalJavascript/RegistrationWithUserFieldsTest.php +++ b/core/modules/user/tests/src/FunctionalJavascript/RegistrationWithUserFieldsTest.php @@ -4,16 +4,16 @@ declare(strict_types=1); namespace Drupal\Tests\user\FunctionalJavascript; -use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests user registration forms with additional fields. - * - * @group user */ +#[Group('user')] class RegistrationWithUserFieldsTest extends WebDriverTestBase { /** diff --git a/core/modules/user/tests/src/FunctionalJavascript/UserPasswordResetTest.php b/core/modules/user/tests/src/FunctionalJavascript/UserPasswordResetTest.php index ab815c20cdb..a8fc7da8b47 100644 --- a/core/modules/user/tests/src/FunctionalJavascript/UserPasswordResetTest.php +++ b/core/modules/user/tests/src/FunctionalJavascript/UserPasswordResetTest.php @@ -10,12 +10,12 @@ use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\TestFileCreationTrait; use Drupal\user\Entity\User; +use PHPUnit\Framework\Attributes\Group; /** * Ensure that password reset methods work as expected. - * - * @group user */ +#[Group('user')] class UserPasswordResetTest extends WebDriverTestBase { use AssertMailTrait { diff --git a/core/modules/user/tests/src/FunctionalJavascript/UserPermissionsTest.php b/core/modules/user/tests/src/FunctionalJavascript/UserPermissionsTest.php index d467a2fbabf..6c2e80c438b 100644 --- a/core/modules/user/tests/src/FunctionalJavascript/UserPermissionsTest.php +++ b/core/modules/user/tests/src/FunctionalJavascript/UserPermissionsTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\user\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\user\RoleInterface; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JS components added to the user permissions page. - * - * @group user */ +#[Group('user')] class UserPermissionsTest extends WebDriverTestBase { /** diff --git a/core/modules/user/tests/src/FunctionalJavascript/UserRegisterFormTest.php b/core/modules/user/tests/src/FunctionalJavascript/UserRegisterFormTest.php index 6e0d600aa3b..e35ee0b19b1 100644 --- a/core/modules/user/tests/src/FunctionalJavascript/UserRegisterFormTest.php +++ b/core/modules/user/tests/src/FunctionalJavascript/UserRegisterFormTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\user\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests user registration forms via JS. - * - * @group user */ +#[Group('user')] class UserRegisterFormTest extends WebDriverTestBase { /** diff --git a/core/modules/user/tests/src/Kernel/UserRequirementsTest.php b/core/modules/user/tests/src/Kernel/UserRequirementsTest.php index 146ab9c8b90..746370a15d6 100644 --- a/core/modules/user/tests/src/Kernel/UserRequirementsTest.php +++ b/core/modules/user/tests/src/Kernel/UserRequirementsTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\user\Kernel; use Drupal\KernelTests\KernelTestBase; +use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\user\Traits\UserCreationTrait; /** @@ -70,4 +71,21 @@ class UserRequirementsTest extends KernelTestBase { $this->assertArrayNotHasKey('conflicting emails', $output); } + /** + * Tests that the requirements check does not flag user translations. + */ + public function testTranslatedUserEmail(): void { + \Drupal::service('module_installer')->install(['language']); + ConfigurableLanguage::createFromLangcode('is')->save(); + + $output = $this->moduleHandler->invoke('user', 'runtime_requirements'); + $this->assertArrayNotHasKey('conflicting emails', $output); + + $user = $this->createUser([], 'User A', FALSE, ['mail' => 'unique@example.com']); + $user->addTranslation('is')->save(); + + $output = $this->moduleHandler->invoke('user', 'runtime_requirements'); + $this->assertArrayNotHasKey('conflicting emails', $output); + } + } diff --git a/core/modules/views/config/schema/views.data_types.schema.yml b/core/modules/views/config/schema/views.data_types.schema.yml index 0e011d73dc1..31c6b2f6479 100644 --- a/core/modules/views/config/schema/views.data_types.schema.yml +++ b/core/modules/views/config/schema/views.data_types.schema.yml @@ -633,29 +633,33 @@ views_pager: offset: type: integer label: 'Offset' + constraints: + PositiveOrZero: [] pagination_heading_level: + # Validating against a string, but the list is populated by a protected + # property of the plugin. This could be a callback in the future. type: string label: 'Pager header element' items_per_page: type: integer label: 'Items per page' + constraints: + PositiveOrZero: [] views_pager_sql: type: views_pager label: 'SQL pager' mapping: - items_per_page: - type: integer - label: 'Items per page' - pagination_heading_level: - type: string - label: 'Pager header element' total_pages: type: integer label: 'Number of pages' + constraints: + PositiveOrZero: [] id: type: integer label: 'Pager ID' + constraints: + PositiveOrZero: [] tags: type: mapping label: 'Pager link labels' @@ -669,6 +673,8 @@ views_pager_sql: quantity: type: integer label: 'Number of pager links visible' + constraints: + PositiveOrZero: [] expose: type: mapping label: 'Exposed options' @@ -682,6 +688,11 @@ views_pager_sql: items_per_page_options: type: string label: 'Exposed items per page options' + constraints: + # Comma separated list of integers, with optional space in between. + Regex: + pattern: '/^(\d+)(,\s*\d+)*$/' + message: 'Per page should be a valid list of integers.' items_per_page_options_all: type: boolean label: 'Include all items option' diff --git a/core/modules/views/config/schema/views.pager.schema.yml b/core/modules/views/config/schema/views.pager.schema.yml index eb360227e02..072dbc5b87e 100644 --- a/core/modules/views/config/schema/views.pager.schema.yml +++ b/core/modules/views/config/schema/views.pager.schema.yml @@ -33,3 +33,5 @@ views.pager.full: quantity: type: integer label: 'Number of pager links visible' + constraints: + PositiveOrZero: [] 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/Hook/ViewsHooks.php b/core/modules/views/src/Hook/ViewsHooks.php index b309887723c..5a7b28735f5 100644 --- a/core/modules/views/src/Hook/ViewsHooks.php +++ b/core/modules/views/src/Hook/ViewsHooks.php @@ -375,7 +375,7 @@ class ViewsHooks { #[Hook('view_presave')] public function viewPresave(ViewEntityInterface $view): void { /** @var \Drupal\views\ViewsConfigUpdater $config_updater */ - $config_updater = \Drupal::classResolver(ViewsConfigUpdater::class); + $config_updater = \Drupal::service(ViewsConfigUpdater::class); $config_updater->updateAll($view); } diff --git a/core/modules/views/src/Plugin/views/argument/DayDate.php b/core/modules/views/src/Plugin/views/argument/DayDate.php index 884355a72a4..a4a94da0699 100644 --- a/core/modules/views/src/Plugin/views/argument/DayDate.php +++ b/core/modules/views/src/Plugin/views/argument/DayDate.php @@ -29,7 +29,7 @@ class DayDate extends Date { $day = str_pad($data->{$this->name_alias}, 2, '0', STR_PAD_LEFT); // strtotime() respects server timezone, so we need to set the time fixed // as utc time - return $this->dateFormatter->format(strtotime("2005" . "05" . $day . " 00:00:00 UTC"), 'custom', $this->format, 'UTC'); + return $this->dateFormatter->format(strtotime("200505" . $day . " 00:00:00 UTC"), 'custom', $this->format, 'UTC'); } /** @@ -37,7 +37,7 @@ class DayDate extends Date { */ public function title() { $day = str_pad($this->argument, 2, '0', STR_PAD_LEFT); - return $this->dateFormatter->format(strtotime("2005" . "05" . $day . " 00:00:00 UTC"), 'custom', $this->format, 'UTC'); + return $this->dateFormatter->format(strtotime("200505" . $day . " 00:00:00 UTC"), 'custom', $this->format, 'UTC'); } /** diff --git a/core/modules/views/src/Plugin/views/argument/MonthDate.php b/core/modules/views/src/Plugin/views/argument/MonthDate.php index cec2159c9a3..a24f23f120d 100644 --- a/core/modules/views/src/Plugin/views/argument/MonthDate.php +++ b/core/modules/views/src/Plugin/views/argument/MonthDate.php @@ -28,7 +28,7 @@ class MonthDate extends Date { public function summaryName($data) { $month = str_pad($data->{$this->name_alias}, 2, '0', STR_PAD_LEFT); try { - return $this->dateFormatter->format(strtotime("2005" . $month . "15" . " 00:00:00 UTC"), 'custom', $this->format, 'UTC'); + return $this->dateFormatter->format(strtotime("2005" . $month . "15 00:00:00 UTC"), 'custom', $this->format, 'UTC'); } catch (\InvalidArgumentException) { return parent::summaryName($data); @@ -41,7 +41,7 @@ class MonthDate extends Date { public function title() { $month = str_pad($this->argument, 2, '0', STR_PAD_LEFT); try { - return $this->dateFormatter->format(strtotime("2005" . $month . "15" . " 00:00:00 UTC"), 'custom', $this->format, 'UTC'); + return $this->dateFormatter->format(strtotime("2005" . $month . "15 00:00:00 UTC"), 'custom', $this->format, 'UTC'); } catch (\InvalidArgumentException) { return parent::title(); diff --git a/core/modules/views/src/Plugin/views/argument/YearMonthDate.php b/core/modules/views/src/Plugin/views/argument/YearMonthDate.php index 16410ad2556..b82071f6d6a 100644 --- a/core/modules/views/src/Plugin/views/argument/YearMonthDate.php +++ b/core/modules/views/src/Plugin/views/argument/YearMonthDate.php @@ -27,14 +27,14 @@ class YearMonthDate extends Date { */ public function summaryName($data) { $created = $data->{$this->name_alias}; - return $this->dateFormatter->format(strtotime($created . "15" . " 00:00:00 UTC"), 'custom', $this->format, 'UTC'); + return $this->dateFormatter->format(strtotime($created . "15 00:00:00 UTC"), 'custom', $this->format, 'UTC'); } /** * {@inheritdoc} */ public function title() { - return $this->dateFormatter->format(strtotime($this->argument . "15" . " 00:00:00 UTC"), 'custom', $this->format, 'UTC'); + return $this->dateFormatter->format(strtotime($this->argument . "15 00:00:00 UTC"), 'custom', $this->format, 'UTC'); } } 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/src/Plugin/views/pager/Full.php b/core/modules/views/src/Plugin/views/pager/Full.php index 0176fc6e7f9..ed8f7d7566a 100644 --- a/core/modules/views/src/Plugin/views/pager/Full.php +++ b/core/modules/views/src/Plugin/views/pager/Full.php @@ -79,8 +79,8 @@ class Full extends SqlBase { * {@inheritdoc} */ public function render($input) { - // The 0, 1, 3, 4 indexes are correct. See the template_preprocess_pager() - // documentation. + // The 0, 1, 3, 4 indexes are correct. See the + // \Drupal\Core\Pager\PagerPreprocess::preprocessPager() documentation. $tags = [ 0 => $this->options['tags']['first'], 1 => $this->options['tags']['previous'], diff --git a/core/modules/views/src/Plugin/views/pager/Mini.php b/core/modules/views/src/Plugin/views/pager/Mini.php index 0f95f7a0d2f..e17aa7fabdd 100644 --- a/core/modules/views/src/Plugin/views/pager/Mini.php +++ b/core/modules/views/src/Plugin/views/pager/Mini.php @@ -90,7 +90,8 @@ class Mini extends SqlBase { * {@inheritdoc} */ public function render($input) { - // The 1, 3 indexes are correct, see template_preprocess_pager(). + // The 1, 3 indexes are correct, see + // \Drupal\Core\Pager\PagerPreprocess::preprocessPager(). $tags = [ 1 => $this->options['tags']['previous'], 3 => $this->options['tags']['next'], diff --git a/core/modules/views/src/Plugin/views/pager/PagerPluginBase.php b/core/modules/views/src/Plugin/views/pager/PagerPluginBase.php index 3356d703c93..5796488746c 100644 --- a/core/modules/views/src/Plugin/views/pager/PagerPluginBase.php +++ b/core/modules/views/src/Plugin/views/pager/PagerPluginBase.php @@ -29,14 +29,18 @@ abstract class PagerPluginBase extends PluginBase { /** * The current page. + * + * @phpcs:ignore Drupal.Commenting.VariableComment.MissingVar */ - // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName, Drupal.Commenting.VariableComment.Missing + // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName public $current_page = NULL; /** * The total number of lines. + * + * @phpcs:ignore Drupal.Commenting.VariableComment.MissingVar */ - // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName, Drupal.Commenting.VariableComment.Missing + // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName public $total_items = 0; /** diff --git a/core/modules/views/src/Plugin/views/style/StylePluginBase.php b/core/modules/views/src/Plugin/views/style/StylePluginBase.php index 3f555cfec9f..ca8df3ba8fe 100644 --- a/core/modules/views/src/Plugin/views/style/StylePluginBase.php +++ b/core/modules/views/src/Plugin/views/style/StylePluginBase.php @@ -85,12 +85,12 @@ abstract class StylePluginBase extends PluginBase { /** * Stores the rendered field values, keyed by the row index and field name. * + * @var array|null + * * @see \Drupal\views\Plugin\views\style\StylePluginBase::renderFields() * @see \Drupal\views\Plugin\views\style\StylePluginBase::getField() - * - * @var array|null */ - // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName, Drupal.Commenting.VariableComment.Missing + // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName protected $rendered_fields; /** diff --git a/core/modules/views/src/ViewExecutable.php b/core/modules/views/src/ViewExecutable.php index 4867ba48f9a..407260ed285 100644 --- a/core/modules/views/src/ViewExecutable.php +++ b/core/modules/views/src/ViewExecutable.php @@ -400,21 +400,21 @@ class ViewExecutable { /** * Force the query to calculate the total number of results. * - * @todo Move to the query. - * * @var bool + * + * @todo Move to the query. */ - // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName, Drupal.Commenting.VariableComment.Missing + // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName public $get_total_rows; /** * Indicates if the sorts have been built. * - * @todo Group with other static properties. - * * @var bool + * + * @todo Group with other static properties. */ - // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName, Drupal.Commenting.VariableComment.Missing + // phpcs:ignore Drupal.NamingConventions.ValidVariableName.LowerCamelName public $build_sort; /** diff --git a/core/modules/views/src/ViewsConfigUpdater.php b/core/modules/views/src/ViewsConfigUpdater.php index 9c5d38e10ac..c6a4fbc62d0 100644 --- a/core/modules/views/src/ViewsConfigUpdater.php +++ b/core/modules/views/src/ViewsConfigUpdater.php @@ -4,106 +4,38 @@ namespace Drupal\views; use Drupal\Component\Plugin\PluginManagerInterface; use Drupal\Core\Config\TypedConfigManagerInterface; -use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * Provides a BC layer for modules providing old configurations. * * @internal */ -class ViewsConfigUpdater implements ContainerInjectionInterface { - - /** - * The entity type manager. - * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface - */ - protected $entityTypeManager; - - /** - * The entity field manager. - * - * @var \Drupal\Core\Entity\EntityFieldManagerInterface - */ - protected $entityFieldManager; - - /** - * The typed config manager. - * - * @var \Drupal\Core\Config\TypedConfigManagerInterface - */ - protected $typedConfigManager; - - /** - * The views data service. - * - * @var \Drupal\views\ViewsData - */ - protected $viewsData; - - /** - * The formatter plugin manager service. - * - * @var \Drupal\Component\Plugin\PluginManagerInterface - */ - protected $formatterPluginManager; +class ViewsConfigUpdater { /** * Flag determining whether deprecations should be triggered. - * - * @var bool */ - protected $deprecationsEnabled = TRUE; + protected bool $deprecationsEnabled = TRUE; /** * Stores which deprecations were triggered. - * - * @var bool */ - protected $triggeredDeprecations = []; + protected array $triggeredDeprecations = []; /** * ViewsConfigUpdater constructor. - * - * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager - * The entity type manager. - * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager - * The entity field manager. - * @param \Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager - * The typed config manager. - * @param \Drupal\views\ViewsData $views_data - * The views data service. - * @param \Drupal\Component\Plugin\PluginManagerInterface $formatter_plugin_manager - * The formatter plugin manager service. */ public function __construct( - EntityTypeManagerInterface $entity_type_manager, - EntityFieldManagerInterface $entity_field_manager, - TypedConfigManagerInterface $typed_config_manager, - ViewsData $views_data, - PluginManagerInterface $formatter_plugin_manager, + private readonly EntityTypeManagerInterface $entityTypeManager, + private readonly EntityFieldManagerInterface $entityFieldManager, + private readonly TypedConfigManagerInterface $typedConfigManager, + private readonly ViewsData $viewsData, + #[Autowire(service: 'plugin.manager.field.formatter')] + private readonly PluginManagerInterface $formatterPluginManager, ) { - $this->entityTypeManager = $entity_type_manager; - $this->entityFieldManager = $entity_field_manager; - $this->typedConfigManager = $typed_config_manager; - $this->viewsData = $views_data; - $this->formatterPluginManager = $formatter_plugin_manager; - } - - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container) { - return new static( - $container->get('entity_type.manager'), - $container->get('entity_field.manager'), - $container->get('config.typed'), - $container->get('views.views_data'), - $container->get('plugin.manager.field.formatter') - ); } /** @@ -112,11 +44,18 @@ class ViewsConfigUpdater implements ContainerInjectionInterface { * @param bool $enabled * Whether deprecations should be enabled. */ - public function setDeprecationsEnabled($enabled) { + public function setDeprecationsEnabled(bool $enabled): void { $this->deprecationsEnabled = $enabled; } /** + * Whether deprecations are enabled. + */ + public function areDeprecationsEnabled(): bool { + return $this->deprecationsEnabled; + } + + /** * Performs all required updates. * * @param \Drupal\views\ViewEntityInterface $view @@ -259,7 +198,7 @@ class ViewsConfigUpdater implements ContainerInjectionInterface { } $deprecations_triggered = &$this->triggeredDeprecations['2640994'][$view->id()]; - if ($this->deprecationsEnabled && $changed && !$deprecations_triggered) { + if ($this->areDeprecationsEnabled() && $changed && !$deprecations_triggered) { $deprecations_triggered = TRUE; @trigger_error(sprintf('The update to convert "numeric" arguments to "entity_target_id" for entity reference fields for view "%s" is deprecated in drupal:10.3.0 and is removed from drupal:12.0.0. Profile, module and theme provided configuration should be updated. See https://www.drupal.org/node/3441945', $view->id()), E_USER_DEPRECATED); } @@ -351,7 +290,7 @@ class ViewsConfigUpdater implements ContainerInjectionInterface { } $deprecations_triggered = &$this->triggeredDeprecations['table_css_class'][$view->id()]; - if ($this->deprecationsEnabled && $changed && !$deprecations_triggered) { + if ($this->areDeprecationsEnabled() && $changed && !$deprecations_triggered) { $deprecations_triggered = TRUE; @trigger_error(sprintf('The update to add a default table CSS class for view "%s" is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Profile, module and theme provided configuration should be updated. See https://www.drupal.org/node/3499943', $view->id()), E_USER_DEPRECATED); } 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_test_config_updater/src/Hook/ViewsTestConfigUpdaterHooks.php b/core/modules/views/tests/modules/views_test_config_updater/src/Hook/ViewsTestConfigUpdaterHooks.php new file mode 100644 index 00000000000..77db1f9adc9 --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config_updater/src/Hook/ViewsTestConfigUpdaterHooks.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\views_test_config_updater\Hook; + +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; +use Drupal\views\ViewEntityInterface; +use Drupal\views\ViewsConfigUpdater; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +/** + * Hooks for the views_test_config_updater module. + */ +class ViewsTestConfigUpdaterHooks { + + public function __construct( + protected readonly ViewsConfigUpdater $viewsConfigUpdater, + #[Autowire(service: 'keyvalue')] + protected readonly KeyValueFactoryInterface $keyValueFactory, + ) { + + } + + /** + * Implements hook_ENTITY_TYPE_presave(). + */ + #[Hook('view_presave')] + public function viewPresave(ViewEntityInterface $view): void { + $this->keyValueFactory->get('views_test_config_updater')->set('deprecations_enabled', $this->viewsConfigUpdater->areDeprecationsEnabled()); + } + +} diff --git a/core/modules/views/tests/modules/views_test_config_updater/views_test_config_updater.info.yml b/core/modules/views/tests/modules/views_test_config_updater/views_test_config_updater.info.yml new file mode 100644 index 00000000000..1efb67ee48f --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config_updater/views_test_config_updater.info.yml @@ -0,0 +1,6 @@ +name: 'Views Test Config Updater' +type: module +package: Testing +version: VERSION +dependencies: + - drupal:views diff --git a/core/modules/views/tests/modules/views_test_config_updater/views_test_config_updater.post_update.php b/core/modules/views/tests/modules/views_test_config_updater/views_test_config_updater.post_update.php new file mode 100644 index 00000000000..db4c780aa8a --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config_updater/views_test_config_updater.post_update.php @@ -0,0 +1,22 @@ +<?php + +/** + * @file + * Post update functions for Views Test Config Updater. + */ + +declare(strict_types=1); + +use Drupal\Core\Config\Entity\ConfigEntityUpdater; +use Drupal\views\ViewEntityInterface; +use Drupal\views\ViewsConfigUpdater; + +/** + * Test post update to set deprecations disabled. + */ +function views_test_config_updater_post_update_set_deprecations_disabled(?array &$sandbox = NULL): void { + /** @var \Drupal\views\ViewsConfigUpdater $viewsConfigUpdater */ + $viewsConfigUpdater = \Drupal::service(ViewsConfigUpdater::class); + $viewsConfigUpdater->setDeprecationsEnabled(FALSE); + \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', static fn (ViewEntityInterface $view): bool => TRUE); +} diff --git a/core/modules/views/tests/modules/views_test_data/test_views/views.view.test_content_access_filter.yml b/core/modules/views/tests/modules/views_test_data/test_views/views.view.test_content_access_filter.yml new file mode 100644 index 00000000000..8680489c2b6 --- /dev/null +++ b/core/modules/views/tests/modules/views_test_data/test_views/views.view.test_content_access_filter.yml @@ -0,0 +1,247 @@ +status: true +dependencies: + config: + - core.entity_view_mode.node.teaser + module: + - node + - user +id: test_content_access_filter +label: 'Test Content Access Filter' +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +display: + default: + display_plugin: default + id: default + display_title: Default + position: 0 + display_options: + access: + type: perm + options: + perm: 'access content' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: mini + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: ‹‹ + next: ›› + style: + type: default + row: + type: 'entity:node' + options: + view_mode: teaser + fields: + title: + id: title + table: node_field_data + field: title + entity_type: node + entity_field: title + label: '' + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + settings: + link_to_entity: true + plugin_id: field + relationship: none + group_type: group + admin_label: '' + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + click_sort_column: value + type: string + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + filters: + nid: + id: nid + table: node_access + field: nid + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: '' + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + operator_limit_selection: false + operator_list: { } + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + plugin_id: node_access + status: + id: status + table: node_field_data + field: status + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: '1' + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + operator_limit_selection: false + operator_list: { } + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: node + entity_field: status + plugin_id: boolean + sorts: + created: + id: created + table: node_field_data + field: created + order: DESC + entity_type: node + entity_field: created + plugin_id: date + relationship: none + group_type: group + admin_label: '' + exposed: false + expose: + label: '' + field_identifier: '' + granularity: second + title: 'Test Content Access Filter' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: test-content-access-filter + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } 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/AccessTest.php b/core/modules/views/tests/src/Functional/Plugin/AccessTest.php index df420cf879f..92874dd8b41 100644 --- a/core/modules/views/tests/src/Functional/Plugin/AccessTest.php +++ b/core/modules/views/tests/src/Functional/Plugin/AccessTest.php @@ -22,7 +22,12 @@ class AccessTest extends ViewTestBase { * * @var array */ - public static $testViews = ['test_access_none', 'test_access_static', 'test_access_dynamic']; + public static $testViews = [ + 'test_access_none', + 'test_access_static', + 'test_access_dynamic', + 'test_content_access_filter', + ]; /** * {@inheritdoc} @@ -113,4 +118,32 @@ class AccessTest extends ViewTestBase { $this->assertSession()->statusCodeEquals(200); } + /** + * Tests that node_access table is joined when hook_node_grants() is implemented. + */ + public function testContentAccessFilter(): void { + $view = Views::getView('test_content_access_filter'); + $view->setDisplay('page_1'); + + $view->initQuery(); + $view->execute(); + /** @var \Drupal\Core\Database\Query\Select $main_query */ + $main_query = $view->build_info['query']; + $tables = array_keys($main_query->getTables()); + $this->assertNotContains('node_access', $tables); + + // Enable node access test module to ensure that table is present again. + \Drupal::service('module_installer')->install(['node_access_test']); + node_access_rebuild(); + + $view = Views::getView('test_content_access_filter'); + $view->setDisplay('page_1'); + $view->initQuery(); + $view->execute(); + /** @var \Drupal\Core\Database\Query\Select $main_query */ + $main_query = $view->build_info['query']; + $tables = array_keys($main_query->getTables()); + $this->assertContains('node_access', $tables); + } + } 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/Functional/ViewsConfigUpdaterTest.php b/core/modules/views/tests/src/Functional/ViewsConfigUpdaterTest.php new file mode 100644 index 00000000000..dc5b47eece7 --- /dev/null +++ b/core/modules/views/tests/src/Functional/ViewsConfigUpdaterTest.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\views\Functional; + +use Drupal\Core\Database\Database; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\UpdatePathTestTrait; + +/** + * Tests the views config updater service. + * + * @group views + */ +class ViewsConfigUpdaterTest extends BrowserTestBase { + + use UpdatePathTestTrait; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'views', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $connection = Database::getConnection(); + + // Enable views_test_config_updater via the database so post_update hooks + // can run. + $extensions = $connection->select('config') + ->fields('config', ['data']) + ->condition('collection', '') + ->condition('name', 'core.extension') + ->execute() + ->fetchField(); + $extensions = unserialize($extensions); + $extensions['module']['views_test_config_updater'] = 0; + $connection->update('config') + ->fields([ + 'data' => serialize($extensions), + ]) + ->condition('collection', '') + ->condition('name', 'core.extension') + ->execute(); + } + + /** + * Tests the deprecationsEnabled flag persists from post_update to presave. + * + * @see views_test_config_updater_post_update_set_deprecations_disabled + * @see \Drupal\views_test_config_updater\Hook\ViewsTestConfigUpdaterHooks::viewPresave() + */ + public function testDeprecationsFlagPersists(): void { + $this->assertNull(\Drupal::keyValue('views_test_config_updater')->get('deprecations_enabled')); + + $this->runUpdates(); + + $this->assertFalse(\Drupal::keyValue('views_test_config_updater')->get('deprecations_enabled')); + } + +} diff --git a/core/modules/views/tests/src/FunctionalJavascript/BlockExposedFilterAJAXTest.php b/core/modules/views/tests/src/FunctionalJavascript/BlockExposedFilterAJAXTest.php index 76b782fdf19..b645ceaef11 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/BlockExposedFilterAJAXTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/BlockExposedFilterAJAXTest.php @@ -8,12 +8,12 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\node\Traits\ContentTypeCreationTrait; use Drupal\Tests\node\Traits\NodeCreationTrait; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the exposed filter ajax functionality in a block. - * - * @group views */ +#[Group('views')] class BlockExposedFilterAJAXTest extends WebDriverTestBase { use ContentTypeCreationTrait; diff --git a/core/modules/views/tests/src/FunctionalJavascript/ClickSortingAJAXTest.php b/core/modules/views/tests/src/FunctionalJavascript/ClickSortingAJAXTest.php index 858c5ee2365..cbdbb5a242b 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/ClickSortingAJAXTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/ClickSortingAJAXTest.php @@ -8,12 +8,12 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\node\Traits\ContentTypeCreationTrait; use Drupal\Tests\node\Traits\NodeCreationTrait; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the click sorting AJAX functionality of Views exposed forms. - * - * @group views */ +#[Group('views')] class ClickSortingAJAXTest extends WebDriverTestBase { use ContentTypeCreationTrait; diff --git a/core/modules/views/tests/src/FunctionalJavascript/ExposedFilterAJAXTest.php b/core/modules/views/tests/src/FunctionalJavascript/ExposedFilterAJAXTest.php index af7cad9e287..1ddbcdba9fc 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/ExposedFilterAJAXTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/ExposedFilterAJAXTest.php @@ -8,12 +8,12 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\node\Traits\ContentTypeCreationTrait; use Drupal\Tests\node\Traits\NodeCreationTrait; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the basic AJAX functionality of Views exposed forms. - * - * @group views */ +#[Group('views')] class ExposedFilterAJAXTest extends WebDriverTestBase { use ContentTypeCreationTrait; diff --git a/core/modules/views/tests/src/FunctionalJavascript/GlossaryViewTest.php b/core/modules/views/tests/src/FunctionalJavascript/GlossaryViewTest.php index 390046be412..f80bcc95619 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/GlossaryViewTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/GlossaryViewTest.php @@ -10,12 +10,12 @@ use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\node\Traits\ContentTypeCreationTrait; use Drupal\Tests\node\Traits\NodeCreationTrait; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the basic AJAX functionality of the Glossary View. - * - * @group node */ +#[Group('node')] class GlossaryViewTest extends WebDriverTestBase { use ContentTypeCreationTrait; diff --git a/core/modules/views/tests/src/FunctionalJavascript/PaginationAJAXTest.php b/core/modules/views/tests/src/FunctionalJavascript/PaginationAJAXTest.php index feba85b6172..f6da5d52dbe 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/PaginationAJAXTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/PaginationAJAXTest.php @@ -8,12 +8,12 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\node\Traits\ContentTypeCreationTrait; use Drupal\Tests\node\Traits\NodeCreationTrait; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the click sorting AJAX functionality of Views exposed forms. - * - * @group views */ +#[Group('views')] class PaginationAJAXTest extends WebDriverTestBase { use ContentTypeCreationTrait; diff --git a/core/modules/views/tests/src/FunctionalJavascript/Plugin/BulkOperationsTest.php b/core/modules/views/tests/src/FunctionalJavascript/Plugin/BulkOperationsTest.php index fb6bcab6de7..46885027d7d 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/Plugin/BulkOperationsTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/Plugin/BulkOperationsTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\views\FunctionalJavascript\Plugin; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the bulk operations. - * - * @group views */ +#[Group('views')] class BulkOperationsTest extends WebDriverTestBase { /** diff --git a/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/ContextualFilterTest.php b/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/ContextualFilterTest.php index 1987e585431..91baf920074 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/ContextualFilterTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/ContextualFilterTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\views\FunctionalJavascript\Plugin\views\Handler; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the contextual filter handler UI. - * - * @group views */ +#[Group('views')] class ContextualFilterTest extends WebDriverTestBase { /** diff --git a/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/FieldTest.php b/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/FieldTest.php index 1224c578594..6808086cac0 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/FieldTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/FieldTest.php @@ -10,12 +10,12 @@ use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; use Drupal\Tests\SchemaCheckTestTrait; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the field handler UI. - * - * @group views */ +#[Group('views')] class FieldTest extends WebDriverTestBase { use SchemaCheckTestTrait; diff --git a/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/FilterTest.php b/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/FilterTest.php index 6ecbeeff788..ce5c8eb52db 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/FilterTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/FilterTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\views\FunctionalJavascript\Plugin\views\Handler; use Drupal\field\Entity\FieldConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\node\Entity\NodeType; +use PHPUnit\Framework\Attributes\Group; /** * Tests the add filter handler UI. - * - * @group views */ +#[Group('views')] class FilterTest extends WebDriverTestBase { /** diff --git a/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/GroupedExposedFilterTest.php b/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/GroupedExposedFilterTest.php index 458d9053167..f1ae1c5a23e 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/GroupedExposedFilterTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/Plugin/views/Handler/GroupedExposedFilterTest.php @@ -8,12 +8,12 @@ use Drupal\field\Entity\FieldConfig; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\node\Entity\NodeType; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the grouped exposed filter admin UI. - * - * @group views */ +#[Group('views')] class GroupedExposedFilterTest extends WebDriverTestBase { /** diff --git a/core/modules/views/tests/src/FunctionalJavascript/RedirectAjaxTest.php b/core/modules/views/tests/src/FunctionalJavascript/RedirectAjaxTest.php index 2cee37e5c95..8cfd23b2b8e 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/RedirectAjaxTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/RedirectAjaxTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\views\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\node\Traits\ContentTypeCreationTrait; use Drupal\Tests\node\Traits\NodeCreationTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests that the redirects work with Ajax enabled views. - * - * @group views */ +#[Group('views')] class RedirectAjaxTest extends WebDriverTestBase { use ContentTypeCreationTrait; 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.api.php b/core/modules/views/views.api.php index 6579f93199b..9d2f23cf4c9 100644 --- a/core/modules/views/views.api.php +++ b/core/modules/views/views.api.php @@ -13,6 +13,7 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Language\LanguageInterface; use Drupal\views\Plugin\views\cache\CachePluginBase; use Drupal\views\Plugin\views\PluginBase; +use Drupal\views\Plugin\views\query\QueryPluginBase; use Drupal\views\ViewExecutable; /** diff --git a/core/modules/views/views.post_update.php b/core/modules/views/views.post_update.php index 48623fc593e..2dcc15e73e1 100644 --- a/core/modules/views/views.post_update.php +++ b/core/modules/views/views.post_update.php @@ -59,7 +59,7 @@ function views_removed_post_updates(): array { */ function views_post_update_views_data_argument_plugin_id(?array &$sandbox = NULL): void { /** @var \Drupal\views\ViewsConfigUpdater $view_config_updater */ - $view_config_updater = \Drupal::classResolver(ViewsConfigUpdater::class); + $view_config_updater = \Drupal::service(ViewsConfigUpdater::class); $view_config_updater->setDeprecationsEnabled(FALSE); \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', function (ViewEntityInterface $view) use ($view_config_updater): bool { return $view_config_updater->needsEntityArgumentUpdate($view); @@ -71,7 +71,7 @@ function views_post_update_views_data_argument_plugin_id(?array &$sandbox = NULL */ function views_post_update_update_remember_role_empty(?array &$sandbox = NULL): void { /** @var \Drupal\views\ViewsConfigUpdater $view_config_updater */ - $view_config_updater = \Drupal::classResolver(ViewsConfigUpdater::class); + $view_config_updater = \Drupal::service(ViewsConfigUpdater::class); $view_config_updater->setDeprecationsEnabled(FALSE); \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', function (ViewEntityInterface $view) use ($view_config_updater): bool { return $view_config_updater->needsRememberRolesUpdate($view); @@ -83,7 +83,7 @@ function views_post_update_update_remember_role_empty(?array &$sandbox = NULL): */ function views_post_update_table_css_class(?array &$sandbox = NULL): void { /** @var \Drupal\views\ViewsConfigUpdater $view_config_updater */ - $view_config_updater = \Drupal::classResolver(ViewsConfigUpdater::class); + $view_config_updater = \Drupal::service(ViewsConfigUpdater::class); $view_config_updater->setDeprecationsEnabled(FALSE); \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', function (ViewEntityInterface $view) use ($view_config_updater): bool { return $view_config_updater->needsTableCssClassUpdate($view); diff --git a/core/modules/views/views.services.yml b/core/modules/views/views.services.yml index 2a3aa1a16fa..19c5caf92b7 100644 --- a/core/modules/views/views.services.yml +++ b/core/modules/views/views.services.yml @@ -107,3 +107,5 @@ services: tags: - { name: backend_overridable } Drupal\views\Plugin\views\query\CastSqlInterface: '@views.cast_sql' + Drupal\views\ViewsConfigUpdater: + autowire: true 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/views_ui/css/views_ui.admin.theme.css b/core/modules/views_ui/css/views_ui.admin.theme.css index 529e8fa77c6..554d5044cb7 100644 --- a/core/modules/views_ui/css/views_ui.admin.theme.css +++ b/core/modules/views_ui/css/views_ui.admin.theme.css @@ -39,11 +39,11 @@ background: linear-gradient(-90deg, #fff 0, #e8e8e8 100%) no-repeat, repeat-y; - box-shadow: 0 0 0 rgba(0, 0, 0, 0.3333) inset; + box-shadow: 0 0 0 rgb(0, 0, 0, 0.3333) inset; } .views-admin a.icon:hover { border-color: #d0d0d0; - box-shadow: 0 0 1px rgba(0, 0, 0, 0.3333) inset; + box-shadow: 0 0 1px rgb(0, 0, 0, 0.3333) inset; } .views-admin a.icon:active { border-color: #c0c0c0; diff --git a/core/modules/views_ui/src/Form/Ajax/AddHandler.php b/core/modules/views_ui/src/Form/Ajax/AddHandler.php index 2f5c9590bc2..1934593779e 100644 --- a/core/modules/views_ui/src/Form/Ajax/AddHandler.php +++ b/core/modules/views_ui/src/Form/Ajax/AddHandler.php @@ -169,7 +169,7 @@ class AddHandler extends ViewsFormBase { // Add a div to show the selected items $form['selected'] = [ '#type' => 'item', - '#markup' => '<span class="views-ui-view-title">' . $this->t('Selected:') . '</span> ' . '<div class="views-selected-options"></div>', + '#markup' => '<span class="views-ui-view-title">' . $this->t('Selected:') . '</span><div class="views-selected-options"></div>', '#theme_wrappers' => ['form_element', 'views_ui_container'], '#attributes' => [ 'class' => ['container-inline', 'views-add-form-selected', 'views-offset-bottom'], diff --git a/core/modules/views_ui/tests/src/FunctionalJavascript/AdminAjaxTest.php b/core/modules/views_ui/tests/src/FunctionalJavascript/AdminAjaxTest.php index 7b6f8495264..de762b018b1 100644 --- a/core/modules/views_ui/tests/src/FunctionalJavascript/AdminAjaxTest.php +++ b/core/modules/views_ui/tests/src/FunctionalJavascript/AdminAjaxTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\views_ui\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the admin UI AJAX interactions. - * - * @group views_ui */ +#[Group('views_ui')] class AdminAjaxTest extends WebDriverTestBase { /** diff --git a/core/modules/views_ui/tests/src/FunctionalJavascript/DisplayTest.php b/core/modules/views_ui/tests/src/FunctionalJavascript/DisplayTest.php index 03ad843b1ad..24e12e2c460 100644 --- a/core/modules/views_ui/tests/src/FunctionalJavascript/DisplayTest.php +++ b/core/modules/views_ui/tests/src/FunctionalJavascript/DisplayTest.php @@ -7,17 +7,16 @@ namespace Drupal\Tests\views_ui\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\locale\SourceString; +use Drupal\Tests\node\Traits\NodeCreationTrait; use Drupal\views\Entity\View; use Drupal\views\Tests\ViewTestData; -use Drupal\Tests\node\Traits\NodeCreationTrait; +use PHPUnit\Framework\Attributes\Group; // cSpell:ignore Blokk hozzáadása - /** * Tests the display UI. - * - * @group views_ui */ +#[Group('views_ui')] class DisplayTest extends WebDriverTestBase { use NodeCreationTrait; diff --git a/core/modules/views_ui/tests/src/FunctionalJavascript/FieldDialogsTest.php b/core/modules/views_ui/tests/src/FunctionalJavascript/FieldDialogsTest.php index 7864b7286f7..6e5e13712e0 100644 --- a/core/modules/views_ui/tests/src/FunctionalJavascript/FieldDialogsTest.php +++ b/core/modules/views_ui/tests/src/FunctionalJavascript/FieldDialogsTest.php @@ -6,12 +6,12 @@ namespace Drupal\Tests\views_ui\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the fields dialogs. - * - * @group views_ui */ +#[Group('views_ui')] class FieldDialogsTest extends WebDriverTestBase { /** diff --git a/core/modules/views_ui/tests/src/FunctionalJavascript/FilterCriteriaTest.php b/core/modules/views_ui/tests/src/FunctionalJavascript/FilterCriteriaTest.php index f2580e7457e..a336d3cf780 100644 --- a/core/modules/views_ui/tests/src/FunctionalJavascript/FilterCriteriaTest.php +++ b/core/modules/views_ui/tests/src/FunctionalJavascript/FilterCriteriaTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\views_ui\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the View UI filter criteria group dialog. - * - * @group views_ui */ +#[Group('views_ui')] class FilterCriteriaTest extends WebDriverTestBase { /** diff --git a/core/modules/views_ui/tests/src/FunctionalJavascript/FilterEntityReferenceTest.php b/core/modules/views_ui/tests/src/FunctionalJavascript/FilterEntityReferenceTest.php index b6021c6580e..eca6f479ed5 100644 --- a/core/modules/views_ui/tests/src/FunctionalJavascript/FilterEntityReferenceTest.php +++ b/core/modules/views_ui/tests/src/FunctionalJavascript/FilterEntityReferenceTest.php @@ -7,13 +7,14 @@ namespace Drupal\Tests\views_ui\FunctionalJavascript; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\views_ui\Traits\FilterEntityReferenceTrait; +use PHPUnit\Framework\Attributes\Group; /** * Tests views creation wizard. * - * @group views_ui * @see \Drupal\views\Plugin\views\filter\EntityReference */ +#[Group('views_ui')] class FilterEntityReferenceTest extends WebDriverTestBase { use FilterEntityReferenceTrait; diff --git a/core/modules/views_ui/tests/src/FunctionalJavascript/FilterOptionsTest.php b/core/modules/views_ui/tests/src/FunctionalJavascript/FilterOptionsTest.php index 3a5dc110040..fbfd5141ffe 100644 --- a/core/modules/views_ui/tests/src/FunctionalJavascript/FilterOptionsTest.php +++ b/core/modules/views_ui/tests/src/FunctionalJavascript/FilterOptionsTest.php @@ -5,12 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\views_ui\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript filtering of options in add handler form. - * - * @group views_ui */ +#[Group('views_ui')] class FilterOptionsTest extends WebDriverTestBase { /** diff --git a/core/modules/views_ui/tests/src/FunctionalJavascript/PreviewTest.php b/core/modules/views_ui/tests/src/FunctionalJavascript/PreviewTest.php index c1b53b9765e..d8ab9632e17 100644 --- a/core/modules/views_ui/tests/src/FunctionalJavascript/PreviewTest.php +++ b/core/modules/views_ui/tests/src/FunctionalJavascript/PreviewTest.php @@ -8,12 +8,12 @@ use Behat\Mink\Element\NodeElement; use Drupal\Core\Database\Database; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\views\Tests\ViewTestData; +use PHPUnit\Framework\Attributes\Group; /** * Tests the UI preview functionality. - * - * @group views_ui */ +#[Group('views_ui')] class PreviewTest extends WebDriverTestBase { /** diff --git a/core/modules/views_ui/tests/src/FunctionalJavascript/ViewsListingTest.php b/core/modules/views_ui/tests/src/FunctionalJavascript/ViewsListingTest.php index b6f442e6265..d0d6d4725e8 100644 --- a/core/modules/views_ui/tests/src/FunctionalJavascript/ViewsListingTest.php +++ b/core/modules/views_ui/tests/src/FunctionalJavascript/ViewsListingTest.php @@ -5,13 +5,14 @@ declare(strict_types=1); namespace Drupal\Tests\views_ui\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests the JavaScript filtering on the Views listing page. * * @see core/modules/views_ui/js/views_ui.listing.js - * @group views_ui */ +#[Group('views_ui')] class ViewsListingTest extends WebDriverTestBase { /** diff --git a/core/modules/views_ui/tests/src/FunctionalJavascript/ViewsWizardTest.php b/core/modules/views_ui/tests/src/FunctionalJavascript/ViewsWizardTest.php index b1026d78584..5526fb7bff3 100644 --- a/core/modules/views_ui/tests/src/FunctionalJavascript/ViewsWizardTest.php +++ b/core/modules/views_ui/tests/src/FunctionalJavascript/ViewsWizardTest.php @@ -5,13 +5,14 @@ declare(strict_types=1); namespace Drupal\Tests\views_ui\FunctionalJavascript; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests views creation wizard. * * @see core/modules/views_ui/js/views-admin.js - * @group views_ui */ +#[Group('views_ui')] class ViewsWizardTest extends WebDriverTestBase { /** diff --git a/core/modules/workflows/src/Plugin/WorkflowTypeBase.php b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php index 47488b38801..e86b77a2285 100644 --- a/core/modules/workflows/src/Plugin/WorkflowTypeBase.php +++ b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php @@ -2,7 +2,7 @@ namespace Drupal\workflows\Plugin; -use Drupal\Component\Plugin\PluginBase; +use Drupal\Core\Plugin\ConfigurablePluginBase; use Drupal\Core\Plugin\PluginWithFormsTrait; use Drupal\workflows\State; use Drupal\workflows\StateInterface; @@ -16,7 +16,7 @@ use Drupal\workflows\WorkflowTypeInterface; * * @see \Drupal\workflows\Annotation\WorkflowType */ -abstract class WorkflowTypeBase extends PluginBase implements WorkflowTypeInterface { +abstract class WorkflowTypeBase extends ConfigurablePluginBase implements WorkflowTypeInterface { use PluginWithFormsTrait; @@ -28,14 +28,6 @@ abstract class WorkflowTypeBase extends PluginBase implements WorkflowTypeInterf /** * {@inheritdoc} */ - public function __construct(array $configuration, $plugin_id, $plugin_definition) { - parent::__construct($configuration, $plugin_id, $plugin_definition); - $this->setConfiguration($configuration); - } - - /** - * {@inheritdoc} - */ public function label() { $definition = $this->getPluginDefinition(); // The label can be an object. @@ -60,15 +52,9 @@ abstract class WorkflowTypeBase extends PluginBase implements WorkflowTypeInterf /** * {@inheritdoc} */ - public function getConfiguration() { - return $this->configuration; - } - - /** - * {@inheritdoc} - */ public function setConfiguration(array $configuration) { $this->configuration = $configuration + $this->defaultConfiguration(); + return $this; } /** 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/css/workspaces.toolbar.pcss.css b/core/modules/workspaces/css/workspaces.toolbar.pcss.css index 84b3fa8f986..a603d53949c 100644 --- a/core/modules/workspaces/css/workspaces.toolbar.pcss.css +++ b/core/modules/workspaces/css/workspaces.toolbar.pcss.css @@ -52,7 +52,7 @@ text-align: start; color: inherit; - @nest .toolbar-oriented & { + .toolbar-oriented & { width: auto; text-align: initial; } diff --git a/core/modules/workspaces/src/Controller/WorkspacesHtmlEntityFormController.php b/core/modules/workspaces/src/Controller/WorkspacesHtmlEntityFormController.php index 6f9d6d5b656..aa5aa49f9d2 100644 --- a/core/modules/workspaces/src/Controller/WorkspacesHtmlEntityFormController.php +++ b/core/modules/workspaces/src/Controller/WorkspacesHtmlEntityFormController.php @@ -35,11 +35,16 @@ class WorkspacesHtmlEntityFormController extends FormController { */ public function getContentResult(Request $request, RouteMatchInterface $route_match): array { $form_arg = $this->getFormArgument($route_match); - $form_object = $this->getFormObject($route_match, $form_arg); + // If no operation is provided, use 'default'. + $form_arg .= '.default'; + [$entity_type_id, $operation] = explode('.', $form_arg); - /** @var \Drupal\Core\Entity\EntityInterface $entity */ - $entity = $form_object->getEntity(); - if ($this->workspaceInfo->isEntitySupported($entity)) { + if ($route_match->getRawParameter($entity_type_id) !== NULL) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $entity = $route_match->getParameter($entity_type_id); + } + + if (isset($entity) && $this->workspaceInfo->isEntitySupported($entity)) { $active_workspace = $this->workspaceManager->getActiveWorkspace(); // Prepare a minimal render array in case we need to return it. @@ -48,7 +53,7 @@ class WorkspacesHtmlEntityFormController extends FormController { $build['#cache']['max-age'] = $entity->getCacheMaxAge(); // Prevent entities from being edited if they're tracked in workspace. - if ($form_object->getOperation() !== 'delete') { + if ($operation !== 'delete') { $constraints = array_values(array_filter($entity->getTypedData()->getConstraints(), function ($constraint) { return $constraint instanceof EntityWorkspaceConflictConstraint; })); @@ -68,7 +73,7 @@ class WorkspacesHtmlEntityFormController extends FormController { // Prevent entities from being deleted in a workspace if they have a // published default revision. - if ($form_object->getOperation() === 'delete' && $active_workspace && !$this->workspaceInfo->isEntityDeletable($entity, $active_workspace)) { + if ($operation === 'delete' && $active_workspace && !$this->workspaceInfo->isEntityDeletable($entity, $active_workspace)) { $build['#markup'] = $this->t('This @entity_type_label can only be deleted in the Live workspace.', [ '@entity_type_label' => $entity->getEntityType()->getSingularLabel(), ]); diff --git a/core/modules/workspaces/src/Hook/FormOperations.php b/core/modules/workspaces/src/Hook/FormOperations.php index 61b775ea3cd..6f91618e71a 100644 --- a/core/modules/workspaces/src/Hook/FormOperations.php +++ b/core/modules/workspaces/src/Hook/FormOperations.php @@ -8,7 +8,10 @@ use Drupal\Core\Form\WorkspaceSafeFormInterface; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\Render\Element; use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\workspaces\Entity\Workspace; +use Drupal\workspaces\Negotiator\QueryParameterWorkspaceNegotiator; use Drupal\workspaces\WorkspaceManagerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * Defines a class for reacting to form operations. @@ -17,6 +20,8 @@ class FormOperations { public function __construct( protected WorkspaceManagerInterface $workspaceManager, + #[Autowire('@workspaces.negotiator.query_parameter')] + protected QueryParameterWorkspaceNegotiator $queryParameterNegotiator, ) {} /** @@ -24,8 +29,21 @@ class FormOperations { */ #[Hook('form_alter')] public function formAlter(array &$form, FormStateInterface $form_state, $form_id): void { + $active_workspace = $this->workspaceManager->getActiveWorkspace(); + + // Ensure that the form's initial workspace (if any) is used for the current + // request. + $form_workspace_id = $form_state->getUserInput()['active_workspace_id'] ?? NULL; + $form_workspace = $form_workspace_id + ? Workspace::load($form_workspace_id) + : NULL; + if ($form_workspace && (!$active_workspace || $active_workspace->id() != $form_workspace->id())) { + $this->workspaceManager->setActiveWorkspace($form_workspace, FALSE); + $active_workspace = $form_workspace; + } + // No alterations are needed if we're not in a workspace context. - if (!$this->workspaceManager->hasActiveWorkspace()) { + if (!$active_workspace) { return; } @@ -47,6 +65,17 @@ class FormOperations { ]; $this->addWorkspaceValidation($form); } + else { + // Persist the active workspace for the entire lifecycle of the form, + // including AJAX requests. + $form['active_workspace_id'] = [ + '#type' => 'hidden', + '#value' => $active_workspace->id(), + ]; + + $url_query_options = $this->queryParameterNegotiator->getQueryOptions($active_workspace->id()); + $this->setAjaxWorkspace($form, $url_query_options + ['persist' => FALSE]); + } } /** @@ -83,4 +112,29 @@ class FormOperations { } } + /** + * Ensures that the current workspace is persisted across AJAX interactions. + * + * @param array &$element + * An associative array containing the structure of the form. + * @param array $url_query_options + * An array of URL query options used by the query parameter workspace + * negotiator. + */ + protected function setAjaxWorkspace(array &$element, array $url_query_options): void { + // Recurse through all children if needed. + foreach (Element::children($element) as $key) { + if (isset($element[$key]) && $element[$key]) { + $this->setAjaxWorkspace($element[$key], $url_query_options); + } + } + + if (isset($element['#ajax']) && !isset($element['#ajax']['options']['query']['workspace'])) { + $element['#ajax']['options']['query'] = array_merge_recursive( + $url_query_options, + $element['#ajax']['options']['query'] ?? [], + ); + } + } + } 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', ]), diff --git a/core/modules/workspaces/src/Negotiator/QueryParameterWorkspaceNegotiator.php b/core/modules/workspaces/src/Negotiator/QueryParameterWorkspaceNegotiator.php index 1f0b688541f..04efac2c3b1 100644 --- a/core/modules/workspaces/src/Negotiator/QueryParameterWorkspaceNegotiator.php +++ b/core/modules/workspaces/src/Negotiator/QueryParameterWorkspaceNegotiator.php @@ -4,6 +4,7 @@ namespace Drupal\workspaces\Negotiator; use Drupal\Component\Utility\Crypt; use Drupal\Core\Site\Settings; +use Drupal\workspaces\WorkspaceInterface; use Symfony\Component\HttpFoundation\Request; /** @@ -12,6 +13,11 @@ use Symfony\Component\HttpFoundation\Request; class QueryParameterWorkspaceNegotiator extends SessionWorkspaceNegotiator { /** + * Whether the negotiated workspace should be persisted. + */ + protected bool $persist = TRUE; + + /** * {@inheritdoc} */ public function applies(Request $request) { @@ -24,6 +30,8 @@ class QueryParameterWorkspaceNegotiator extends SessionWorkspaceNegotiator { * {@inheritdoc} */ public function getActiveWorkspaceId(Request $request): ?string { + $this->persist = (bool) $request->query->get('persist', TRUE); + $workspace_id = (string) $request->query->get('workspace'); $token = (string) $request->query->get('token'); $is_valid_token = hash_equals($this->getQueryToken($workspace_id), $token); @@ -36,6 +44,40 @@ class QueryParameterWorkspaceNegotiator extends SessionWorkspaceNegotiator { } /** + * {@inheritdoc} + */ + public function setActiveWorkspace(WorkspaceInterface $workspace) { + if ($this->persist) { + parent::setActiveWorkspace($workspace); + } + } + + /** + * {@inheritdoc} + */ + public function unsetActiveWorkspace() { + if ($this->persist) { + parent::unsetActiveWorkspace(); + } + } + + /** + * Returns the query options used by this negotiator. + * + * @param string $workspace_id + * A workspace ID. + * + * @return array + * An array of query options that can be used for a \Drupal\Core\Url object. + */ + public function getQueryOptions(string $workspace_id): array { + return [ + 'workspace' => $workspace_id, + 'token' => $this->getQueryToken($workspace_id), + ]; + } + + /** * Calculates a token based on a workspace ID. * * @param string $workspace_id diff --git a/core/modules/workspaces/src/WorkspaceManager.php b/core/modules/workspaces/src/WorkspaceManager.php index 5bb2dc454c3..b0e9b8ac959 100644 --- a/core/modules/workspaces/src/WorkspaceManager.php +++ b/core/modules/workspaces/src/WorkspaceManager.php @@ -9,24 +9,53 @@ use Drupal\Core\Session\AccountProxyInterface; use Drupal\Core\Site\Settings; use Drupal\Core\State\StateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\workspaces\Negotiator\WorkspaceNegotiatorInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; use Symfony\Component\HttpFoundation\RequestStack; /** * Provides the workspace manager. + * + * @property iterable $negotiators */ class WorkspaceManager implements WorkspaceManagerInterface { use StringTranslationTrait; /** - * The current active workspace or FALSE if there is no active workspace. + * The current active workspace. * - * @var \Drupal\workspaces\WorkspaceInterface|false + * The value is either a workspace object, FALSE if there is no active + * workspace, or NULL if the active workspace hasn't been determined yet. */ - protected $activeWorkspace; + protected WorkspaceInterface|false|null $activeWorkspace = NULL; - public function __construct(protected RequestStack $requestStack, protected EntityTypeManagerInterface $entityTypeManager, protected MemoryCacheInterface $entityMemoryCache, protected AccountProxyInterface $currentUser, protected StateInterface $state, protected LoggerInterface $logger, protected ClassResolverInterface $classResolver, protected WorkspaceAssociationInterface $workspaceAssociation, protected WorkspaceInformationInterface $workspaceInfo, protected array $negotiatorIds = []) { + /** + * An array of workspace negotiator services. + * + * @todo Remove in drupal:12.0.0. + */ + private array $collectedNegotiators = []; + + public function __construct( + protected RequestStack $requestStack, + protected EntityTypeManagerInterface $entityTypeManager, + protected MemoryCacheInterface $entityMemoryCache, + protected AccountProxyInterface $currentUser, + protected StateInterface $state, + #[Autowire(service: 'logger.channel.workspaces')] + protected LoggerInterface $logger, + #[AutowireIterator(tag: 'workspace_negotiator')] + protected $negotiators, + protected WorkspaceAssociationInterface $workspaceAssociation, + protected WorkspaceInformationInterface $workspaceInfo, + ) { + if ($negotiators instanceof ClassResolverInterface) { + @trigger_error('Passing the \'class_resolver\' service as the 7th argument to ' . __METHOD__ . ' is deprecated in drupal:11.3.0 and is unsupported in drupal:12.0.0. Use autowiring for the \'workspaces.manager\' service instead. See https://www.drupal.org/node/3532939', E_USER_DEPRECATED); + $this->negotiators = $this->collectedNegotiators; + } } /** @@ -43,10 +72,7 @@ class WorkspaceManager implements WorkspaceManagerInterface { if (!isset($this->activeWorkspace)) { $request = $this->requestStack->getCurrentRequest(); - foreach ($this->negotiatorIds as $negotiator_id) { - /** @var \Drupal\workspaces\Negotiator\WorkspaceIdNegotiatorInterface $negotiator */ - $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id); - + foreach ($this->negotiators as $negotiator) { if ($negotiator->applies($request)) { if ($workspace_id = $negotiator->getActiveWorkspaceId($request)) { /** @var \Drupal\workspaces\WorkspaceInterface $negotiated_workspace */ @@ -79,16 +105,19 @@ class WorkspaceManager implements WorkspaceManagerInterface { /** * {@inheritdoc} */ - public function setActiveWorkspace(WorkspaceInterface $workspace) { + public function setActiveWorkspace(WorkspaceInterface $workspace, /* bool $persist = TRUE */) { + $persist = func_num_args() < 2 || func_get_arg(1); + $this->doSwitchWorkspace($workspace); - // Set the workspace on the proper negotiator. - $request = $this->requestStack->getCurrentRequest(); - foreach ($this->negotiatorIds as $negotiator_id) { - $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id); - if ($negotiator->applies($request)) { - $negotiator->setActiveWorkspace($workspace); - break; + // Set the workspace on the first applicable negotiator. + if ($persist) { + $request = $this->requestStack->getCurrentRequest(); + foreach ($this->negotiators as $negotiator) { + if ($negotiator->applies($request)) { + $negotiator->setActiveWorkspace($workspace); + break; + } } } @@ -102,8 +131,7 @@ class WorkspaceManager implements WorkspaceManagerInterface { $this->doSwitchWorkspace(NULL); // Unset the active workspace on all negotiators. - foreach ($this->negotiatorIds as $negotiator_id) { - $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id); + foreach ($this->negotiators as $negotiator) { $negotiator->unsetActiveWorkspace(); } @@ -253,4 +281,18 @@ class WorkspaceManager implements WorkspaceManagerInterface { } } + /** + * Adds a workspace negotiator service. + * + * @param \Drupal\workspaces\Negotiator\WorkspaceNegotiatorInterface $negotiator + * The negotiator to be added. + * + * @todo Remove in drupal:12.0.0. + * + * @internal + */ + public function addNegotiator(WorkspaceNegotiatorInterface $negotiator): void { + $this->collectedNegotiators[] = $negotiator; + } + } diff --git a/core/modules/workspaces/src/WorkspaceManagerInterface.php b/core/modules/workspaces/src/WorkspaceManagerInterface.php index a61d29f50d5..8d8024c6620 100644 --- a/core/modules/workspaces/src/WorkspaceManagerInterface.php +++ b/core/modules/workspaces/src/WorkspaceManagerInterface.php @@ -24,20 +24,24 @@ interface WorkspaceManagerInterface { public function getActiveWorkspace(); /** - * Sets the active workspace via the workspace negotiators. + * Sets the active workspace. * * @param \Drupal\workspaces\WorkspaceInterface $workspace * The workspace to set as active. + * phpcs:ignore + * @param bool $persist + * (optional) Whether to persist this workspace in the first applicable + * negotiator. Defaults to TRUE. * * @return $this * * @throws \Drupal\workspaces\WorkspaceAccessException * Thrown when the current user doesn't have access to view the workspace. */ - public function setActiveWorkspace(WorkspaceInterface $workspace); + public function setActiveWorkspace(WorkspaceInterface $workspace, /* bool $persist = TRUE */); /** - * Unsets the active workspace via the workspace negotiators. + * Unsets the active workspace. * * @return $this */ diff --git a/core/modules/workspaces/tests/modules/workspaces_test/src/Form/ActiveWorkspaceTestForm.php b/core/modules/workspaces/tests/modules/workspaces_test/src/Form/ActiveWorkspaceTestForm.php new file mode 100644 index 00000000000..955cf472038 --- /dev/null +++ b/core/modules/workspaces/tests/modules/workspaces_test/src/Form/ActiveWorkspaceTestForm.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\workspaces_test\Form; + +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Form\WorkspaceSafeFormInterface; +use Drupal\Core\KeyValueStore\KeyValueStoreInterface; +use Drupal\Core\Url; +use Drupal\workspaces\WorkspaceManagerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Form for testing the active workspace. + * + * @internal + */ +class ActiveWorkspaceTestForm extends FormBase implements WorkspaceSafeFormInterface { + + /** + * The workspace manager. + */ + protected WorkspaceManagerInterface $workspaceManager; + + /** + * The test key-value store. + */ + protected KeyValueStoreInterface $keyValue; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): static { + $instance = parent::create($container); + $instance->workspaceManager = $container->get('workspaces.manager'); + $instance->keyValue = $container->get('keyvalue')->get('ws_test'); + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'active_workspace_test_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $form['test'] = [ + '#type' => 'textfield', + '#ajax' => [ + 'url' => Url::fromRoute('workspaces_test.get_form'), + 'callback' => function () { + $this->keyValue->set('ajax_test_active_workspace', $this->workspaceManager->getActiveWorkspace()->id()); + return new AjaxResponse(); + }, + ], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + $this->keyValue->set('form_test_active_workspace', $this->workspaceManager->getActiveWorkspace()->id()); + } + +} diff --git a/core/modules/workspaces/tests/modules/workspaces_test/src/Hook/WorkspacesTestHooks.php b/core/modules/workspaces/tests/modules/workspaces_test/src/Hook/WorkspacesTestHooks.php index d452d343ca3..d5d64ca5549 100644 --- a/core/modules/workspaces/tests/modules/workspaces_test/src/Hook/WorkspacesTestHooks.php +++ b/core/modules/workspaces/tests/modules/workspaces_test/src/Hook/WorkspacesTestHooks.php @@ -4,13 +4,21 @@ declare(strict_types=1); namespace Drupal\workspaces_test\Hook; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; /** * Hook implementations for workspaces_test. */ class WorkspacesTestHooks { + public function __construct( + #[Autowire(service: 'keyvalue')] + protected readonly KeyValueFactoryInterface $keyValueFactory, + ) {} + /** * Implements hook_entity_type_alter(). */ @@ -31,7 +39,53 @@ class WorkspacesTestHooks { public function entityTranslationCreate(): void { /** @var \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager */ $workspace_manager = \Drupal::service('workspaces.manager'); - \Drupal::keyValue('ws_test')->set('workspace_was_active', $workspace_manager->hasActiveWorkspace()); + $this->keyValueFactory->get('ws_test')->set('workspace_was_active', $workspace_manager->hasActiveWorkspace()); + } + + /** + * Implements hook_entity_create(). + */ + #[Hook('entity_create')] + public function entityCreate(EntityInterface $entity): void { + $this->incrementHookCount('hook_entity_create', $entity); + } + + /** + * Implements hook_entity_presave(). + */ + #[Hook('entity_presave')] + public function entityPresave(EntityInterface $entity): void { + $this->incrementHookCount('hook_entity_presave', $entity); + } + + /** + * Implements hook_entity_insert(). + */ + #[Hook('entity_insert')] + public function entityInsert(EntityInterface $entity): void { + $this->incrementHookCount('hook_entity_insert', $entity); + } + + /** + * Implements hook_entity_update(). + */ + #[Hook('entity_update')] + public function entityUpdate(EntityInterface $entity): void { + $this->incrementHookCount('hook_entity_update', $entity); + } + + /** + * Increments the invocation count for a specific entity hook. + * + * @param string $hook_name + * The name of the hook being invoked (e.g., 'hook_entity_create'). + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity object involved in the hook. + */ + protected function incrementHookCount(string $hook_name, EntityInterface $entity): void { + $key = $entity->getEntityTypeId() . '.' . $hook_name . '.count'; + $count = $this->keyValueFactory->get('ws_test')->get($key, 0); + $this->keyValueFactory->get('ws_test')->set($key, $count + 1); } } diff --git a/core/modules/workspaces/tests/modules/workspaces_test/workspaces_test.routing.yml b/core/modules/workspaces/tests/modules/workspaces_test/workspaces_test.routing.yml new file mode 100644 index 00000000000..bdf7648db9c --- /dev/null +++ b/core/modules/workspaces/tests/modules/workspaces_test/workspaces_test.routing.yml @@ -0,0 +1,7 @@ +workspaces_test.get_form: + path: '/active-workspace-test-form' + defaults: + _title: 'Active Workspace Test Form' + _form: '\Drupal\workspaces_test\Form\ActiveWorkspaceTestForm' + requirements: + _access: 'TRUE' diff --git a/core/modules/workspaces/tests/src/Functional/PathWorkspacesTest.php b/core/modules/workspaces/tests/src/Functional/PathWorkspacesTest.php index c3fe0e722a2..19007def89d 100644 --- a/core/modules/workspaces/tests/src/Functional/PathWorkspacesTest.php +++ b/core/modules/workspaces/tests/src/Functional/PathWorkspacesTest.php @@ -7,7 +7,6 @@ namespace Drupal\Tests\workspaces\Functional; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\content_translation\Traits\ContentTranslationTestTrait; use Drupal\Tests\WaitTerminateTestTrait; -use Drupal\workspaces\Entity\Workspace; /** * Tests path aliases with workspaces. @@ -91,9 +90,8 @@ class PathWorkspacesTest extends BrowserTestBase { 'status' => TRUE, ]); - // Switch to Stage and create an alias for the node. - $stage = Workspace::load('stage'); - $this->switchToWorkspace($stage); + // Activate a workspace and create an alias for the node. + $stage = $this->createAndActivateWorkspaceThroughUi('Stage', 'stage'); $edit = [ 'path[0][alias]' => '/' . $this->randomMachineName(), @@ -131,9 +129,8 @@ class PathWorkspacesTest extends BrowserTestBase { 'status' => TRUE, ]); - // Switch to Stage and create an alias for the node. - $stage = Workspace::load('stage'); - $this->switchToWorkspace($stage); + // Activate a workspace and create an alias for the node. + $stage = $this->createAndActivateWorkspaceThroughUi('Stage', 'stage'); $edit = [ 'path[0][alias]' => '/' . $this->randomMachineName(), @@ -169,7 +166,7 @@ class PathWorkspacesTest extends BrowserTestBase { * Tests path aliases with workspaces for translatable nodes. */ public function testPathAliasesWithTranslation(): void { - $stage = Workspace::load('stage'); + $stage = $this->createWorkspaceThroughUi('Stage', 'stage'); // Create one node with a random alias. $default_node = $this->drupalCreateNode([ diff --git a/core/modules/workspaces/tests/src/Functional/Rest/WorkspaceResourceTestBase.php b/core/modules/workspaces/tests/src/Functional/Rest/WorkspaceResourceTestBase.php index b998af426b2..3cbddf127d9 100644 --- a/core/modules/workspaces/tests/src/Functional/Rest/WorkspaceResourceTestBase.php +++ b/core/modules/workspaces/tests/src/Functional/Rest/WorkspaceResourceTestBase.php @@ -123,7 +123,7 @@ abstract class WorkspaceResourceTestBase extends EntityResourceTestBase { ], 'revision_id' => [ [ - 'value' => 2, + 'value' => 1, ], ], 'parent' => [], diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php index 2109552e761..9f9dad806f6 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php @@ -66,17 +66,22 @@ class WorkspaceCacheContextTest extends BrowserTestBase { $cache_bin = $variation_cache_factory->get($build['#cache']['bin']); $this->assertInstanceOf(\stdClass::class, $cache_bin->get($build['#cache']['keys'], CacheableMetadata::createFromRenderArray($build))); - // Switch to the 'stage' workspace and check that the correct workspace - // cache context is used. + // Switch to the test workspace and check that the correct workspace cache + // context is used. $test_user = $this->drupalCreateUser(['view any workspace']); $this->drupalLogin($test_user); - $stage = Workspace::load('stage'); + $vultures = Workspace::create([ + 'id' => 'vultures', + 'label' => 'Vultures', + ]); + $vultures->save(); + $workspace_manager = \Drupal::service('workspaces.manager'); - $workspace_manager->setActiveWorkspace($stage); + $workspace_manager->setActiveWorkspace($vultures); $cache_context = new WorkspaceCacheContext($workspace_manager); - $this->assertSame('stage', $cache_context->getContext()); + $this->assertSame('vultures', $cache_context->getContext()); $build = \Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full'); @@ -85,7 +90,7 @@ class WorkspaceCacheContextTest extends BrowserTestBase { $this->assertContains('workspace', $build['#cache']['contexts']); $context_tokens = $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys(); - $this->assertContains('[workspace]=stage', $context_tokens); + $this->assertContains('[workspace]=vultures', $context_tokens); // Test that a cache entry is created. $cache_bin = $variation_cache_factory->get($build['#cache']['bin']); diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceEntityDeleteTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceEntityDeleteTest.php index 165eb6c1d95..25ce72c1af4 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspaceEntityDeleteTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceEntityDeleteTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\workspaces\Functional; use Drupal\Tests\BrowserTestBase; -use Drupal\workspaces\Entity\Workspace; /** * Tests entity deletions with workspaces. @@ -56,7 +55,7 @@ class WorkspaceEntityDeleteTest extends BrowserTestBase { $this->drupalLogin($editor); // Create a Dev workspace as a child of Stage. - $stage = Workspace::load('stage'); + $stage = $this->createWorkspaceThroughUi('Stage', 'stage'); $dev = $this->createWorkspaceThroughUi('Dev', 'dev', 'stage'); // Create a published and an unpublished node in Live. @@ -141,8 +140,7 @@ class WorkspaceEntityDeleteTest extends BrowserTestBase { // Create a published node in Live. $published_live = $this->createNodeThroughUi('Test 1 published - live', 'article'); - $stage = Workspace::load('stage'); - $this->switchToWorkspace($stage); + $this->createAndActivateWorkspaceThroughUi('Stage', 'stage'); // A user with the 'bypass node access' permission will be able to see the // 'Delete' operation button, but it shouldn't be able to perform the diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php index efd3bef34c2..f01c554cea7 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\workspaces\Functional; use Drupal\Tests\BrowserTestBase; -use Drupal\workspaces\Entity\Workspace; /** * Tests Workspaces form validation. @@ -40,8 +39,7 @@ class WorkspaceFormValidationTest extends BrowserTestBase { * Tests partial form validation through #limit_validation_errors. */ public function testValidateLimitErrors(): void { - $stage = Workspace::load('stage'); - $this->switchToWorkspace($stage); + $this->createAndActivateWorkspaceThroughUi(); $edit = [ 'test' => 'test1', diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceMenuLinkContentIntegrationTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceMenuLinkContentIntegrationTest.php index 864ac8c1a36..75f6db02b15 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspaceMenuLinkContentIntegrationTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceMenuLinkContentIntegrationTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\workspaces\Functional; use Drupal\Tests\BrowserTestBase; -use Drupal\workspaces\Entity\Workspace; /** * Tests workspace integration for custom menu links. @@ -61,7 +60,7 @@ class WorkspaceMenuLinkContentIntegrationTest extends BrowserTestBase { * Tests custom menu links in non-default workspaces. */ public function testWorkspacesWithCustomMenuLinks(): void { - $stage = Workspace::load('stage'); + $stage = $this->createWorkspaceThroughUi('Stage', 'stage'); $this->setupWorkspaceSwitcherBlock(); diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceSwitcherTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceSwitcherTest.php index d7ac36a5e88..eef28c5ff65 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspaceSwitcherTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceSwitcherTest.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\workspaces\Functional; use Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait; +use Drupal\workspaces\Entity\Workspace; /** * Tests workspace switching functionality. @@ -52,14 +53,18 @@ class WorkspaceSwitcherTest extends BrowserTestBase { $mayer = $this->drupalCreateUser($permissions); $this->drupalLogin($mayer); + + $this->createWorkspaceThroughUi('Vultures', 'vultures'); + $this->createWorkspaceThroughUi('Gravity', 'gravity'); } /** * Tests switching workspace via the switcher block and admin page. */ public function testSwitchingWorkspaces(): void { - $this->createAndActivateWorkspaceThroughUi('Vultures', 'vultures'); - $gravity = $this->createWorkspaceThroughUi('Gravity', 'gravity'); + $vultures = Workspace::load('vultures'); + $gravity = Workspace::load('gravity'); + $this->switchToWorkspace($vultures); // Confirm the block shows on the front page. $this->drupalGet('<front>'); @@ -89,12 +94,12 @@ class WorkspaceSwitcherTest extends BrowserTestBase { // When adding a query parameter the workspace will be switched. $current_user_url = \Drupal::currentUser()->getAccount()->toUrl(); - $this->drupalGet($current_user_url, ['query' => ['workspace' => 'stage']]); - $web_assert->elementContains('css', '#block-workspace-switcher', 'Stage'); + $this->drupalGet($current_user_url, ['query' => ['workspace' => 'vultures']]); + $web_assert->elementContains('css', '#block-workspace-switcher', 'Vultures'); // The workspace switching via query parameter should persist. $this->drupalGet($current_user_url); - $web_assert->elementContains('css', '#block-workspace-switcher', 'Stage'); + $web_assert->elementContains('css', '#block-workspace-switcher', 'Vultures'); // Check that WorkspaceCacheContext provides the cache context used to // support its functionality. diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceTest.php index 0684f46467e..b632e60efd5 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspaceTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceTest.php @@ -33,6 +33,7 @@ class WorkspaceTest extends BrowserTestBase { 'user', 'workspaces', 'workspaces_ui', + 'workspaces_test', ]; /** @@ -157,6 +158,7 @@ class WorkspaceTest extends BrowserTestBase { public function testWorkspaceFormRevisions(): void { $this->drupalLogin($this->editor1); $storage = \Drupal::entityTypeManager()->getStorage('workspace'); + $this->createWorkspaceThroughUi('Stage', 'stage'); // The current 'stage' workspace entity should be revision 1. $stage_workspace = $storage->load('stage'); @@ -334,7 +336,7 @@ class WorkspaceTest extends BrowserTestBase { $user->delete(); $this->drupalGet('/admin/config/workflow/workspaces'); $this->assertSession()->pageTextContains('Summer event'); - $summer_event_workspace_row = $page->find('css', 'table tbody tr:nth-of-type(3)'); + $summer_event_workspace_row = $page->find('css', 'table tbody tr:nth-of-type(2)'); $this->assertEquals('N/A', $summer_event_workspace_row->find('css', 'td:nth-of-type(2)')->getText()); } @@ -364,7 +366,9 @@ class WorkspaceTest extends BrowserTestBase { $this->assertSession()->pageTextContains('There are no changes that can be published from Test workspace to Live.'); // Create a node in the workspace. - $this->createNodeThroughUi('Test node', 'test'); + $this->drupalGet('/node/add/test'); + $this->assertEquals(1, \Drupal::keyValue('ws_test')->get('node.hook_entity_create.count')); + $this->submitForm(['title[0][value]' => 'Test node'], 'Save'); $this->drupalGet('/admin/config/workflow/workspaces/manage/test_workspace/publish'); $this->assertSession()->statusCodeEquals(200); diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceTestUtilities.php b/core/modules/workspaces/tests/src/Functional/WorkspaceTestUtilities.php index 7f4230b0773..56e91d8a26b 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspaceTestUtilities.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceTestUtilities.php @@ -56,9 +56,9 @@ trait WorkspaceTestUtilities { /** * Creates and activates a new Workspace through the UI. * - * @param string $label + * @param string|null $label * The label of the workspace to create. - * @param string $id + * @param string|null $id * The ID of the workspace to create. * @param string $parent * (optional) The ID of the parent workspace. Defaults to '_none'. @@ -66,7 +66,10 @@ trait WorkspaceTestUtilities { * @return \Drupal\workspaces\WorkspaceInterface * The workspace that was just created. */ - protected function createAndActivateWorkspaceThroughUi(string $label, string $id, string $parent = '_none'): WorkspaceInterface { + protected function createAndActivateWorkspaceThroughUi(?string $label = NULL, ?string $id = NULL, string $parent = '_none'): WorkspaceInterface { + $id ??= $this->randomMachineName(); + $label ??= $this->randomString(); + $this->drupalGet('/admin/config/workflow/workspaces/add'); $this->submitForm([ 'id' => $id, @@ -76,15 +79,19 @@ trait WorkspaceTestUtilities { $this->getSession()->getPage()->hasContent("$label ($id)"); - return Workspace::load($id); + // Keep the test runner in sync with the system under test. + $workspace = Workspace::load($id); + \Drupal::service('workspaces.manager')->setActiveWorkspace($workspace); + + return $workspace; } /** * Creates a new Workspace through the UI. * - * @param string $label + * @param string|null $label * The label of the workspace to create. - * @param string $id + * @param string|null $id * The ID of the workspace to create. * @param string $parent * (optional) The ID of the parent workspace. Defaults to '_none'. @@ -92,7 +99,10 @@ trait WorkspaceTestUtilities { * @return \Drupal\workspaces\WorkspaceInterface * The workspace that was just created. */ - protected function createWorkspaceThroughUi($label, $id, $parent = '_none') { + protected function createWorkspaceThroughUi(?string $label = NULL, ?string $id = NULL, string $parent = '_none') { + $id ??= $this->randomMachineName(); + $label ??= $this->randomString(); + $this->drupalGet('/admin/config/workflow/workspaces/add'); $this->submitForm([ 'id' => $id, diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceViewsBulkFormTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceViewsBulkFormTest.php index de8795b644a..92b2673341e 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspaceViewsBulkFormTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceViewsBulkFormTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\workspaces\Functional; use Drupal\Tests\views\Functional\BulkFormTest; -use Drupal\workspaces\Entity\Workspace; /** * Tests the views bulk form in a workspace. @@ -40,8 +39,7 @@ class WorkspaceViewsBulkFormTest extends BulkFormTest { // Ensure that all the test methods are executed in the context of a // workspace. $this->setupWorkspaceSwitcherBlock(); - $stage = Workspace::load('stage'); - $this->switchToWorkspace($stage); + $this->createAndActivateWorkspaceThroughUi('Test workspace', 'test'); } /** diff --git a/core/modules/workspaces/tests/src/Functional/WorkspacesUninstallTest.php b/core/modules/workspaces/tests/src/Functional/WorkspacesUninstallTest.php index c14fe48effb..487e0dc5f55 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspacesUninstallTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspacesUninstallTest.php @@ -43,18 +43,12 @@ class WorkspacesUninstallTest extends BrowserTestBase { */ public function testUninstallingWorkspace(): void { $this->createContentType(['type' => 'article']); - $this->drupalGet('/admin/modules/uninstall'); - $session = $this->assertSession(); - $session->linkExists('Remove workspaces'); - $this->clickLink('Remove workspaces'); - $session->pageTextContains('Are you sure you want to delete all workspaces?'); - $this->drupalGet('/admin/modules/uninstall/entity/workspace'); - $this->submitForm([], 'Delete all workspaces'); $this->drupalGet('admin/modules/uninstall'); $this->submitForm(['uninstall[workspaces_ui]' => TRUE], 'Uninstall'); $this->submitForm([], 'Uninstall'); $this->submitForm(['uninstall[workspaces]' => TRUE], 'Uninstall'); $this->submitForm([], 'Uninstall'); + $session = $this->assertSession(); $session->pageTextContains('The selected modules have been uninstalled.'); $session->pageTextNotContains('Workspaces'); diff --git a/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesLayoutBuilderIntegrationTest.php b/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesLayoutBuilderIntegrationTest.php index d058cfcc838..632ec73f122 100644 --- a/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesLayoutBuilderIntegrationTest.php +++ b/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesLayoutBuilderIntegrationTest.php @@ -9,14 +9,14 @@ use Drupal\Tests\system\Traits\OffCanvasTestTrait; use Drupal\Tests\workspaces\Functional\WorkspaceTestUtilities; use Drupal\user\UserInterface; use Drupal\workspaces\Entity\Workspace; +use PHPUnit\Framework\Attributes\Group; /** * Tests for layout editing in workspaces. - * - * @group layout_builder - * @group workspaces - * @group #slow */ +#[Group('layout_builder')] +#[Group('workspaces')] +#[Group('#slow')] class WorkspacesLayoutBuilderIntegrationTest extends InlineBlockTestBase { use OffCanvasTestTrait; @@ -62,6 +62,7 @@ class WorkspacesLayoutBuilderIntegrationTest extends InlineBlockTestBase { ]); $this->drupalLogin($this->defaultUser); $this->setupWorkspaceSwitcherBlock(); + Workspace::create(['id' => 'stage', 'label' => 'Stage'])->save(); // Enable layout builder. $this->drupalGet(static::FIELD_UI_PREFIX . '/display/default'); @@ -190,7 +191,7 @@ class WorkspacesLayoutBuilderIntegrationTest extends InlineBlockTestBase { /** * Tests workspace specific layout tempstore data. * - * @covers \Drupal\workspaces\WorkspacesLayoutTempstoreRepository::getKey + * @legacy-covers \Drupal\workspaces\WorkspacesLayoutTempstoreRepository::getKey */ public function testWorkspacesLayoutTempstore(): void { $assert_session = $this->assertSession(); diff --git a/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesMediaLibraryIntegrationTest.php b/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesMediaLibraryIntegrationTest.php index 83ecb8e434f..673aed12a73 100644 --- a/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesMediaLibraryIntegrationTest.php +++ b/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesMediaLibraryIntegrationTest.php @@ -7,12 +7,12 @@ namespace Drupal\Tests\workspaces\FunctionalJavascript; use Drupal\Tests\media_library\FunctionalJavascript\EntityReferenceWidgetTest; use Drupal\user\UserInterface; use Drupal\workspaces\Entity\Workspace; +use PHPUnit\Framework\Attributes\Group; /** * Tests the Media library entity reference widget in a workspace. - * - * @group workspaces */ +#[Group('workspaces')] class WorkspacesMediaLibraryIntegrationTest extends EntityReferenceWidgetTest { /** @@ -47,8 +47,9 @@ class WorkspacesMediaLibraryIntegrationTest extends EntityReferenceWidgetTest { // Ensure that all the test methods are executed in the context of a // workspace. - $stage = Workspace::load('stage'); - \Drupal::service('workspaces.manager')->setActiveWorkspace($stage); + $workspace = Workspace::create(['id' => 'test', 'label' => 'Test']); + $workspace->save(); + \Drupal::service('workspaces.manager')->setActiveWorkspace($workspace); } /** diff --git a/core/modules/workspaces/tests/src/Kernel/WorkspaceFormPersistenceTest.php b/core/modules/workspaces/tests/src/Kernel/WorkspaceFormPersistenceTest.php new file mode 100644 index 00000000000..ac3e7b3a51c --- /dev/null +++ b/core/modules/workspaces/tests/src/Kernel/WorkspaceFormPersistenceTest.php @@ -0,0 +1,120 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\workspaces\Kernel; + +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\EventSubscriber\MainContentViewSubscriber; +use Drupal\Core\Form\FormBuilderInterface; +use Drupal\Core\Form\FormState; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; +use Drupal\workspaces\Entity\Workspace; +use Drupal\workspaces_test\Form\ActiveWorkspaceTestForm; +use Symfony\Component\HttpFoundation\Request; + +/** + * Tests form persistence for the active workspace. + * + * @group workspaces + */ +class WorkspaceFormPersistenceTest extends KernelTestBase { + + use UserCreationTrait; + use WorkspaceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'user', + 'workspaces', + 'workspaces_test', + ]; + + /** + * The entity type manager. + */ + protected EntityTypeManagerInterface $entityTypeManager; + + /** + * The form builder. + */ + protected FormBuilderInterface $formBuilder; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->entityTypeManager = \Drupal::entityTypeManager(); + $this->formBuilder = \Drupal::formBuilder(); + + $this->installEntitySchema('user'); + $this->installEntitySchema('workspace'); + + Workspace::create(['id' => 'ham', 'label' => 'Ham'])->save(); + Workspace::create(['id' => 'cheese', 'label' => 'Cheese'])->save(); + + $this->setCurrentUser($this->createUser([ + 'view any workspace', + ])); + } + + /** + * Tests that the active workspace is persisted throughout a form's lifecycle. + */ + public function testFormPersistence(): void { + $form_arg = ActiveWorkspaceTestForm::class; + + $this->switchToWorkspace('ham'); + $form_state_1 = new FormState(); + $form_1 = $this->formBuilder->buildForm($form_arg, $form_state_1); + + $this->switchToWorkspace('cheese'); + $form_state_2 = new FormState(); + $this->formBuilder->buildForm($form_arg, $form_state_2); + + // Submit the second form and check the workspace in which it was submitted. + $this->formBuilder->submitForm($form_arg, $form_state_2); + $this->assertSame('cheese', $this->keyValue->get('ws_test')->get('form_test_active_workspace')); + + // Submit the first form and check the workspace in which it was submitted. + $this->formBuilder->submitForm($form_arg, $form_state_1); + $this->assertSame('ham', $this->keyValue->get('ws_test')->get('form_test_active_workspace')); + + // Reset the workspace manager service to simulate a new request and check + // that the second workspace is still active. + \Drupal::getContainer()->set('workspaces.manager', NULL); + $this->assertSame('cheese', \Drupal::service('workspaces.manager')->getActiveWorkspace()->id()); + + // Reset the workspace manager service again to prepare for a new request. + \Drupal::getContainer()->set('workspaces.manager', NULL); + + $request = Request::create( + $form_1['test']['#ajax']['url']->toString(), + 'POST', + [ + MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax', + ] + $form_1['test']['#attached']['drupalSettings']['ajax'][$form_1['test']['#id']]['submit'], + ); + \Drupal::service('http_kernel')->handle($request); + + $form_state_1->setTriggeringElement($form_1['test']); + \Drupal::service('form_ajax_response_builder')->buildResponse($request, $form_1, $form_state_1, []); + + // Check that the AJAX callback is executed in the initial workspace of its + // parent form. + $this->assertSame('ham', $this->keyValue->get('ws_test')->get('ajax_test_active_workspace')); + + // Reset the workspace manager service again and check that the AJAX request + // didn't change the persisted workspace. + \Drupal::getContainer()->set('workspaces.manager', NULL); + \Drupal::requestStack()->pop(); + $this->assertSame('cheese', \Drupal::service('workspaces.manager')->getActiveWorkspace()->id()); + } + +} diff --git a/core/modules/workspaces/workspaces.install b/core/modules/workspaces/workspaces.install index 6c9c99f29a3..be1f19a3a30 100644 --- a/core/modules/workspaces/workspaces.install +++ b/core/modules/workspaces/workspaces.install @@ -6,38 +6,6 @@ */ use Drupal\Core\Entity\EntityTypeInterface; -use Drupal\workspaces\Entity\Workspace; - -/** - * Implements hook_install(). - */ -function workspaces_install(): void { - // Set the owner of these default workspaces to be first user which has the - // 'administrator' role. This way we avoid hard coding user ID 1 for sites - // that prefer to not give it any special meaning. - $admin_roles = \Drupal::entityTypeManager()->getStorage('user_role')->getQuery() - ->condition('is_admin', TRUE) - ->execute(); - if (!empty($admin_roles)) { - $query = \Drupal::entityTypeManager()->getStorage('user')->getQuery() - ->accessCheck(FALSE) - ->condition('roles', $admin_roles, 'IN') - ->condition('status', 1) - ->sort('uid', 'ASC') - ->range(0, 1); - $result = $query->execute(); - } - - // Default to user ID 1 if we could not find any other administrator users. - $owner_id = !empty($result) ? reset($result) : 1; - - // Create a 'stage' workspace by default. - Workspace::create([ - 'id' => 'stage', - 'label' => 'Stage', - 'uid' => $owner_id, - ])->save(); -} /** * Implements hook_schema(). diff --git a/core/modules/workspaces/workspaces.services.yml b/core/modules/workspaces/workspaces.services.yml index 1fd07d36d77..4abb1d1cae4 100644 --- a/core/modules/workspaces/workspaces.services.yml +++ b/core/modules/workspaces/workspaces.services.yml @@ -3,9 +3,9 @@ services: autoconfigure: true workspaces.manager: class: Drupal\workspaces\WorkspaceManager - arguments: ['@request_stack', '@entity_type.manager', '@entity.memory_cache', '@current_user', '@state', '@logger.channel.workspaces', '@class_resolver', '@workspaces.association', '@workspaces.information'] + autowire: true tags: - - { name: service_id_collector, tag: workspace_negotiator } + - { name: service_collector, call: addNegotiator, tag: workspace_negotiator } Drupal\workspaces\WorkspaceManagerInterface: '@workspaces.manager' workspaces.information: class: Drupal\workspaces\WorkspaceInformation diff --git a/core/modules/workspaces_ui/tests/src/FunctionalJavascript/WorkspaceToolbarIntegrationTest.php b/core/modules/workspaces_ui/tests/src/FunctionalJavascript/WorkspaceToolbarIntegrationTest.php index 488a6dd34c3..5d500fce8fa 100644 --- a/core/modules/workspaces_ui/tests/src/FunctionalJavascript/WorkspaceToolbarIntegrationTest.php +++ b/core/modules/workspaces_ui/tests/src/FunctionalJavascript/WorkspaceToolbarIntegrationTest.php @@ -5,12 +5,14 @@ declare(strict_types=1); namespace Drupal\Tests\workspaces_ui\FunctionalJavascript; use Drupal\Tests\system\FunctionalJavascript\OffCanvasTestBase; +use Drupal\workspaces\Entity\Workspace; +use PHPUnit\Framework\Attributes\Group; /** * Tests workspace settings stray integration. - * - * @group workspaces_ui */ +#[Group('workspaces')] +#[Group('workspaces_ui')] class WorkspaceToolbarIntegrationTest extends OffCanvasTestBase { /** @@ -34,6 +36,7 @@ class WorkspaceToolbarIntegrationTest extends OffCanvasTestBase { 'access administration pages', ]); $this->drupalLogin($admin_user); + Workspace::create(['id' => 'stage', 'label' => 'Stage'])->save(); } /** |