diff options
Diffstat (limited to 'core/modules')
759 files changed, 14996 insertions, 7091 deletions
diff --git a/core/modules/announcements_feed/tests/src/Kernel/AnnounceTestBase.php b/core/modules/announcements_feed/tests/src/Kernel/AnnounceTestBase.php index b70ad5d6c275..e017014afe58 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 0cc66d22384e..69c364b1f250 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 c237f07ea34d..80258bad508e 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 000000000000..40f71d091c26 --- /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/big_pipe.module b/core/modules/big_pipe/big_pipe.module index 75509d6f649e..d04104799cd3 100644 --- a/core/modules/big_pipe/big_pipe.module +++ b/core/modules/big_pipe/big_pipe.module @@ -22,6 +22,7 @@ function big_pipe_theme_suggestions_big_pipe_interface_preview(array $variables) // Use simplified template suggestion, if any. // For example, this simplifies + // phpcs:ignore Drupal.Files.LineLength // big-pipe-interface-preview--Drupal-block-BlockViewBuilder--lazyBuilder--<BLOCK ID>.html.twig // to // big-pipe-interface-preview--block--<BLOCK ID>.html.twig diff --git a/core/modules/big_pipe/js/big_pipe.js b/core/modules/big_pipe/js/big_pipe.js index 3c3e106e7037..cc8c7a244961 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/modules/big_pipe_messages_test/big_pipe_messages_test.info.yml b/core/modules/big_pipe/tests/modules/big_pipe_messages_test/big_pipe_messages_test.info.yml new file mode 100644 index 000000000000..f7522b47887c --- /dev/null +++ b/core/modules/big_pipe/tests/modules/big_pipe_messages_test/big_pipe_messages_test.info.yml @@ -0,0 +1,5 @@ +name: 'BigPipe messages test' +type: module +description: 'Forces the messages placeholder to go via the big pipe strategy for testing purposes.' +package: Testing +version: VERSION diff --git a/core/modules/big_pipe/tests/modules/big_pipe_messages_test/src/Hook/BigPipeMessagesHooks.php b/core/modules/big_pipe/tests/modules/big_pipe_messages_test/src/Hook/BigPipeMessagesHooks.php new file mode 100644 index 000000000000..bb8212bb2848 --- /dev/null +++ b/core/modules/big_pipe/tests/modules/big_pipe_messages_test/src/Hook/BigPipeMessagesHooks.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\big_pipe_messages_test\Hook; + +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Security\Attribute\TrustedCallback; + +/** + * Hook implementations for big_pipe_test. + */ +class BigPipeMessagesHooks { + + /** + * Implements hook_element_info_alter(). + */ + #[Hook('element_info_alter')] + public function elementInfoAlter(array &$info): void { + $info['status_messages']['#pre_render'][] = static::class . '::preRenderMessages'; + } + + /** + * Pre render callback. + * + * Removes #placeholder_strategy from the messages element to force the + * messages placeholder to go via the big pipe strategy for testing purposes. + */ + #[TrustedCallback] + public static function preRenderMessages(array $element): array { + if (isset($element['#attached']['placeholders'])) { + $key = key($element['#attached']['placeholders']); + unset($element['#attached']['placeholders'][$key]['#placeholder_strategy_denylist']); + } + if (isset($element['messages']['#attached']['placeholders'])) { + $key = key($element['messages']['#attached']['placeholders']); + unset($element['messages']['#attached']['placeholders'][$key]['#placeholder_strategy_denylist']); + } + return $element; + } + +} diff --git a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php index 26e92fc14982..1ca75bb57262 100644 --- a/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php +++ b/core/modules/big_pipe/tests/src/Functional/BigPipeTest.php @@ -32,7 +32,7 @@ class BigPipeTest extends BrowserTestBase { /** * {@inheritdoc} */ - protected static $modules = ['big_pipe', 'big_pipe_test', 'dblog']; + protected static $modules = ['big_pipe', 'big_pipe_messages_test', 'big_pipe_test', 'dblog']; /** * {@inheritdoc} @@ -88,9 +88,10 @@ class BigPipeTest extends BrowserTestBase { // 2. Session (authenticated). $this->drupalLogin($this->rootUser); + $this->drupalGet(Url::fromRoute('big_pipe_test')); $this->assertSessionCookieExists('1'); $this->assertBigPipeNoJsCookieExists('0'); - $this->assertSession()->responseContains('<noscript><meta http-equiv="Refresh" content="0; URL=' . base_path() . 'big_pipe/no-js?destination=' . UrlHelper::encodePath(base_path() . 'user/1?check_logged_in=1') . '" />' . "\n" . '</noscript>'); + $this->assertSession()->responseContains('<noscript><meta http-equiv="Refresh" content="0; URL=' . base_path() . 'big_pipe/no-js?destination=' . UrlHelper::encodePath(base_path() . 'big_pipe_test') . '" />' . "\n" . '</noscript>'); $this->assertSession()->responseNotContains($no_js_to_js_markup); $this->assertBigPipeNoJsMetaRefreshRedirect(); $this->assertBigPipeNoJsCookieExists('1'); @@ -103,10 +104,10 @@ class BigPipeTest extends BrowserTestBase { // 3. Session (anonymous). $this->drupalGet(Url::fromRoute('user.login', [], ['query' => ['trigger_session' => 1]])); - $this->drupalGet(Url::fromRoute('user.login')); + $this->drupalGet(Url::fromRoute('big_pipe_test')); $this->assertSessionCookieExists('1'); $this->assertBigPipeNoJsCookieExists('0'); - $this->assertSession()->responseContains('<noscript><meta http-equiv="Refresh" content="0; URL=' . base_path() . 'big_pipe/no-js?destination=' . base_path() . 'user/login" />' . "\n" . '</noscript>'); + $this->assertSession()->responseContains('<noscript><meta http-equiv="Refresh" content="0; URL=' . base_path() . 'big_pipe/no-js?destination=' . base_path() . 'big_pipe_test" />' . "\n" . '</noscript>'); $this->assertSession()->responseNotContains($no_js_to_js_markup); $this->assertBigPipeNoJsMetaRefreshRedirect(); $this->assertBigPipeNoJsCookieExists('1'); @@ -314,14 +315,9 @@ class BigPipeTest extends BrowserTestBase { // @see performMetaRefresh() $this->drupalGet(Url::fromRoute('big_pipe_test_multi_occurrence')); - // cspell:disable-next-line - $big_pipe_placeholder_id = 'callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args%5B0%5D&token=_HAdUpwWmet0TOTe2PSiJuMntExoshbm1kh2wQzzzAA'; - $expected_placeholder_replacement = '<script type="application/vnd.drupal-ajax" data-big-pipe-replacement-for-placeholder-with-id="' . $big_pipe_placeholder_id . '">'; $this->assertSession()->pageTextContains('The count is 1.'); $this->assertSession()->responseNotContains('The count is 2.'); $this->assertSession()->responseNotContains('The count is 3.'); - $raw_content = $this->getSession()->getPage()->getContent(); - $this->assertSame(1, substr_count($raw_content, $expected_placeholder_replacement), 'Only one placeholder replacement was found for the duplicate #lazy_builder arrays.'); // By calling performMetaRefresh() here, we simulate JavaScript being // disabled, because as far as the BigPipe module is concerned, it is diff --git a/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipeRegressionTest.php b/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipeRegressionTest.php index 20244a8fa098..8e4abff5734f 100644 --- a/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipeRegressionTest.php +++ b/core/modules/big_pipe/tests/src/FunctionalJavascript/BigPipeRegressionTest.php @@ -22,6 +22,7 @@ class BigPipeRegressionTest extends WebDriverTestBase { */ protected static $modules = [ 'big_pipe', + 'big_pipe_messages_test', 'big_pipe_regression_test', ]; diff --git a/core/modules/block/block.module b/core/modules/block/block.module index 94a2cb9fc7a7..24e285894915 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 b73f96422317..97623ff523af 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 74922444e8df..853ce28a47b8 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 9b031b7daa79..35c6f23d86f4 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 000000000000..317d19ddc64f --- /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 f33092de869e..b5a1e4643468 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 98cd3992fbc4..802a60bccb15 100644 --- a/core/modules/block/src/Hook/BlockHooks.php +++ b/core/modules/block/src/Hook/BlockHooks.php @@ -82,13 +82,14 @@ class BlockHooks { * block.html.twig is used. * * Most themes use their own copy of block.html.twig. The default is located - * inside "core/modules/block/templates/block.html.twig". Look in there for the - * full list of available variables. + * inside "core/modules/block/templates/block.html.twig". Look in there for + * the full list of available variables. * * @param array $variables * An associative array containing: - * - elements: An associative array containing the properties of the element. - * Properties used: #block, #configuration, #children, #plugin_id. + * - elements: An associative array containing the properties of the + * element. Properties used: #block, #configuration, #children, + * and #plugin_id. */ public function preprocessBlock(&$variables): void { $variables['configuration'] = $variables['elements']['#configuration']; @@ -98,8 +99,8 @@ class BlockHooks { $variables['in_preview'] = $variables['elements']['#in_preview'] ?? FALSE; $variables['label'] = !empty($variables['configuration']['label_display']) ? $variables['configuration']['label'] : ''; $variables['content'] = $variables['elements']['content']; - // A block's label is configuration: it is static. Allow dynamic labels to be - // set in the render array. + // A block's label is configuration: it is static. Allow dynamic labels to + // be set in the render array. if (isset($variables['elements']['content']['#title']) && !empty($variables['configuration']['label_display'])) { $variables['label'] = $variables['elements']['content']['#title']; } @@ -150,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 19afd070f9d0..7954c270df24 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 d7ee92575fe3..4801c308cf54 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/Kernel/BlockConfigSchemaTest.php b/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php index 8b2ead48edaf..6305ab7f8415 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 000000000000..80e3f7983420 --- /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 d93e1f819eab..377cd31deacb 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 3f20b2148b8d..cb94233ebaff 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 592b3ee97147..53021a078f6a 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 2661eda513b5..5d43a1d37f8e 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/optional/views.view.block_content.yml b/core/modules/block_content/config/optional/views.view.block_content.yml index 1bccbb446467..a8da86eede0b 100644 --- a/core/modules/block_content/config/optional/views.view.block_content.yml +++ b/core/modules/block_content/config/optional/views.view.block_content.yml @@ -496,6 +496,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields query: 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 8c4161df5b5e..6d7b2aeadc8f 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 be86d9e97000..000000000000 --- 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 bc6a6dcec694..000000000000 --- 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 5d9eaf430b43..000000000000 --- 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 98b2a547ccfb..000000000000 --- 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 34d5dd6e5ed0..0d2765e8018a 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 f6763f451c57..e4f80d202541 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 aaf7957b8952..9c12709b2a55 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 b2776f51d7d8..77f8eee7939d 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 0d92bde49300..ed69e44f6f6a 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 ecbc6c3866d2..fa6fe3955038 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 bd1cfd608f14..d960c0a22d68 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 c42fb2f5de54..131a56b516f8 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 844e06895cc5..4e6c3b141e70 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 998c43340ebb..481478095608 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/modules/block_content_test_views/test_views/views.view.test_block_content_redirect_destination.yml b/core/modules/block_content/tests/modules/block_content_test_views/test_views/views.view.test_block_content_redirect_destination.yml index 8f902c94bda2..df0c7314f9a6 100644 --- a/core/modules/block_content/tests/modules/block_content_test_views/test_views/views.view.test_block_content_redirect_destination.yml +++ b/core/modules/block_content/tests/modules/block_content_test_views/test_views/views.view.test_block_content_redirect_destination.yml @@ -63,6 +63,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php b/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php index bca42cd3e328..364b5f4524d6 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 d0a3794b978d..ffee97e46793 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 fba8362cd2cd..2ec82c2195b8 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 000000000000..bb1a20df880c --- /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 000000000000..fb4bff9e63bc --- /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 1b8af1195eec..04c54c637d1a 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 bb0186431e9a..fe07b3d5652e 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 000000000000..0322a3ebf173 --- /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 b9fd73b49fa6..000000000000 --- 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 a407e2388754..000000000000 --- 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 55c9a89a10ca..000000000000 --- 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 2a2c3fd16587..7624fbfd7083 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 07ce32c8fedc..4dd9a4e3afa0 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 aef00a7595e7..f6d2bdb0da8c 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 9356c590f56b..6ad80b095eab 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 38c848e43941..06fbe635bf9c 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 50abc4fe3b82..dedcf00f63ba 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 8c6ceadd4a13..8bfd9e47fbdd 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 6b717b1ad0b4..677b7b18a23e 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 cb4685e49f23..3148fc0b944c 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 c5f89893c8ec..5c1afcf987b3 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 b4623eee7728..2ae8ca9d809d 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 151fb2a30293..6ab77c7b60fe 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 4ba0020a2e1e..b6702acbab90 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/HTMLRestrictions.php b/core/modules/ckeditor5/src/HTMLRestrictions.php index eb35e61b1e93..e10e81ef46da 100644 --- a/core/modules/ckeditor5/src/HTMLRestrictions.php +++ b/core/modules/ckeditor5/src/HTMLRestrictions.php @@ -1102,7 +1102,7 @@ final class HTMLRestrictions { } /** - * Extracts the subset of plain tags (attributes omitted) from allowed elements. + * Extracts plain tags (attributes omitted) from allowed elements. * * @return \Drupal\ckeditor5\HTMLRestrictions * The extracted subset of the given set of HTML restrictions. diff --git a/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Alignment.php b/core/modules/ckeditor5/src/Plugin/CKEditor5Plugin/Alignment.php index b74b08fb347d..c1eb1eebfeb2 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 fdce2aceade6..8723ae266ca8 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); + } + } + } } /** @@ -467,7 +497,8 @@ final class CKEditor5PluginDefinition extends PluginDefinition implements Plugin * Whether this plugin has an asset library to load on the admin UI. * * @return bool - * TRUE if the plugin has an asset library to load on the admin UI, FALSE otherwise. + * TRUE if the plugin has an asset library to load on the admin UI, FALSE + * otherwise. * * @see \Drupal\ckeditor5\Annotation\DrupalAspectsOfCKEditor5Plugin::$admin_library */ diff --git a/core/modules/ckeditor5/src/SmartDefaultSettings.php b/core/modules/ckeditor5/src/SmartDefaultSettings.php index ec3f90fa250c..3c2d0d9ec2c0 100644 --- a/core/modules/ckeditor5/src/SmartDefaultSettings.php +++ b/core/modules/ckeditor5/src/SmartDefaultSettings.php @@ -76,7 +76,7 @@ final class SmartDefaultSettings { } /** - * Computes the closest possible equivalent settings for switching to CKEditor 5. + * Computes the closest equivalent settings for switching to CKEditor 5. * * @param \Drupal\editor\EditorInterface|null $text_editor * The editor being reconfigured for CKEditor 5; infer the settings based on 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 000000000000..8ee35f2a3e9d --- /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 000000000000..d9ebea174b67 --- /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/CKEditor5DialogTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5DialogTest.php index 734bc3b430c2..9ed2be2d85ba 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5DialogTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5DialogTest.php @@ -81,7 +81,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/CKEditor5HeightTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5HeightTest.php new file mode 100644 index 000000000000..81928b1642b9 --- /dev/null +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/CKEditor5HeightTest.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\ckeditor5\FunctionalJavascript; + +use Drupal\editor\Entity\Editor; +use Drupal\Tests\ckeditor5\Traits\CKEditor5TestTrait; + +/** + * Tests ckeditor height respects field rows config. + * + * @group ckeditor5 + * @internal + */ +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/ImageTestBase.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBase.php index 427a74a7054c..8efebd4de818 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBase.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestBase.php @@ -92,7 +92,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 2e0d3e6e3ceb..4cc94a97cded 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/ImageTestTestBase.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageTestTestBase.php index 070ae0e90cad..a6230938d12b 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/ImageUrlTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTest.php index 949b4d04ed23..d2ed0f991e85 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/ImageUrlTest.php @@ -30,7 +30,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 5d0d6c50c824..1638a44cd280 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/MediaLinkabilityTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLinkabilityTest.php index f7580f8847ac..2d581b36ee5c 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLinkabilityTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaLinkabilityTest.php @@ -134,10 +134,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 @@ -272,13 +272,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 +298,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/MediaTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaTest.php index 114704afb7f8..5ba284497086 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/MediaTest.php @@ -332,7 +332,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')); diff --git a/core/modules/ckeditor5/tests/src/FunctionalJavascript/StyleTest.php b/core/modules/ckeditor5/tests/src/FunctionalJavascript/StyleTest.php index e295090d7c18..2de2d3201dc9 100644 --- a/core/modules/ckeditor5/tests/src/FunctionalJavascript/StyleTest.php +++ b/core/modules/ckeditor5/tests/src/FunctionalJavascript/StyleTest.php @@ -658,7 +658,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/Kernel/CKEditor5PluginManagerTest.php b/core/modules/ckeditor5/tests/src/Kernel/CKEditor5PluginManagerTest.php index 84e26e783882..731e777749eb 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 8c87a78752ed..75735a0cc513 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 a1b76110eb7a..000000000000 --- 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 000000000000..bb69c28c6fbf --- /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/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php b/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php index f512c008ceeb..dfd6c1db1da8 100644 --- a/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php +++ b/core/modules/ckeditor5/tests/src/Unit/HTMLRestrictionsTest.php @@ -1131,8 +1131,8 @@ class HTMLRestrictionsTest extends UnitTestCase { 'expected_union' => 'a', ]; yield 'attribute restrictions are different: <ol type=*> vs <ol type="A"> — vice versa' => [ - 'b' => new HTMLRestrictions(['ol' => ['type' => ['A' => TRUE]]]), - 'a' => new HTMLRestrictions(['ol' => ['type' => TRUE]]), + 'a' => new HTMLRestrictions(['ol' => ['type' => ['A' => TRUE]]]), + 'b' => new HTMLRestrictions(['ol' => ['type' => TRUE]]), 'expected_diff' => HTMLRestrictions::emptySet(), 'expected_intersection' => 'a', 'expected_union' => 'b', @@ -1145,8 +1145,8 @@ class HTMLRestrictionsTest extends UnitTestCase { 'expected_union' => 'a', ]; yield 'attribute restrictions are different: <ol type=*> vs <ol type="1"> — vice versa' => [ - 'b' => new HTMLRestrictions(['ol' => ['type' => ['1' => TRUE]]]), - 'a' => new HTMLRestrictions(['ol' => ['type' => TRUE]]), + 'a' => new HTMLRestrictions(['ol' => ['type' => ['1' => TRUE]]]), + 'b' => new HTMLRestrictions(['ol' => ['type' => TRUE]]), 'expected_diff' => HTMLRestrictions::emptySet(), 'expected_intersection' => 'a', 'expected_union' => 'b', diff --git a/core/modules/comment/config/optional/views.view.comment.yml b/core/modules/comment/config/optional/views.view.comment.yml index a54fb25883dd..f3edd69dc65f 100644 --- a/core/modules/comment/config/optional/views.view.comment.yml +++ b/core/modules/comment/config/optional/views.view.comment.yml @@ -823,6 +823,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields query: diff --git a/core/modules/comment/src/Hook/CommentThemeHooks.php b/core/modules/comment/src/Hook/CommentThemeHooks.php index e789af6dab10..c137d586d416 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/src/Plugin/views/field/LastTimestamp.php b/core/modules/comment/src/Plugin/views/field/LastTimestamp.php index c0bd35f30cae..3f1698d12363 100644 --- a/core/modules/comment/src/Plugin/views/field/LastTimestamp.php +++ b/core/modules/comment/src/Plugin/views/field/LastTimestamp.php @@ -9,7 +9,7 @@ use Drupal\views\ResultRow; use Drupal\views\ViewExecutable; /** - * Field handler to display the timestamp of a comment with the count of comments. + * Displays the timestamp of a comment with the count of comments. * * @ingroup views_field_handlers */ diff --git a/core/modules/comment/templates/field--comment.html.twig b/core/modules/comment/templates/field--comment.html.twig index 879f4d57ae4f..1ea746db0296 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 db1ffae5a6d0..01a40394b409 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/modules/comment_test_views/test_views/views.view.test_comment_operations.yml b/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_operations.yml index fdabeedeed4a..50b74fc66422 100644 --- a/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_operations.yml +++ b/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_operations.yml @@ -68,6 +68,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_schema.yml b/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_schema.yml index 04fcb57e2a8b..e174974fe2f8 100644 --- a/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_schema.yml +++ b/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_comment_schema.yml @@ -235,6 +235,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: false columns: diff --git a/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_new_comments.yml b/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_new_comments.yml index b2e2cf4a41c2..fb0736cf4486 100644 --- a/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_new_comments.yml +++ b/core/modules/comment/tests/modules/comment_test_views/test_views/views.view.test_new_comments.yml @@ -38,6 +38,11 @@ display: type: full style: type: table + options: + grouping: { } + class: '' + row_class: '' + default_row_class: true row: type: fields fields: diff --git a/core/modules/comment/tests/src/Functional/CommentAdminTest.php b/core/modules/comment/tests/src/Functional/CommentAdminTest.php index f8dfc8a9b38b..69c634ba0f95 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 819403386b1c..4927803208b0 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 379e51803b05..c04c478f3a2f 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 2fe777a1da3e..8ac15c8f1bce 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 1fe966fb127e..d9f9b15724d6 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 8cf7e858b738..d54e4d3c8156 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 501953547012..2a40a48f47ee 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/ConfigExportTest.php b/core/modules/config/tests/src/FunctionalJavascript/ConfigExportTest.php index 2489d31ae958..de9bb6ce8897 100644 --- a/core/modules/config/tests/src/FunctionalJavascript/ConfigExportTest.php +++ b/core/modules/config/tests/src/FunctionalJavascript/ConfigExportTest.php @@ -4,8 +4,8 @@ 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; /** * Tests the config export form. @@ -14,6 +14,8 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; */ 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_translation/migrations/d6_block_translation.yml b/core/modules/config_translation/migrations/d6_block_translation.yml index 6d57fdae1be4..7925c49626f2 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 9c82ee6b6786..d2530e3b50a8 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 6ce0054a3d8e..6edbdf3561ed 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/contact/src/Hook/ContactFormHooks.php b/core/modules/contact/src/Hook/ContactFormHooks.php new file mode 100644 index 000000000000..b31b929bddf2 --- /dev/null +++ b/core/modules/contact/src/Hook/ContactFormHooks.php @@ -0,0 +1,76 @@ +<?php + +namespace Drupal\contact\Hook; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\user\UserDataInterface; + +/** + * Form hook implementations for Contact module. + */ +class ContactFormHooks { + + use StringTranslationTrait; + + public function __construct( + protected readonly AccountInterface $currentUser, + protected readonly UserDataInterface $userData, + protected readonly configFactoryInterface $configFactory, + ) {} + + /** + * Implements hook_form_FORM_ID_alter() for \Drupal\user\ProfileForm. + * + * Add the enable personal contact form to an individual user's account page. + * + * @see \Drupal\user\ProfileForm::form() + */ + #[Hook('form_user_form_alter')] + public function formUserFormAlter(&$form, FormStateInterface $form_state) : void { + $form['contact'] = [ + '#type' => 'details', + '#title' => $this->t('Contact settings'), + '#open' => TRUE, + '#weight' => 5, + ]; + $account = $form_state->getFormObject()->getEntity(); + if (!$this->currentUser->isAnonymous() && $account->id()) { + $account_data = $this->userData->get('contact', $account->id(), 'enabled'); + } + $form['contact']['contact'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Personal contact form'), + '#default_value' => $account_data ?? $this->configFactory->getEditable('contact.settings')->get('user_default_enabled'), + '#description' => $this->t('Allow other users to contact you via a personal contact form which keeps your email address hidden. Note that some privileged users such as site administrators are still able to contact you even if you choose to disable this feature.'), + ]; + $form['actions']['submit']['#submit'][] = 'contact_user_profile_form_submit'; + } + + /** + * Implements hook_form_FORM_ID_alter() for \Drupal\user\AccountSettingsForm. + * + * Adds the default personal contact setting on the user settings page. + */ + #[Hook('form_user_admin_settings_alter')] + public function formUserAdminSettingsAlter(&$form, FormStateInterface $form_state) : void { + $form['contact'] = [ + '#type' => 'details', + '#title' => $this->t('Contact settings'), + '#open' => TRUE, + '#weight' => 0, + ]; + $form['contact']['contact_default_status'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable the personal contact form by default for new users'), + '#description' => $this->t('Changing this setting will not affect existing users.'), + '#default_value' => $this->configFactory->getEditable('contact.settings')->get('user_default_enabled'), + ]; + // Add submit handler to save contact configuration. + $form['#submit'][] = 'contact_form_user_admin_settings_submit'; + } + +} diff --git a/core/modules/contact/src/Hook/ContactHooks.php b/core/modules/contact/src/Hook/ContactHooks.php index 3ed2831c122b..c083c12c1d47 100644 --- a/core/modules/contact/src/Hook/ContactHooks.php +++ b/core/modules/contact/src/Hook/ContactHooks.php @@ -3,7 +3,6 @@ namespace Drupal\contact\Hook; use Drupal\contact\Plugin\rest\resource\ContactMessageResource; -use Drupal\Core\Form\FormStateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\user\Entity\User; use Drupal\Core\Url; @@ -173,57 +172,6 @@ class ContactHooks { } /** - * Implements hook_form_FORM_ID_alter() for \Drupal\user\ProfileForm. - * - * Add the enable personal contact form to an individual user's account page. - * - * @see \Drupal\user\ProfileForm::form() - */ - #[Hook('form_user_form_alter')] - public function formUserFormAlter(&$form, FormStateInterface $form_state) : void { - $form['contact'] = [ - '#type' => 'details', - '#title' => $this->t('Contact settings'), - '#open' => TRUE, - '#weight' => 5, - ]; - $account = $form_state->getFormObject()->getEntity(); - if (!\Drupal::currentUser()->isAnonymous() && $account->id()) { - $account_data = \Drupal::service('user.data')->get('contact', $account->id(), 'enabled'); - } - $form['contact']['contact'] = [ - '#type' => 'checkbox', - '#title' => $this->t('Personal contact form'), - '#default_value' => $account_data ?? \Drupal::config('contact.settings')->get('user_default_enabled'), - '#description' => $this->t('Allow other users to contact you via a personal contact form which keeps your email address hidden. Note that some privileged users such as site administrators are still able to contact you even if you choose to disable this feature.'), - ]; - $form['actions']['submit']['#submit'][] = 'contact_user_profile_form_submit'; - } - - /** - * Implements hook_form_FORM_ID_alter() for \Drupal\user\AccountSettingsForm. - * - * Adds the default personal contact setting on the user settings page. - */ - #[Hook('form_user_admin_settings_alter')] - public function formUserAdminSettingsAlter(&$form, FormStateInterface $form_state) : void { - $form['contact'] = [ - '#type' => 'details', - '#title' => $this->t('Contact settings'), - '#open' => TRUE, - '#weight' => 0, - ]; - $form['contact']['contact_default_status'] = [ - '#type' => 'checkbox', - '#title' => $this->t('Enable the personal contact form by default for new users'), - '#description' => $this->t('Changing this setting will not affect existing users.'), - '#default_value' => \Drupal::configFactory()->getEditable('contact.settings')->get('user_default_enabled'), - ]; - // Add submit handler to save contact configuration. - $form['#submit'][] = 'contact_form_user_admin_settings_submit'; - } - - /** * Implements hook_rest_resource_alter(). */ #[Hook('rest_resource_alter')] diff --git a/core/modules/contact/tests/src/Functional/ContactPersonalTest.php b/core/modules/contact/tests/src/Functional/ContactPersonalTest.php index bac903bdd29a..df4f0834788a 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/config/optional/views.view.moderated_content.yml b/core/modules/content_moderation/config/optional/views.view.moderated_content.yml index ae4fda31e816..f7e80ac1162c 100644 --- a/core/modules/content_moderation/config/optional/views.view.moderated_content.yml +++ b/core/modules/content_moderation/config/optional/views.view.moderated_content.yml @@ -765,6 +765,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields query: 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 4e8e77ff7da2..5a130bf160c3 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/src/Plugin/Field/ModerationStateFieldItemList.php b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php index 41174b60490b..eadb49642080 100644 --- a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php +++ b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php @@ -56,7 +56,7 @@ class ModerationStateFieldItemList extends FieldItemList { // It is possible that the bundle does not exist at this point. For example, // the node type form creates a fake Node entity to get default values. - // @see \Drupal\node\NodeTypeForm::form() + // @see \Drupal\node\Form\NodeTypeForm::form() $workflow = $moderation_info->getWorkFlowForEntity($entity); return $workflow ? $workflow->getTypePlugin()->getInitialState($entity)->id() : NULL; } diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml index 83a6b62da89b..8d2a8519c32f 100644 --- a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml @@ -348,6 +348,11 @@ display: group_items: { } style: type: table + options: + grouping: { } + class: '' + row_class: '' + default_row_class: true row: type: fields query: diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_sort_base_table.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_sort_base_table.yml index 16ba5ba97ac1..9c1cb4e26dce 100644 --- a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_sort_base_table.yml +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_sort_base_table.yml @@ -206,6 +206,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true columns: diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_sort_revision_table.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_sort_revision_table.yml index 1e7e3b48ceaf..c2f10a8a729c 100644 --- a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_sort_revision_table.yml +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_sort_revision_table.yml @@ -205,6 +205,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true columns: diff --git a/core/modules/content_moderation/tests/src/Functional/WorkspaceContentModerationIntegrationTest.php b/core/modules/content_moderation/tests/src/Functional/WorkspaceContentModerationIntegrationTest.php index 4b4fbe2c7905..2462173f970b 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/tests/modules/content_translation_test_views/test_views/views.view.test_entity_translations_link.yml b/core/modules/content_translation/tests/modules/content_translation_test_views/test_views/views.view.test_entity_translations_link.yml index ffa73e8edef8..d5c5c667a00d 100644 --- a/core/modules/content_translation/tests/modules/content_translation_test_views/test_views/views.view.test_entity_translations_link.yml +++ b/core/modules/content_translation/tests/modules/content_translation_test_views/test_views/views.view.test_entity_translations_link.yml @@ -37,6 +37,7 @@ display: style: type: table options: + class: '' columns: name: name translation_link: translation_link diff --git a/core/modules/contextual/contextual.libraries.yml b/core/modules/contextual/contextual.libraries.yml index bfc1c996c98f..0e80f3aed427 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 06a6728be396..55a83d5ca12a 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 87ccaa52dffe..5a852e28fbac 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 8fc206cc2c3b..c94d0df414c9 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 622f897917f5..000000000000 --- 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 000000000000..6c6db5fe70cd --- /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 88f66193f9f3..000000000000 --- 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 2bcf9cdcca0f..000000000000 --- 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 10d8dff2deaa..000000000000 --- 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 62287c1bf118..000000000000 --- 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 2a3d144bea07..000000000000 --- 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 349428301d81..000000000000 --- 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 fcd932b1faf4..000000000000 --- 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 47db1f9bde6a..7d873196b431 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'])) { @@ -38,14 +38,16 @@ class ContextualThemeHooks { if (isset($element) && is_array($element) && !empty($element['#contextual_links'])) { $variables['#cache']['contexts'][] = 'user.permissions'; if ($this->currentUser->hasPermission('access contextual links')) { - // Mark this element as potentially having contextual links attached to it. + // Mark this element as potentially having contextual links attached to + // it. $variables['attributes']['class'][] = 'contextual-region'; - // Renders a contextual links placeholder unconditionally, thus not breaking - // the render cache. Although the empty placeholder is rendered for all - // users, contextual_page_attachments() only adds the asset library for - // users with the 'access contextual links' permission, thus preventing - // unnecessary HTTP requests for users without that permission. + // Renders a contextual links placeholder unconditionally, thus not + // breaking the render cache. Although the empty placeholder is rendered + // for all users, contextual_page_attachments() only adds the asset + // library for users with the 'access contextual links' permission, thus + // preventing unnecessary HTTP requests for users without that + // permission. $variables['title_suffix']['contextual_links'] = [ '#type' => 'contextual_links_placeholder', '#id' => _contextual_links_to_id($element['#contextual_links']), diff --git a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php index 75e56b5f76b2..1d4fa243c492 100644 --- a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php +++ b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php @@ -73,47 +73,40 @@ class EditModeTest extends WebDriverTestBase { $page = $this->getSession()->getPage(); // Get the page twice to ensure edit mode remains enabled after a new page // request. - for ($page_get_count = 0; $page_get_count < 2; $page_get_count++) { - $this->drupalGet('user'); - $expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]')); - - // After the page loaded we need to additionally wait until the settings - // tray Ajax activity is done. - if ($page_get_count === 0) { - $web_assert->assertWaitOnAjaxRequest(); - } - - if ($page_get_count == 0) { - $unrestricted_tab_count = $this->getTabbableElementsCount(); - $this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count); - - // Enable edit mode. - // After the first page load the page will be in edit mode when loaded. - $this->pressToolbarEditButton(); - } - - $this->assertAnnounceEditMode(); - $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); - - // Disable edit mode. - $this->pressToolbarEditButton(); - $this->assertAnnounceLeaveEditMode(); - $this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount()); - // Enable edit mode again. - $this->pressToolbarEditButton(); - // Finally assert that the 'edit mode enabled' announcement is still - // correct after toggling the edit mode at least once. - $this->assertAnnounceEditMode(); - $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); - - // Test while Edit Mode is enabled it doesn't interfere with pages with - // no contextual links. - $this->drupalGet('admin/structure/block'); - $web_assert->elementContains('css', 'h1.page-title', 'Block layout'); - $this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]'))); - $this->assertGreaterThan(0, $this->getTabbableElementsCount()); - } - + $this->drupalGet('user'); + $expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]')); + + // After the page loaded we need to additionally wait until the settings + // tray Ajax activity is done. + $web_assert->assertWaitOnAjaxRequest(); + + $unrestricted_tab_count = $this->getTabbableElementsCount(); + $this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count); + + // Enable edit mode. + // After the first page load the page will be in edit mode when loaded. + $this->pressToolbarEditButton(); + + $this->assertAnnounceEditMode(); + $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); + + // Disable edit mode. + $this->pressToolbarEditButton(); + $this->assertAnnounceLeaveEditMode(); + $this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount()); + // Enable edit mode again. + $this->pressToolbarEditButton(); + // Finally assert that the 'edit mode enabled' announcement is still + // correct after toggling the edit mode at least once. + $this->assertAnnounceEditMode(); + $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount()); + + // Test while Edit Mode is enabled it doesn't interfere with pages with + // no contextual links. + $this->drupalGet('admin/structure/block'); + $web_assert->elementContains('css', 'h1.page-title', 'Block layout'); + $this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]'))); + $this->assertGreaterThan(0, $this->getTabbableElementsCount()); } /** diff --git a/core/modules/datetime/datetime.module b/core/modules/datetime/datetime.module index 94cfaf6e9063..55a65f84dae7 100644 --- a/core/modules/datetime/datetime.module +++ b/core/modules/datetime/datetime.module @@ -17,7 +17,9 @@ use Drupal\field\FieldStorageConfigInterface; * @param \Drupal\field\FieldStorageConfigInterface $field_storage * The field storage config entity. * @param array $data - * Field view data or FieldViewsDataProvider::defaultFieldImplementation($field_storage) if empty. + * Field view data or + * FieldViewsDataProvider::defaultFieldImplementation($field_storage) if + * empty. * @param string $column_name * The schema column name with the datetime value. * @@ -29,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/DateTimeViewsHelper.php b/core/modules/datetime/src/DateTimeViewsHelper.php index 61d959e70efd..5e2f49b9e1d8 100644 --- a/core/modules/datetime/src/DateTimeViewsHelper.php +++ b/core/modules/datetime/src/DateTimeViewsHelper.php @@ -29,7 +29,8 @@ class DateTimeViewsHelper { * @param \Drupal\field\FieldStorageConfigInterface $field_storage * The field storage config entity. * @param array $data - * Field view data or views_field_default_views_data($field_storage) if empty. + * Field view data or views_field_default_views_data($field_storage) if + * empty. * @param string $column_name * The schema column name with the datetime value. * diff --git a/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php b/core/modules/datetime/src/Plugin/Field/FieldWidget/DateTimeDefaultWidget.php index 9055d44982bf..4fc259a760a1 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 f2c2578f3207..d6dee40b55ed 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 04c6f4eaf1c2..b2f90d662e9a 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/dblog/config/optional/views.view.watchdog.yml b/core/modules/dblog/config/optional/views.view.watchdog.yml index 3050881f0de7..224519da1e5c 100644 --- a/core/modules/dblog/config/optional/views.view.watchdog.yml +++ b/core/modules/dblog/config/optional/views.view.watchdog.yml @@ -654,6 +654,7 @@ display: empty_table: false caption: '' description: '' + class: '' row: type: fields query: diff --git a/core/modules/dblog/tests/src/Functional/DbLogTest.php b/core/modules/dblog/tests/src/Functional/DbLogTest.php index 95c463924433..d1a09aed265d 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 639aa030618a..12ec751f41c0 100644 --- a/core/modules/editor/tests/src/Functional/EditorAdminTest.php +++ b/core/modules/editor/tests/src/Functional/EditorAdminTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\editor\Functional; -use Drupal\Component\Render\FormattableMarkup; +use Drupal\Component\Utility\Html; use Drupal\filter\Entity\FilterFormat; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; @@ -142,7 +142,7 @@ class EditorAdminTest extends BrowserTestBase { $this->drupalLogin($account); // The node edit page header. - $text = (string) new FormattableMarkup('<em>Edit @type</em> @title', ['@type' => $node_type->label(), '@title' => $node->label()]); + $text = sprintf('<em>Edit %s</em> %s', Html::escape($node_type->label()), Html::escape($node->label())); // Go to node edit form. $this->drupalGet('node/' . $node->id() . '/edit'); diff --git a/core/modules/field/field.module b/core/modules/field/field.module index 1819df65669d..ee5db3615262 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 aa759522f458..e94e084ea3e3 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')] @@ -313,8 +317,8 @@ class FieldHooks { /** * Implements hook_ENTITY_TYPE_update() for 'field_storage_config'. * - * Reset the field handler settings, when the storage target_type is changed on - * an entity reference field. + * Reset the field handler settings, when the storage target_type is changed + * on an entity reference field. */ #[Hook('field_storage_config_update')] public function fieldStorageConfigUpdate(FieldStorageConfigInterface $field_storage): void { @@ -330,13 +334,14 @@ class FieldHooks { if ($class !== $item_class && !is_subclass_of($class, $item_class)) { return; } - // If target_type changed, reset the handler in the fields using that storage. + // If target_type changed, reset the handler in the fields using that + // storage. if ($field_storage->getSetting('target_type') !== $field_storage->getOriginal()->getSetting('target_type')) { foreach ($field_storage->getBundles() as $bundle) { $field = FieldConfig::loadByName($field_storage->getTargetEntityTypeId(), $bundle, $field_storage->getName()); - // Reset the handler settings. This triggers field_field_config_presave(), - // which will take care of reassigning the handler to the correct - // derivative for the new target_type. + // Reset the handler settings. This triggers + // field_field_config_presave(), which will take care of reassigning the + // handler to the correct derivative for the new target_type. $field->setSetting('handler_settings', []); $field->save(); } @@ -394,9 +399,9 @@ class FieldHooks { return; } // In case we removed all the target bundles allowed by the field in - // EntityReferenceItem::onDependencyRemoval() or field_entity_bundle_delete() - // we have to log a critical message because the field will not function - // correctly anymore. + // EntityReferenceItem::onDependencyRemoval() or + // field_entity_bundle_delete() we have to log a critical message because + // the field will not function correctly anymore. $handler_settings = $field->getSetting('handler_settings'); if (isset($handler_settings['target_bundles']) && $handler_settings['target_bundles'] === []) { \Drupal::logger('entity_reference')->critical('The %field_name entity reference field (entity_type: %entity_type, bundle: %bundle) no longer has any valid bundle it can reference. The field is not working correctly anymore and has to be adjusted.', [ 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 369575020135..dd9a4167ba53 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 d8496e30e99b..407fdd794a45 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 de14164bd807..48a5c652c8c8 100644 --- a/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php +++ b/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Drupal\Tests\field\Functional\FunctionalString; -use Drupal\Component\Render\FormattableMarkup; use Drupal\entity_test\Entity\EntityTest; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; @@ -95,7 +94,7 @@ class StringFieldTest extends BrowserTestBase { $this->drupalGet('entity_test/add'); $this->assertSession()->fieldValueEquals("{$field_name}[0][value]", ''); $this->assertSession()->fieldNotExists("{$field_name}[0][format]"); - $this->assertSession()->responseContains(new FormattableMarkup('placeholder="A placeholder on @widget_type"', ['@widget_type' => $widget_type])); + $this->assertSession()->responseContains('placeholder="A placeholder on ' . $widget_type . '"'); // Submit with some value. $value = $this->randomMachineName(); diff --git a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php index 129f28576d61..76907277eae1 100644 --- a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php +++ b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Drupal\Tests\field\FunctionalJavascript\EntityReference; use Behat\Mink\Element\NodeElement; -use Drupal\Component\Render\FormattableMarkup; use Drupal\Core\Url; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait; @@ -317,10 +316,9 @@ class EntityReferenceAdminTest extends WebDriverTestBase { // Try to select the views handler. $this->drupalGet($bundle_path . '/fields/' . $field_name); $page->findField('settings[handler]')->setValue('views'); - $views_text = (string) new FormattableMarkup('No eligible views were found. <a href=":create">Create a view</a> with an <em>Entity Reference</em> display, or add such a display to an <a href=":existing">existing view</a>.', [ - ':create' => Url::fromRoute('views_ui.add')->toString(), - ':existing' => Url::fromRoute('entity.view.collection')->toString(), - ]); + $create = Url::fromRoute('views_ui.add')->toString(); + $existing = Url::fromRoute('entity.view.collection')->toString(); + $views_text = 'No eligible views were found. <a href="' . $create . '">Create a view</a> with an <em>Entity Reference</em> display, or add such a display to an <a href="' . $existing . '">existing view</a>.'; $assert_session->waitForElement('xpath', '//a[contains(text(), "Create a view")]'); $assert_session->responseContains($views_text); diff --git a/core/modules/field/tests/src/Kernel/FieldCrudTest.php b/core/modules/field/tests/src/Kernel/FieldCrudTest.php index 278f8dbc87c3..bf1e0cf5c575 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 f845abeba457..849dd2402125 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 ca81ab17602b..20ea2c954b2c 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/src/Entity/FieldLayoutEntityDisplayTrait.php b/core/modules/field_layout/src/Entity/FieldLayoutEntityDisplayTrait.php index 652b72b49ba0..c2fddcda697f 100644 --- a/core/modules/field_layout/src/Entity/FieldLayoutEntityDisplayTrait.php +++ b/core/modules/field_layout/src/Entity/FieldLayoutEntityDisplayTrait.php @@ -70,7 +70,7 @@ trait FieldLayoutEntityDisplayTrait { } /** - * Implements \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface::setLayout(). + * {@inheritdoc} */ public function setLayout(LayoutInterface $layout) { $this->setLayoutId($layout->getPluginId(), $layout->getConfiguration()); @@ -78,7 +78,7 @@ trait FieldLayoutEntityDisplayTrait { } /** - * Implements \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface::getLayout(). + * {@inheritdoc} */ public function getLayout() { return $this->doGetLayout($this->getLayoutId(), $this->getLayoutSettings()); 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 d12e6cc8a302..5b84207b5f0c 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 b136e0f4b3cc..fc27ca1a253e 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 8904dd925e57..67c049fa5057 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 bd18b66a07c5..9259f341c95a 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 305f8039d70a..a188af5d92d6 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/src/Form/FieldConfigEditForm.php b/core/modules/field_ui/src/Form/FieldConfigEditForm.php index 04e6ebb92f8f..d723d93fa900 100644 --- a/core/modules/field_ui/src/Form/FieldConfigEditForm.php +++ b/core/modules/field_ui/src/Form/FieldConfigEditForm.php @@ -530,7 +530,8 @@ class FieldConfigEditForm extends EntityForm { * The parent entity that the field is attached to. * * @return \Drupal\Core\TypedData\TypedDataInterface - * The typed data object representing the field configuration and its default value. + * The typed data object representing the field configuration and its + * default value. */ private function getTypedData(FieldConfigInterface $field_config, FieldableEntityInterface $parent): TypedDataInterface { // Make sure that typed data manager is re-generating the instance. This diff --git a/core/modules/field_ui/src/Form/FieldStorageConfigEditForm.php b/core/modules/field_ui/src/Form/FieldStorageConfigEditForm.php index 4e9aafe8dbc6..2dd200cfe3ef 100644 --- a/core/modules/field_ui/src/Form/FieldStorageConfigEditForm.php +++ b/core/modules/field_ui/src/Form/FieldStorageConfigEditForm.php @@ -263,7 +263,8 @@ class FieldStorageConfigEditForm extends EntityForm { * returns that cardinality or NULL if no cardinality has been enforced. * * @return int|null - * The enforced cardinality as an integer, or NULL if no cardinality is enforced. + * The enforced cardinality as an integer, or NULL if no cardinality is + * enforced. */ protected function getEnforcedCardinality() { /** @var \Drupal\Core\Field\FieldTypePluginManager $field_type_manager */ diff --git a/core/modules/field_ui/src/Hook/FieldUiHooks.php b/core/modules/field_ui/src/Hook/FieldUiHooks.php index 06434e2ec4df..374cb331fc57 100644 --- a/core/modules/field_ui/src/Hook/FieldUiHooks.php +++ b/core/modules/field_ui/src/Hook/FieldUiHooks.php @@ -238,8 +238,8 @@ class FieldUiHooks { #[Hook('form_field_ui_field_storage_add_form_alter')] public function formFieldUiFieldStorageAddFormAlter(array &$form) : void { $optgroup = (string) $this->t('Reference'); - // Move the "Entity reference" option to the end of the list and rename it to - // "Other". + // Move the "Entity reference" option to the end of the list and rename it + // to "Other". unset($form['add']['new_storage_type']['#options'][$optgroup]['entity_reference']); $form['add']['new_storage_type']['#options'][$optgroup]['entity_reference'] = $this->t('Other…'); } @@ -249,7 +249,7 @@ class FieldUiHooks { * * Adds a button 'Save and manage fields' to forms. * - * @see \Drupal\node\NodeTypeForm + * @see \Drupal\node\Form\NodeTypeForm * @see \Drupal\comment\CommentTypeForm * @see \Drupal\media\MediaTypeForm * @see \Drupal\block_content\BlockContentTypeForm diff --git a/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTestBase.php b/core/modules/field_ui/tests/src/Functional/ManageFieldsFunctionalTestBase.php index 360e1f8ffa66..c8c99edf4f8d 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/ManageFieldsTest.php b/core/modules/field_ui/tests/src/FunctionalJavascript/ManageFieldsTest.php index c469e83ec915..8ca2dbb9d837 100644 --- a/core/modules/field_ui/tests/src/FunctionalJavascript/ManageFieldsTest.php +++ b/core/modules/field_ui/tests/src/FunctionalJavascript/ManageFieldsTest.php @@ -37,21 +37,6 @@ class ManageFieldsTest extends WebDriverTestBase { protected $defaultTheme = 'stark'; /** - * @var string - */ - protected $type; - - /** - * @var string - */ - - protected $type2; - /** - * @var \Drupal\Core\Entity\entityTypeManagerInterface - */ - protected $entityTypeManager; - - /** * {@inheritdoc} */ protected function setUp(): void { @@ -68,19 +53,16 @@ class ManageFieldsTest extends WebDriverTestBase { ]); $this->drupalLogin($admin_user); - $type = $this->drupalCreateContentType([ + $this->drupalCreateContentType([ 'name' => 'Article', 'type' => 'article', ]); - $this->type = $type->id(); - $type2 = $this->drupalCreateContentType([ + $this->drupalCreateContentType([ 'name' => 'Basic Page', 'type' => 'page', ]); - $this->type2 = $type2->id(); - $this->entityTypeManager = $this->container->get('entity_type.manager'); $this->getSession()->resizeWindow(1100, 800); } diff --git a/core/modules/file/config/optional/views.view.files.yml b/core/modules/file/config/optional/views.view.files.yml index be8ba838966e..c24c710714ad 100644 --- a/core/modules/file/config/optional/views.view.files.yml +++ b/core/modules/file/config/optional/views.view.files.yml @@ -761,6 +761,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields query: @@ -1152,6 +1153,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields options: { } diff --git a/core/modules/file/file.module b/core/modules/file/file.module index 5dd741f0c432..295c35998e44 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 f09740667e32..2e9be38dacb6 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 a38e64ab53cc..bb4268ff389e 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 1491510fd648..4bdcf5455f9d 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 d021a538f44c..902c675cdcf4 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/modules/file_test_views/config/optional/views.view.test_file_user_file_data.yml b/core/modules/file/tests/modules/file_test_views/config/optional/views.view.test_file_user_file_data.yml index 7924edd65726..78fd4196bf70 100644 --- a/core/modules/file/tests/modules/file_test_views/config/optional/views.view.test_file_user_file_data.yml +++ b/core/modules/file/tests/modules/file_test_views/config/optional/views.view.test_file_user_file_data.yml @@ -28,6 +28,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/file/tests/src/Functional/SaveUploadFormTest.php b/core/modules/file/tests/src/Functional/SaveUploadFormTest.php index 7830090636c4..3db3b0b06d66 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 66cfe08cad38..cb5bf4f09b00 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/Kernel/FileItemTest.php b/core/modules/file/tests/src/Kernel/FileItemTest.php index 09a28b68f0f1..c01cf28a1151 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/filter.module b/core/modules/filter/filter.module index 89b8e2e1e6f9..38da47a437ed 100644 --- a/core/modules/filter/filter.module +++ b/core/modules/filter/filter.module @@ -637,7 +637,7 @@ function _filter_autop($text) { // and comments. We don't apply any processing to the contents of these tags // to avoid messing up code. We look for matched pairs and allow basic // nesting. For example: - // "processed <pre> ignored <script> ignored </script> ignored </pre> processed" + // "processed<pre>ignored<script>ignored</script>ignored</pre>processed" $chunks = preg_split('@(<!--.*?-->|</?(?:pre|script|style|object|iframe|drupal-media|svg|!--)[^>]*>)@i', $text, -1, PREG_SPLIT_DELIM_CAPTURE); // Note: PHP ensures the array consists of alternating delimiters and literals // and begins and ends with a literal (inserting NULL as required). diff --git a/core/modules/filter/src/Element/TextFormat.php b/core/modules/filter/src/Element/TextFormat.php index 38ca133e3cd2..6b1fc25c3ffb 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/Entity/FilterFormat.php b/core/modules/filter/src/Entity/FilterFormat.php index 12e7846163b6..8b785122dc49 100644 --- a/core/modules/filter/src/Entity/FilterFormat.php +++ b/core/modules/filter/src/Entity/FilterFormat.php @@ -99,7 +99,7 @@ class FilterFormat extends ConfigEntityBase implements FilterFormatInterface, En protected $weight = 0; /** - * List of user role IDs to grant access to use this format on initial creation. + * List of role IDs to grant access to use this format on initial creation. * * This property is always empty and unused for existing text formats. * diff --git a/core/modules/filter/src/FilterFormatInterface.php b/core/modules/filter/src/FilterFormatInterface.php index 62a704a6fcbe..8c63f33f0a6b 100644 --- a/core/modules/filter/src/FilterFormatInterface.php +++ b/core/modules/filter/src/FilterFormatInterface.php @@ -10,7 +10,7 @@ use Drupal\Core\Config\Entity\ConfigEntityInterface; interface FilterFormatInterface extends ConfigEntityInterface { /** - * Returns the ordered collection of filter plugin instances or an individual plugin instance. + * Returns a sorted collection of filter plugins or an individual instance. * * @param string $instance_id * (optional) The ID of a filter plugin instance to return. 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 e1ddf5521877..862239599b0e 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 fc2e61bbaafc..9178166597cb 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 e41ad66503dc..b8a77a914f6b 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 f54e15e882a5..abe16ebdb48a 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 953f2aa2ce4f..9c53a2e0cf32 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 1e1820766081..13e6bdffda16 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 e0b8394552e3..1e327eea8e57 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 f096610c6593..d7ea09a67894 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 c03c60e00e27..c2d7a4e5042b 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 8573ae26346f..b62e05f3e386 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 f805caa378ca..68edccf507ab 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/image.install b/core/modules/image/image.install index b99e17924ef6..be8fd0272343 100644 --- a/core/modules/image/image.install +++ b/core/modules/image/image.install @@ -33,44 +33,6 @@ function image_uninstall(): void { } /** - * Implements hook_requirements(). - */ -function image_requirements($phase): array { - if ($phase != 'runtime') { - return []; - } - - $toolkit = \Drupal::service('image.toolkit.manager')->getDefaultToolkit(); - if ($toolkit) { - $plugin_definition = $toolkit->getPluginDefinition(); - $requirements = [ - 'image.toolkit' => [ - 'title' => t('Image toolkit'), - 'value' => $toolkit->getPluginId(), - 'description' => $plugin_definition['title'], - ], - ]; - - foreach ($toolkit->getRequirements() as $key => $requirement) { - $namespaced_key = 'image.toolkit.' . $toolkit->getPluginId() . '.' . $key; - $requirements[$namespaced_key] = $requirement; - } - } - else { - $requirements = [ - 'image.toolkit' => [ - 'title' => t('Image toolkit'), - 'value' => t('None'), - 'description' => 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, - ], - ]; - } - - return $requirements; -} - -/** * Implements hook_update_last_removed(). */ function image_update_last_removed(): int { diff --git a/core/modules/image/image.routing.yml b/core/modules/image/image.routing.yml index 612ee40dedb2..667182a593ef 100644 --- a/core/modules/image/image.routing.yml +++ b/core/modules/image/image.routing.yml @@ -53,6 +53,8 @@ image.style_private: required_derivative_scheme: 'private' requirements: _access: 'TRUE' + options: + no_cache: TRUE image.effect_add_form: path: '/admin/config/media/image-styles/manage/{image_style}/add/{image_effect}' diff --git a/core/modules/image/image.services.yml b/core/modules/image/image.services.yml index eda90083af1b..3e9d53f9763d 100644 --- a/core/modules/image/image.services.yml +++ b/core/modules/image/image.services.yml @@ -9,9 +9,3 @@ services: plugin.manager.image.effect: class: Drupal\image\ImageEffectManager parent: default_plugin_manager - image.page_cache_response_policy.deny_private_image_style_download: - class: Drupal\image\PageCache\DenyPrivateImageStyleDownload - arguments: ['@current_route_match'] - public: false - tags: - - { name: page_cache_response_policy } diff --git a/core/modules/image/src/Hook/ImageRequirements.php b/core/modules/image/src/Hook/ImageRequirements.php new file mode 100644 index 000000000000..e1018cf539be --- /dev/null +++ b/core/modules/image/src/Hook/ImageRequirements.php @@ -0,0 +1,58 @@ +<?php + +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; + +/** + * Requirements for the Image module. + */ +class ImageRequirements { + + use StringTranslationTrait; + + public function __construct( + protected readonly ImageToolkitManager $imageToolkitManager, + ) {} + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $toolkit = $this->imageToolkitManager->getDefaultToolkit(); + if ($toolkit) { + $plugin_definition = $toolkit->getPluginDefinition(); + $requirements = [ + 'image.toolkit' => [ + 'title' => $this->t('Image toolkit'), + 'value' => $toolkit->getPluginId(), + 'description' => $plugin_definition['title'], + ], + ]; + + foreach ($toolkit->getRequirements() as $key => $requirement) { + $namespaced_key = 'image.toolkit.' . $toolkit->getPluginId() . '.' . $key; + $requirements[$namespaced_key] = $requirement; + } + } + else { + $requirements = [ + 'image.toolkit' => [ + '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' => RequirementSeverity::Error, + ], + ]; + } + + return $requirements; + } + +} diff --git a/core/modules/image/src/ImageEffectBase.php b/core/modules/image/src/ImageEffectBase.php index 58be370c1e6e..745976133be7 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/PageCache/DenyPrivateImageStyleDownload.php b/core/modules/image/src/PageCache/DenyPrivateImageStyleDownload.php deleted file mode 100644 index 6fe9679a1979..000000000000 --- a/core/modules/image/src/PageCache/DenyPrivateImageStyleDownload.php +++ /dev/null @@ -1,44 +0,0 @@ -<?php - -namespace Drupal\image\PageCache; - -use Drupal\Core\PageCache\ResponsePolicyInterface; -use Drupal\Core\Routing\RouteMatchInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; - -/** - * Cache policy for image preview page. - * - * This policy rule denies caching of responses generated by the - * entity.image.preview route. - */ -class DenyPrivateImageStyleDownload implements ResponsePolicyInterface { - - /** - * The current route match. - * - * @var \Drupal\Core\Routing\RouteMatchInterface - */ - protected $routeMatch; - - /** - * Constructs a deny image preview page cache policy. - * - * @param \Drupal\Core\Routing\RouteMatchInterface $route_match - * The current route match. - */ - public function __construct(RouteMatchInterface $route_match) { - $this->routeMatch = $route_match; - } - - /** - * {@inheritdoc} - */ - public function check(Response $response, Request $request) { - if ($this->routeMatch->getRouteName() === 'image.style_private') { - return static::DENY; - } - } - -} diff --git a/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php b/core/modules/image/src/Plugin/Field/FieldType/ImageItem.php index 7ed6b0d3371c..72937d4e79a3 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/Field/FieldWidget/ImageWidget.php b/core/modules/image/src/Plugin/Field/FieldWidget/ImageWidget.php index 4830686472ce..9d58ad40cc70 100644 --- a/core/modules/image/src/Plugin/Field/FieldWidget/ImageWidget.php +++ b/core/modules/image/src/Plugin/Field/FieldWidget/ImageWidget.php @@ -108,9 +108,7 @@ class ImageWidget extends FileWidget { } /** - * Overrides \Drupal\file\Plugin\Field\FieldWidget\FileWidget::formMultipleElements(). - * - * Special handling for draggable multiple widgets and 'add more' button. + * {@inheritdoc} */ protected function formMultipleElements(FieldItemListInterface $items, array &$form, FormStateInterface $form_state) { $elements = parent::formMultipleElements($items, $form, $form_state); 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 000000000000..595743eece7a --- /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/modules/image_test_views/test_views/views.view.test_image_user_image_data.yml b/core/modules/image/tests/modules/image_test_views/test_views/views.view.test_image_user_image_data.yml index cd3cf68914c4..97b13e5332e3 100644 --- a/core/modules/image/tests/modules/image_test_views/test_views/views.view.test_image_user_image_data.yml +++ b/core/modules/image/tests/modules/image_test_views/test_views/views.view.test_image_user_image_data.yml @@ -28,6 +28,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php index 1e5c75339225..54130e7818b6 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 0598b25e9db8..55f2503686d4 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 d247642b3cd4..8b877753fefd 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/image/tests/src/Unit/PageCache/DenyPrivateImageStyleDownloadTest.php b/core/modules/image/tests/src/Unit/PageCache/DenyPrivateImageStyleDownloadTest.php deleted file mode 100644 index c4de0dae8235..000000000000 --- a/core/modules/image/tests/src/Unit/PageCache/DenyPrivateImageStyleDownloadTest.php +++ /dev/null @@ -1,92 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\image\Unit\PageCache; - -use Drupal\Core\PageCache\ResponsePolicyInterface; -use Drupal\image\PageCache\DenyPrivateImageStyleDownload; -use Drupal\Tests\UnitTestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; - -/** - * @coversDefaultClass \Drupal\image\PageCache\DenyPrivateImageStyleDownload - * @group image - */ -class DenyPrivateImageStyleDownloadTest extends UnitTestCase { - - /** - * The response policy under test. - * - * @var \Drupal\image\PageCache\DenyPrivateImageStyleDownload - */ - protected $policy; - - /** - * A request object. - * - * @var \Symfony\Component\HttpFoundation\Request - */ - protected $request; - - /** - * A response object. - * - * @var \Symfony\Component\HttpFoundation\Response - */ - protected $response; - - /** - * The current route match. - * - * @var \Drupal\Core\Routing\RouteMatch|\PHPUnit\Framework\MockObject\MockObject - */ - protected $routeMatch; - - /** - * {@inheritdoc} - */ - protected function setUp(): void { - parent::setUp(); - - $this->routeMatch = $this->createMock('Drupal\Core\Routing\RouteMatchInterface'); - $this->policy = new DenyPrivateImageStyleDownload($this->routeMatch); - $this->response = new Response(); - $this->request = new Request(); - } - - /** - * Asserts that caching is denied on the private image style download route. - * - * @dataProvider providerPrivateImageStyleDownloadPolicy - * @covers ::check - */ - public function testPrivateImageStyleDownloadPolicy($expected_result, $route_name): void { - $this->routeMatch->expects($this->once()) - ->method('getRouteName') - ->willReturn($route_name); - - $actual_result = $this->policy->check($this->response, $this->request); - $this->assertSame($expected_result, $actual_result); - } - - /** - * Provides data and expected results for the test method. - * - * @return array - * Data and expected results. - */ - public static function providerPrivateImageStyleDownloadPolicy() { - return [ - [ResponsePolicyInterface::DENY, 'image.style_private'], - [NULL, 'some.other.route'], - [NULL, NULL], - [NULL, FALSE], - [NULL, TRUE], - [NULL, new \stdClass()], - [NULL, [1, 2, 3]], - ]; - } - -} diff --git a/core/modules/jsonapi/jsonapi.api.php b/core/modules/jsonapi/jsonapi.api.php index 5b2f2002d25c..ca8c5ae993fb 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.install b/core/modules/jsonapi/jsonapi.install index c5a60c424fbe..47efd893ae0e 100644 --- a/core/modules/jsonapi/jsonapi.install +++ b/core/modules/jsonapi/jsonapi.install @@ -5,8 +5,6 @@ * Module install file. */ -use Drupal\Core\Url; - /** * Implements hook_install(). */ @@ -28,55 +26,6 @@ function jsonapi_install(): void { } /** - * Implements hook_requirements(). - */ -function jsonapi_requirements($phase): array { - $requirements = []; - if ($phase === 'runtime') { - $module_handler = \Drupal::moduleHandler(); - $potential_conflicts = [ - 'content_translation', - 'config_translation', - 'language', - ]; - $should_warn = array_reduce($potential_conflicts, function ($should_warn, $module_name) use ($module_handler) { - return $should_warn ?: $module_handler->moduleExists($module_name); - }, FALSE); - if ($should_warn) { - $requirements['jsonapi_multilingual_support'] = [ - 'title' => t('JSON:API multilingual support'), - 'value' => t('Limited'), - 'severity' => REQUIREMENT_INFO, - 'description' => 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/8/modules/jsonapi/translations', - ]), - ]; - } - $requirements['jsonapi_revision_support'] = [ - 'title' => t('JSON:API revision support'), - 'value' => t('Limited'), - 'severity' => REQUIREMENT_INFO, - 'description' => 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/8/modules/jsonapi/revisions', - ]), - ]; - $requirements['jsonapi_read_only_mode'] = [ - 'title' => t('JSON:API allowed operations'), - 'value' => t('Read-only'), - 'severity' => REQUIREMENT_INFO, - ]; - if (!\Drupal::configFactory()->get('jsonapi.settings')->get('read_only')) { - $requirements['jsonapi_read_only_mode']['value'] = t('All (create, read, update, delete)'); - $requirements['jsonapi_read_only_mode']['description'] = t('It is recommended to <a href=":configure-url">configure</a> JSON:API to only accept all operations if the site requires it. <a href=":docs">Learn more about securing your site with JSON:API.</a>', [ - ':docs' => 'https://www.drupal.org/docs/8/modules/jsonapi/security-considerations', - ':configure-url' => Url::fromRoute('jsonapi.settings')->toString(), - ]); - } - } - return $requirements; -} - -/** * Implements hook_update_last_removed(). */ function jsonapi_update_last_removed(): int { diff --git a/core/modules/jsonapi/jsonapi.module b/core/modules/jsonapi/jsonapi.module index 69414af650b9..c512575305a4 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 d59dca4ec03a..2888fbbec77c 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 6416d6cb27ac..53822cdf1f1f 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 7db75a3297be..d5f6d2540fcd 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(), ]; } @@ -218,10 +219,10 @@ class JsonapiHooks { public function jsonapiShortcutFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\shortcut\ShortcutAccessControlHandler::checkAccess() // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for - // (shortcut_set = $shortcut_set_storage->getDisplayedToUser($current_user)), + // "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 new file mode 100644 index 000000000000..4903389fddfd --- /dev/null +++ b/core/modules/jsonapi/src/Hook/JsonapiRequirements.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +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; + +/** + * Requirements for the JSON:API module. + */ +class JsonapiRequirements { + + use StringTranslationTrait; + + public function __construct( + protected readonly ConfigFactoryInterface $configFactory, + protected readonly ModuleHandlerInterface $moduleHandler, + ) {} + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + $potential_conflicts = [ + 'content_translation', + 'config_translation', + 'language', + ]; + $should_warn = array_reduce($potential_conflicts, function ($should_warn, $module_name) { + return $should_warn ?: $this->moduleHandler->moduleExists($module_name); + }, FALSE); + if ($should_warn) { + $requirements['jsonapi_multilingual_support'] = [ + 'title' => $this->t('JSON:API multilingual support'), + 'value' => $this->t('Limited'), + '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', + ]), + ]; + } + $requirements['jsonapi_revision_support'] = [ + 'title' => $this->t('JSON:API revision support'), + 'value' => $this->t('Limited'), + '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', + ]), + ]; + $requirements['jsonapi_read_only_mode'] = [ + 'title' => $this->t('JSON:API allowed operations'), + 'value' => $this->t('Read-only'), + '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)'); + $requirements['jsonapi_read_only_mode']['description'] = $this->t('It is recommended to <a href=":configure-url">configure</a> JSON:API to only accept all operations if the site requires it. <a href=":docs">Learn more about securing your site with JSON:API.</a>', [ + ':docs' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/security-considerations', + ':configure-url' => Url::fromRoute('jsonapi.settings')->toString(), + ]); + } + return $requirements; + } + +} diff --git a/core/modules/jsonapi/src/JsonApiFilter.php b/core/modules/jsonapi/src/JsonApiFilter.php new file mode 100644 index 000000000000..c9ac90be7af7 --- /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/src/Normalizer/Value/TemporaryArrayObjectThrowingExceptions.php b/core/modules/jsonapi/src/Normalizer/Value/TemporaryArrayObjectThrowingExceptions.php index d6e766416b0b..0f3c74f1ef53 100644 --- a/core/modules/jsonapi/src/Normalizer/Value/TemporaryArrayObjectThrowingExceptions.php +++ b/core/modules/jsonapi/src/Normalizer/Value/TemporaryArrayObjectThrowingExceptions.php @@ -97,7 +97,7 @@ class TemporaryArrayObjectThrowingExceptions extends \ArrayObject { } /** - * Gets the class name of the array iterator that is used by \ArrayObject::getIterator(). + * Gets the class name of the iterator used by \ArrayObject::getIterator(). * * @throws \Exception * This class does not support this action but it must implement it, because 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 000000000000..9d082c7649c3 --- /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 000000000000..f9aa3e77bbe9 --- /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 22f8f7f57d1b..7539670e1555 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 000000000000..110e1a6840b3 --- /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 97eec557d22d..48cbc20067a9 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/language/language.permissions.yml b/core/modules/language/language.permissions.yml index 85c3b9199447..81762ae88739 100644 --- a/core/modules/language/language.permissions.yml +++ b/core/modules/language/language.permissions.yml @@ -1,2 +1,3 @@ administer languages: title: 'Administer languages' + restrict access: true diff --git a/core/modules/language/src/Form/NegotiationBrowserDeleteForm.php b/core/modules/language/src/Form/NegotiationBrowserDeleteForm.php index fc87845eb610..28c63580ba70 100644 --- a/core/modules/language/src/Form/NegotiationBrowserDeleteForm.php +++ b/core/modules/language/src/Form/NegotiationBrowserDeleteForm.php @@ -8,7 +8,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; /** - * Defines a confirmation form for deleting a browser language negotiation mapping. + * The confirmation form for deleting a browser language negotiation mapping. * * @internal */ diff --git a/core/modules/language/src/LanguageNegotiatorInterface.php b/core/modules/language/src/LanguageNegotiatorInterface.php index 6893639889cd..b0ca173d3dbc 100644 --- a/core/modules/language/src/LanguageNegotiatorInterface.php +++ b/core/modules/language/src/LanguageNegotiatorInterface.php @@ -165,7 +165,7 @@ interface LanguageNegotiatorInterface { public function getPrimaryNegotiationMethod($type); /** - * Checks whether a language negotiation method is enabled for a language type. + * Checks if a language negotiation method is enabled for a language type. * * @param string $method_id * The language negotiation method ID. 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 54fdf4b0d21d..fe5ff0ca0627 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 7ddb9543e007..06b73b42992d 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 1caa028602f5..0b90c63ce615 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 a63de90a0dd6..55083338bded 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/Access/LayoutBuilderAccessCheck.php b/core/modules/layout_builder/src/Access/LayoutBuilderAccessCheck.php index 7bbb4f514718..193674e5a03c 100644 --- a/core/modules/layout_builder/src/Access/LayoutBuilderAccessCheck.php +++ b/core/modules/layout_builder/src/Access/LayoutBuilderAccessCheck.php @@ -54,7 +54,8 @@ class LayoutBuilderAccessCheck implements AccessInterface { $access = $access->andIf(AccessResult::allowedIfHasPermission($account, 'configure any layout')); } - // Disables access to inline blocks add_block routes if the section storage opts out. + // Disables access to inline blocks add_block routes if the section storage + // opts out. // Check if inline block access should be disabled. if ($operation === 'add_block' && !($section_storage->getPluginDefinition()->get('allow_inline_blocks') ?? TRUE)) { $route_name = $this->route_match->getRouteName(); diff --git a/core/modules/layout_builder/src/Element/LayoutBuilder.php b/core/modules/layout_builder/src/Element/LayoutBuilder.php index d673d24d1d38..2b128f931887 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 786fa3d786af..162928bb0918 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 16bda99a45cd..7a832ad284c6 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 2c66d43b8200..14686b9f532b 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/src/Plugin/ConfigAction/AddComponent.php b/core/modules/layout_builder/src/Plugin/ConfigAction/AddComponent.php new file mode 100644 index 000000000000..57781f55afe6 --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/ConfigAction/AddComponent.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder\Plugin\ConfigAction; + +use Drupal\Component\Uuid\UuidInterface; +use Drupal\Core\Config\Action\Attribute\ConfigAction; +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Config\Action\ConfigActionPluginInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\layout_builder\Plugin\ConfigAction\Deriver\AddComponentDeriver; +use Drupal\Core\Config\ConfigManagerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\layout_builder\SectionComponent; +use Drupal\layout_builder\SectionListInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Adds a component to a layout builder section. + * + * An example of using this in a recipe's config actions would be: + * @code + * dashboard.dashboard.welcome: + * addComponentToLayout: + * section: 0 + * position: 4 + * component: + * region: + * layout_twocol_section: 'second' + * default_region: content + * configuration: + * id: dashboard_text_block + * label: 'My new dashboard block' + * label_display: 'visible' + * provider: 'dashboard' + * context_mapping: { } + * text: + * value: '<p>My new block text</p>' + * format: 'basic_html' + * @endcode + * This will add a component to a layout region, given by the `section` index. + * The `position` will determine where it will be inserted, starting at 0. If is + * higher than the actual number of components in the region, it will be placed + * last. + * The `component` defines the actual component we are adding to the layout. + * Sections can have multiple regions. A `region` mapping will determine which + * region to use based on the id of the layout. If no matching is found, it will + * use the `default_region`. + * The `configuration` array will include the plugin configuration, including a + * mandatory `id` for the plugin ID. It should validate against the config + * schema of the plugin. + * The `additional` array will be copied as is, as that is ignored by config + * schema. + * + * @internal + * This API is experimental. + */ +#[ConfigAction( + id: 'add_layout_component', + admin_label: new TranslatableMarkup('Add component to layout'), + deriver: AddComponentDeriver::class, +)] +final class AddComponent implements ConfigActionPluginInterface, ContainerFactoryPluginInterface { + + public function __construct( + private readonly ConfigManagerInterface $configManager, + private readonly UuidInterface $uuidGenerator, + private readonly string $pluginId, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + assert(is_array($plugin_definition)); + return new static( + $container->get(ConfigManagerInterface::class), + $container->get(UuidInterface::class), + $plugin_id, + ); + } + + /** + * {@inheritdoc} + */ + public function apply(string $configName, mixed $value): void { + assert(is_array($value)); + $section_delta = $value['section']; + $position = $value['position']; + + assert(is_int($section_delta)); + assert(is_int($position)); + + $entity = $this->configManager->loadConfigEntityByName($configName); + if (!$entity instanceof SectionListInterface) { + throw new ConfigActionException("No entity found for applying the addComponentToLayout action."); + } + + $section = $entity->getSection($section_delta); + $component = $value['component']; + $region = $component['default_region'] ?? NULL; + if (array_key_exists('region', $component) && is_array($component['region'])) { + // Since the recipe author might not know ahead of time what layout the + // section is using, they should supply a map whose keys are layout IDs + // and values are region names, so we know where to place this component. + // If the section layout ID is not in the map, they should supply the + // name of a fallback region. If all that fails, give up with an + // exception. + $region = $component['region'][$section->getLayoutId()] ?? + $component['default_region'] ?? + throw new ConfigActionException("Cannot determine which region of the section to place this component into, because no default region was provided."); + } + if ($region === NULL) { + throw new ConfigActionException("Cannot determine which region of the section to place this component into, because no region was provided."); + } + if (!isset($value['component']['configuration']) || !isset($value['component']['configuration']['id'])) { + throw new ConfigActionException("Cannot determine the component configuration, or misses a plugin ID."); + } + // If no weight were set, there would be a warning. So we set a + // default, which will be overridden in insertComponent anyway. + // We also need to generate the UUID here, or it could be null. + $uuid = $component['uuid'] ?? $this->uuidGenerator->generate(); + $component = new SectionComponent($uuid, $region, $component['configuration'], $component['additional'] ?? []); + // If the position is higher than the number of components, just put it last + // instead of failing. + $position = min($position, count($section->getComponentsByRegion($region))); + $section->insertComponent($position, $component); + $entity->setSection($section_delta, $section); + $entity->save(); + } + +} diff --git a/core/modules/layout_builder/src/Plugin/ConfigAction/Deriver/AddComponentDeriver.php b/core/modules/layout_builder/src/Plugin/ConfigAction/Deriver/AddComponentDeriver.php new file mode 100644 index 000000000000..5cc1443b64df --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/ConfigAction/Deriver/AddComponentDeriver.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder\Plugin\ConfigAction\Deriver; + +use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Core\Config\Entity\ConfigEntityInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; +use Drupal\layout_builder\SectionListInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * @internal + * This API is experimental. + */ +final class AddComponentDeriver extends DeriverBase implements ContainerDeriverInterface { + + public function __construct( + private readonly EntityTypeManagerInterface $entityTypeManager, + ) {} + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id): static { + return new static( + $container->get(EntityTypeManagerInterface::class), + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition): array { + $entity_types = []; + foreach ($this->entityTypeManager->getDefinitions() as $entity_type) { + if ($entity_type->entityClassImplements(ConfigEntityInterface::class) && $entity_type->entityClassImplements(SectionListInterface::class)) { + $entity_types[] = $entity_type->id(); + } + } + $base_plugin_definition['entity_types'] = $entity_types; + $this->derivatives['addComponentToLayout'] = $base_plugin_definition; + return $this->derivatives; + } + +} diff --git a/core/modules/layout_builder/src/Plugin/Derivative/ExtraFieldBlockDeriver.php b/core/modules/layout_builder/src/Plugin/Derivative/ExtraFieldBlockDeriver.php index 087ef13d85e5..9a0dd6a9782c 100644 --- a/core/modules/layout_builder/src/Plugin/Derivative/ExtraFieldBlockDeriver.php +++ b/core/modules/layout_builder/src/Plugin/Derivative/ExtraFieldBlockDeriver.php @@ -146,7 +146,7 @@ class ExtraFieldBlockDeriver extends DeriverBase implements ContainerDeriverInte } /** - * Gets a list of entity type and bundle tuples that have layout builder enabled. + * Gets the list of entity type and bundle tuples with layout builder enabled. * * @return array * A structured array with entity type as first key, bundle as second. diff --git a/core/modules/layout_builder/src/SectionListTrait.php b/core/modules/layout_builder/src/SectionListTrait.php index 7a049dec6c40..625fb58dc233 100644 --- a/core/modules/layout_builder/src/SectionListTrait.php +++ b/core/modules/layout_builder/src/SectionListTrait.php @@ -38,9 +38,12 @@ trait SectionListTrait { */ public function getSection($delta) { if (!$this->hasSection($delta)) { - throw new \OutOfBoundsException(sprintf('Invalid delta "%s"', $delta)); + throw new \OutOfBoundsException(sprintf( + 'Invalid section delta "%s", there are %d sections.', + $delta, + $this->count() + )); } - return $this->getSections()[$delta]; } @@ -54,7 +57,7 @@ trait SectionListTrait { * * @return $this */ - protected function setSection($delta, Section $section) { + public function setSection($delta, Section $section) { $sections = $this->getSections(); $sections[$delta] = $section; $this->setSections($sections); diff --git a/core/modules/layout_builder/src/SectionStorageInterface.php b/core/modules/layout_builder/src/SectionStorageInterface.php index ffd559f63aa3..2c9293749649 100644 --- a/core/modules/layout_builder/src/SectionStorageInterface.php +++ b/core/modules/layout_builder/src/SectionStorageInterface.php @@ -136,7 +136,7 @@ interface SectionStorageInterface extends SectionListInterface, PluginInspection public function isApplicable(RefinableCacheableDependencyInterface $cacheability); /** - * Overrides \Drupal\Component\Plugin\PluginInspectionInterface::getPluginDefinition(). + * {@inheritdoc} * * @return \Drupal\layout_builder\SectionStorage\SectionStorageDefinition * The section storage definition. 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 000000000000..691e00a80c5a --- /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 000000000000..93bb03de4048 --- /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_defaults_test/config/schema/layout_builder_defaults_test.schema.yml b/core/modules/layout_builder/tests/modules/layout_builder_defaults_test/config/schema/layout_builder_defaults_test.schema.yml index cd7d15152603..804642885ef7 100644 --- a/core/modules/layout_builder/tests/modules/layout_builder_defaults_test/config/schema/layout_builder_defaults_test.schema.yml +++ b/core/modules/layout_builder/tests/modules/layout_builder_defaults_test/config/schema/layout_builder_defaults_test.schema.yml @@ -4,3 +4,11 @@ layout_builder.section.third_party.layout_builder_defaults_test: which_party: label: 'Which party?' type: string + +block.settings.my_plugin_id: + type: block_settings + label: 'My plugin ID for AddComponentTest' + mapping: + some_configuration: + type: string + label: 'Some configuration value' 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 d7dda399be39..000000000000 --- 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 000000000000..820630e2a4d5 --- /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 000000000000..8298a97d515a --- /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 397eedc8dad7..000000000000 --- 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 000000000000..9304876a9089 --- /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 000000000000..1464d4193332 --- /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 000000000000..e67249102b59 --- /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 5632c3fb8a9e..000000000000 --- 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 6f90fe628703..000000000000 --- 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 000000000000..3e087944e944 --- /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 94cb2455b5a4..75dac6efac20 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 b107ec4f9b41..d9423cd23e81 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/InlineBlockTestBase.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php index 6931a733d96d..bd46767863ad 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/LayoutBuilderDisableInteractionsTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php index 4067edd2616a..dc44888a8b20 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php @@ -7,7 +7,6 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript; use Behat\Mink\Element\NodeElement; use Drupal\block_content\Entity\BlockContent; use Drupal\block_content\Entity\BlockContentType; -use Drupal\Component\Render\FormattableMarkup; use Drupal\FunctionalJavascriptTests\JSWebAssert; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; @@ -190,7 +189,7 @@ class LayoutBuilderDisableInteractionsTest extends WebDriverTestBase { try { $element->click(); $tag_name = $element->getTagName(); - $this->fail(new FormattableMarkup("@tag_name was clickable when it shouldn't have been", ['@tag_name' => $tag_name])); + $this->fail("$tag_name was clickable when it shouldn't have been"); } catch (\Exception $e) { $this->assertTrue(JSWebAssert::isExceptionNotClickable($e)); diff --git a/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderBlockContentDependencyTest.php b/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderBlockContentDependencyTest.php new file mode 100644 index 000000000000..faa5d26bec92 --- /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/Kernel/Plugin/ConfigAction/AddComponentTest.php b/core/modules/layout_builder/tests/src/Kernel/Plugin/ConfigAction/AddComponentTest.php new file mode 100644 index 000000000000..e91daa37ef7c --- /dev/null +++ b/core/modules/layout_builder/tests/src/Kernel/Plugin/ConfigAction/AddComponentTest.php @@ -0,0 +1,401 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\layout_builder\Kernel\Plugin\ConfigAction; + +use Drupal\Core\Config\Action\ConfigActionException; +use Drupal\Core\Config\Action\ConfigActionManager; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\entity_test\EntityTestHelper; +use Drupal\KernelTests\KernelTestBase; +use Drupal\layout_builder\Plugin\SectionStorage\DefaultsSectionStorage; +use Drupal\layout_builder\Section; +use Drupal\layout_builder\SectionListInterface; +use Drupal\layout_builder\SectionStorage\SectionStorageManagerInterface; + +/** + * @coversDefaultClass \Drupal\layout_builder\Plugin\ConfigAction\AddComponent + * + * @group layout_builder + */ +class AddComponentTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'layout_discovery', + 'layout_builder', + 'layout_builder_defaults_test', + 'entity_test', + 'field', + 'system', + 'user', + ]; + + /** + * The plugin. + */ + private readonly DefaultsSectionStorage $plugin; + + /** + * The config action manager. + */ + private readonly ConfigActionManager $configActionManager; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + EntityTestHelper::createBundle('bundle_with_extra_fields'); + $this->installEntitySchema('entity_test'); + $this->installEntitySchema('user'); + $this->installConfig(['layout_builder_defaults_test']); + + $this->plugin = $this->container->get(SectionStorageManagerInterface::class)->createInstance('defaults'); + $this->configActionManager = $this->container->get('plugin.manager.config_action'); + + // Add some extra empty sections. + $view_display = $this->container->get(EntityTypeManagerInterface::class) + ->getStorage('entity_view_display') + ->load('entity_test.bundle_with_extra_fields.default'); + assert($view_display instanceof SectionListInterface); + $view_display->insertSection(1, new Section('layout_onecol')); + $view_display->insertSection(2, new Section('layout_threecol_25_50_25')); + $view_display->save(); + } + + /** + * Tests adding a component to a view display using a config action. + * + * @dataProvider provider + */ + public function testAddComponent(array $config_action_value, string $expected_region, int $added_component_expected_weight, int $existing_component_expected_weight, ?array $expected_error = NULL): void { + if ($expected_error !== NULL) { + $this->expectException($expected_error[0]); + $this->expectExceptionMessage($expected_error[1]); + } + $this->configActionManager->applyAction( + 'addComponentToLayout', + 'core.entity_view_display.entity_test.bundle_with_extra_fields.default', + $config_action_value, + ); + + $view_display = $this->container->get(EntityTypeManagerInterface::class) + ->getStorage('entity_view_display') + ->load('entity_test.bundle_with_extra_fields.default'); + $this->plugin->setContextValue('display', $view_display); + $components = $this->plugin->getSection(0)->getComponents(); + $uuid = end($components)->getUuid(); + + // If we pass the same existing UUID, we replace it. + $is_replacing = $added_component_expected_weight === $existing_component_expected_weight; + $expected_existing_plugin = $is_replacing ? 'my_plugin_id' : 'extra_field_block:entity_test:bundle_with_extra_fields:display_extra_field'; + $this->assertCount($is_replacing ? 1 : 2, $components); + $this->assertSame($expected_existing_plugin, $components['1445597a-c674-431d-ac0a-277d99347a7f']->getPluginId()); + $this->assertSame('my_plugin_id', $components[$uuid]->getPluginId()); + $this->assertSame($expected_region, $components[$uuid]->getRegion()); + $this->assertSame($added_component_expected_weight, $components[$uuid]->getWeight()); + // Assert weight of the existing component in the layout_twocol_section + // first region. + $this->assertSame($existing_component_expected_weight, $components['1445597a-c674-431d-ac0a-277d99347a7f']->getWeight()); + // Assert the component configuration (defined with its config schema), and the + // additional configuration (ignored in config schema) + $this->assertSame($config_action_value['component']['configuration'], $components[$uuid]->get('configuration')); + $this->assertSame($config_action_value['component']['additional'] ?? [], $components[$uuid]->get('additional')); + } + + /** + * Data provider for testAddComponent. + */ + public static function provider(): \Generator { + yield 'add component at first position of a non-empty region' => [ + [ + 'section' => 0, + 'position' => 0, + 'component' => [ + 'region' => [ + 'layout_test_plugin' => 'content', + 'layout_twocol_section' => 'first', + ], + 'default_region' => 'content', + 'configuration' => [ + 'id' => 'my_plugin_id', + ], + ], + ], + 'first', + 1, + 2, + ]; + yield 'edit existing component by giving the same uuid' => [ + [ + 'section' => 0, + 'position' => 0, + 'component' => [ + 'uuid' => '1445597a-c674-431d-ac0a-277d99347a7f', + 'region' => [ + 'layout_test_plugin' => 'content', + 'layout_twocol_section' => 'first', + ], + 'default_region' => 'content', + 'configuration' => [ + 'id' => 'my_plugin_id', + ], + ], + ], + 'first', + 1, + 1, + ]; + yield 'add component at second position of a non-empty region' => [ + [ + 'section' => 0, + 'position' => 1, + 'component' => [ + 'region' => [ + 'layout_test_plugin' => 'content', + 'layout_twocol_section' => 'first', + ], + 'default_region' => 'content', + 'configuration' => [ + 'id' => 'my_plugin_id', + 'some_configuration' => 'my_configuration_value', + ], + 'additional' => [ + 'some_additional_value' => 'my_custom_value', + ], + ], + ], + 'first', + 2, + 1, + ]; + yield 'add component at a position larger than the region size on an empty region' => [ + [ + 'section' => 0, + 'position' => 4, + 'component' => [ + 'region' => [ + 'layout_test_plugin' => 'content', + 'layout_twocol_section' => 'second', + ], + 'default_region' => 'content', + 'configuration' => [ + 'id' => 'my_plugin_id', + 'some_configuration' => 'my_configuration_value', + ], + 'additional' => [ + 'some_additional_value' => 'my_custom_value', + ], + ], + ], + 'second', + // As there is no other block in that section's region, weight is 0 no matter + // of the 4th position we asked for. + 0, + 1, + ]; + yield 'add component at a region not defined in the mapping' => [ + [ + 'section' => 0, + 'position' => 4, + 'component' => [ + 'region' => [ + 'layout_test_plugin' => 'content', + ], + 'default_region' => 'second', + 'configuration' => [ + 'id' => 'my_plugin_id', + ], + ], + ], + // Assigned to the default region, as no mapping matched. + 'second', + 0, + 1, + ]; + yield 'add component at a region defined in the mapping while no default region exist' => [ + [ + 'section' => 0, + 'position' => 4, + 'component' => [ + 'region' => [ + 'layout_twocol_section' => 'second', + ], + 'configuration' => [ + 'id' => 'my_plugin_id', + ], + ], + ], + // Assigned to the matching region, even if no default_region. + 'second', + 0, + 1, + ]; + yield 'add component with only default_region and no region mapping' => [ + [ + 'section' => 0, + 'position' => 4, + 'component' => [ + 'default_region' => 'second', + 'configuration' => [ + 'id' => 'my_plugin_id', + ], + ], + ], + // Assigned to the default region, even with no mapping. + 'second', + 0, + 1, + ]; + yield 'exception when cannot determine a region with mapping and default' => [ + [ + 'section' => 0, + 'position' => 4, + 'component' => [ + 'region' => [ + 'layout_test_plugin' => 'content', + ], + 'configuration' => [ + 'id' => 'my_plugin_id', + ], + ], + ], + 'second', + 0, + 1, + // No default_region, no matching region, so we error. + [ + ConfigActionException::class, + 'Cannot determine which region of the section to place this component into, because no default region was provided.', + ], + yield 'exception when no region given' => [ + [ + 'section' => 0, + 'position' => 4, + 'component' => [ + 'configuration' => [ + 'id' => 'my_plugin_id', + ], + ], + ], + 'second', + 0, + 1, + // No default_region, no matching region, so we error. + [ + ConfigActionException::class, + 'Cannot determine which region of the section to place this component into, because no region was provided.', + ], + ], + yield 'exception when no configuration given' => [ + [ + 'section' => 0, + 'position' => 4, + 'component' => [ + 'region' => [ + 'layout_test_plugin' => 'content', + ], + 'default_region' => 'content', + ], + ], + 'second', + 0, + 1, + // No component configuration. + [ + ConfigActionException::class, + 'Cannot determine the component configuration, or misses a plugin ID.', + ], + ], + yield 'exception when no id in configuration is given' => [ + [ + 'section' => 0, + 'position' => 4, + 'component' => [ + 'region' => [ + 'layout_test_plugin' => 'content', + ], + 'default_region' => 'content', + 'configuration' => [ + 'no_id' => 'my_plugin_id', + ], + ], + ], + 'second', + 0, + 1, + // No component configuration id. + [ + ConfigActionException::class, + 'Cannot determine the component configuration, or misses a plugin ID.', + ], + ], + + ]; + } + + /** + * Tests that adding a component to another section works as expected. + */ + public function testAddComponentToEmptyRegionThatIsNotFirst(): void { + $this->configActionManager->applyAction( + 'addComponentToLayout', + 'core.entity_view_display.entity_test.bundle_with_extra_fields.default', + [ + 'section' => 2, + 'position' => 4, + 'component' => [ + 'region' => [ + 'layout_twocol_section' => 'second', + 'layout_threecol_25_50_25' => 'bottom', + ], + 'default_region' => 'content', + 'configuration' => [ + 'id' => 'my_plugin_id', + ], + ], + ]); + $view_display = $this->container->get(EntityTypeManagerInterface::class) + ->getStorage('entity_view_display') + ->load('entity_test.bundle_with_extra_fields.default'); + $this->plugin->setContextValue('display', $view_display); + + $this->assertCount(1, $this->plugin->getSection(0)->getComponents()); + $this->assertCount(0, $this->plugin->getSection(1)->getComponents()); + $this->assertCount(1, $this->plugin->getSection(2)->getComponents()); + + $components = $this->plugin->getSection(2)->getComponents(); + $uuid = end($components)->getUuid(); + + $this->assertSame('bottom', $components[$uuid]->getRegion()); + $this->assertSame(0, $components[$uuid]->getWeight()); + $this->assertSame(['id' => 'my_plugin_id'], $components[$uuid]->get('configuration')); + } + + /** + * Tests that applying the config action to a missing entity fails. + */ + public function testActionFailsIfEntityNotFound(): void { + $this->expectException(ConfigActionException::class); + $this->expectExceptionMessage('No entity found for applying the addComponentToLayout action.'); + $this->configActionManager->applyAction( + 'addComponentToLayout', + 'core.entity_view_display.entity_test.bundle_with_extra_fields.missing_view_mode', + [ + 'section' => 0, + 'position' => 4, + 'component' => [ + 'default_region' => 'content', + 'configuration' => [ + 'id' => 'my_plugin_id', + ], + ], + ]); + } + +} diff --git a/core/modules/layout_builder/tests/src/Kernel/SectionListTestBase.php b/core/modules/layout_builder/tests/src/Kernel/SectionListTestBase.php index 08d844b5e68e..30c3a1416e85 100644 --- a/core/modules/layout_builder/tests/src/Kernel/SectionListTestBase.php +++ b/core/modules/layout_builder/tests/src/Kernel/SectionListTestBase.php @@ -86,7 +86,7 @@ abstract class SectionListTestBase extends EntityKernelTestBase { */ public function testGetSectionInvalidDelta(): void { $this->expectException(\OutOfBoundsException::class); - $this->expectExceptionMessage('Invalid delta "2"'); + $this->expectExceptionMessage('Invalid section delta "2", there are 2 sections.'); $this->sectionList->getSection(2); } diff --git a/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php b/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php index 530feea461da..baa6a080aa5a 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/layout_discovery.install b/core/modules/layout_discovery/layout_discovery.install deleted file mode 100644 index 28222bc093ba..000000000000 --- a/core/modules/layout_discovery/layout_discovery.install +++ /dev/null @@ -1,22 +0,0 @@ -<?php - -/** - * @file - * Install, update, and uninstall functions for the Layout Discovery module. - */ - -/** - * Implements hook_requirements(). - */ -function layout_discovery_requirements($phase): array { - $requirements = []; - if ($phase === 'install') { - 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, - ]; - } - } - return $requirements; -} diff --git a/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php b/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php new file mode 100644 index 000000000000..fd056c383547 --- /dev/null +++ b/core/modules/layout_discovery/src/Install/Requirements/LayoutDiscoveryRequirements.php @@ -0,0 +1,29 @@ +<?php + +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. + */ +class LayoutDiscoveryRequirements implements InstallRequirementsInterface { + + /** + * {@inheritdoc} + */ + public static function getRequirements(): array { + $requirements = []; + 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' => 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 694fb6b36774..5c78abc23910 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/Kernel/LinkFormatterTest.php b/core/modules/link/tests/src/Kernel/LinkFormatterTest.php new file mode 100644 index 000000000000..8ebda26d9c58 --- /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 000000000000..cbf01a40f606 --- /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 0f204b6af2df..5de40ee764ca 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.compare.inc b/core/modules/locale/locale.compare.inc index 069f200942f5..d9ae64af89af 100644 --- a/core/modules/locale/locale.compare.inc +++ b/core/modules/locale/locale.compare.inc @@ -99,15 +99,15 @@ function locale_translation_project_list() { if (empty($projects)) { $projects = []; - $additional_whitelist = [ + $additional_allow_list = [ 'interface translation project', 'interface translation server pattern', ]; $module_data = _locale_translation_prepare_project_list(\Drupal::service('extension.list.module')->getList(), 'module'); $theme_data = _locale_translation_prepare_project_list(\Drupal::service('extension.list.theme')->reset()->getList(), 'theme'); $project_info = new ProjectInfo(); - $project_info->processInfoList($projects, $module_data, 'module', TRUE, $additional_whitelist); - $project_info->processInfoList($projects, $theme_data, 'theme', TRUE, $additional_whitelist); + $project_info->processInfoList($projects, $module_data, 'module', TRUE, $additional_allow_list); + $project_info->processInfoList($projects, $theme_data, 'theme', TRUE, $additional_allow_list); // Allow other modules to alter projects before fetching and comparing. \Drupal::moduleHandler()->alter('locale_translation_projects', $projects); diff --git a/core/modules/locale/locale.install b/core/modules/locale/locale.install index f583df4426eb..d3377f3773eb 100644 --- a/core/modules/locale/locale.install +++ b/core/modules/locale/locale.install @@ -8,72 +8,6 @@ use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Hook\Attribute\ProceduralHookScanStop; -use Drupal\Core\Link; -use Drupal\Core\Url; - -/** - * Implements hook_requirements(). - */ -function locale_requirements($phase): array { - $requirements = []; - if ($phase == 'runtime') { - $available_updates = []; - $untranslated = []; - $languages = locale_translatable_language_list(); - - if ($languages) { - // Determine the status of the translation updates per language. - $status = locale_translation_get_status(); - if ($status) { - foreach ($status as $project) { - foreach ($project as $langcode => $project_info) { - if (empty($project_info->type)) { - $untranslated[$langcode] = $languages[$langcode]->getName(); - } - elseif ($project_info->type == LOCALE_TRANSLATION_LOCAL || $project_info->type == LOCALE_TRANSLATION_REMOTE) { - $available_updates[$langcode] = $languages[$langcode]->getName(); - } - } - } - - if ($available_updates || $untranslated) { - if ($available_updates) { - $requirements['locale_translation'] = [ - 'title' => t('Translation update status'), - 'value' => Link::fromTextAndUrl(t('Updates available'), Url::fromRoute('locale.translate_status'))->toString(), - 'severity' => REQUIREMENT_WARNING, - 'description' => 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()]), - ]; - } - else { - $requirements['locale_translation'] = [ - 'title' => t('Translation update status'), - 'value' => t('Missing translations'), - 'severity' => REQUIREMENT_INFO, - 'description' => 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()]), - ]; - } - } - else { - $requirements['locale_translation'] = [ - 'title' => t('Translation update status'), - 'value' => t('Up to date'), - 'severity' => REQUIREMENT_OK, - ]; - } - } - else { - $requirements['locale_translation'] = [ - 'title' => t('Translation update status'), - 'value' => Link::fromTextAndUrl(t('Can not determine status'), Url::fromRoute('locale.translate_status'))->toString(), - 'severity' => REQUIREMENT_WARNING, - 'description' => 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()]), - ]; - } - } - } - return $requirements; -} /** * Implements hook_install(). @@ -87,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 new file mode 100644 index 000000000000..988c5fcbdd31 --- /dev/null +++ b/core/modules/locale/src/Hook/LocaleRequirements.php @@ -0,0 +1,83 @@ +<?php + +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; +use Drupal\Core\Url; + +/** + * Requirements for the Locale module. + */ +class LocaleRequirements { + + use StringTranslationTrait; + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + $available_updates = []; + $untranslated = []; + $languages = locale_translatable_language_list(); + + if ($languages) { + // Determine the status of the translation updates per language. + $status = locale_translation_get_status(); + if ($status) { + foreach ($status as $project) { + foreach ($project as $langcode => $project_info) { + if (empty($project_info->type)) { + $untranslated[$langcode] = $languages[$langcode]->getName(); + } + elseif ($project_info->type == LOCALE_TRANSLATION_LOCAL || $project_info->type == LOCALE_TRANSLATION_REMOTE) { + $available_updates[$langcode] = $languages[$langcode]->getName(); + } + } + } + + if ($available_updates || $untranslated) { + if ($available_updates) { + $requirements['locale_translation'] = [ + 'title' => $this->t('Translation update status'), + 'value' => Link::fromTextAndUrl($this->t('Updates available'), Url::fromRoute('locale.translate_status'))->toString(), + '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()]), + ]; + } + else { + $requirements['locale_translation'] = [ + 'title' => $this->t('Translation update status'), + 'value' => $this->t('Missing translations'), + '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()]), + ]; + } + } + else { + $requirements['locale_translation'] = [ + 'title' => $this->t('Translation update status'), + 'value' => $this->t('Up to date'), + 'severity' => RequirementSeverity::OK, + ]; + } + } + else { + $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' => 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()]), + ]; + } + } + return $requirements; + } + +} diff --git a/core/modules/locale/src/Hook/LocaleThemeHooks.php b/core/modules/locale/src/Hook/LocaleThemeHooks.php index d1e438f50ace..4ef5ca0b4989 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/LocaleConfigManager.php b/core/modules/locale/src/LocaleConfigManager.php index 6756e85aa41c..133bf7518e94 100644 --- a/core/modules/locale/src/LocaleConfigManager.php +++ b/core/modules/locale/src/LocaleConfigManager.php @@ -289,9 +289,9 @@ class LocaleConfigManager { * Gets configuration names associated with components. * * @param array $components - * (optional) An associative array containing component types as keys and lists - * of components as values. If not provided or is empty, the method returns all - * configuration names. + * (optional) An associative array containing component types as keys and + * lists of components as values. If not provided or is empty, the method + * returns all configuration names. * * @return array * Array of configuration object names. diff --git a/core/modules/locale/src/LocaleTranslation.php b/core/modules/locale/src/LocaleTranslation.php index a9ccc93626f0..99e852650ee6 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 7e7340cf1070..436d710d7bad 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/StreamWrapper/TranslationsStream.php b/core/modules/locale/src/StreamWrapper/TranslationsStream.php index 980c048e290b..40517477379c 100644 --- a/core/modules/locale/src/StreamWrapper/TranslationsStream.php +++ b/core/modules/locale/src/StreamWrapper/TranslationsStream.php @@ -44,6 +44,7 @@ class TranslationsStream extends LocalStream { } /** + * phpcs:ignore Drupal.Files.LineLength * Implements Drupal\Core\StreamWrapper\StreamWrapperInterface::getExternalUrl(). * * @throws \LogicException diff --git a/core/modules/locale/src/StringDatabaseStorage.php b/core/modules/locale/src/StringDatabaseStorage.php index f023d1968ae7..75c73411978d 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 fc81fdb19b52..000000000000 --- 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 000000000000..47249930c6e1 --- /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 f5f27b01a627..b007c7ab55b0 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 316384330f8b..b52d29348253 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 000000000000..40c9afb713ae --- /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 000000000000..d69c04a8461a --- /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 000000000000..6e1b22e33805 --- /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 000000000000..731d5b9bfeeb --- /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 000000000000..8294939c42f5 --- /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 000000000000..6f26f95ee81d --- /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 000000000000..cbe2e01e9b47 --- /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 000000000000..1d9dec1cd6bd --- /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 000000000000..a13c57e140a6 --- /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 000000000000..4ffc33dfe8ab --- /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 000000000000..e6c24144c701 --- /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 000000000000..318b60829db5 --- /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 000000000000..f686fe86cc3c --- /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/config/optional/views.view.media.yml b/core/modules/media/config/optional/views.view.media.yml index c7a32129b33e..329ac2433870 100644 --- a/core/modules/media/config/optional/views.view.media.yml +++ b/core/modules/media/config/optional/views.view.media.yml @@ -857,6 +857,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields query: diff --git a/core/modules/media/media.install b/core/modules/media/media.install index 48ed664fdef7..62b2eece6193 100644 --- a/core/modules/media/media.install +++ b/core/modules/media/media.install @@ -9,107 +9,9 @@ use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\FileExists; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Hook\Attribute\ProceduralHookScanStop; -use Drupal\Core\StringTranslation\TranslatableMarkup; -use Drupal\Core\Url; -use Drupal\image\Plugin\Field\FieldType\ImageItem; -use Drupal\media\Entity\MediaType; use Drupal\user\RoleInterface; /** - * Implements hook_requirements(). - */ -function media_requirements($phase): array { - $requirements = []; - if ($phase == 'install') { - $destination = 'public://media-icons/generic'; - \Drupal::service('file_system')->prepareDirectory($destination, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); - $is_writable = is_writable($destination); - $is_directory = is_dir($destination); - if (!$is_writable || !$is_directory) { - if (!$is_directory) { - $error = t('The directory %directory does not exist.', ['%directory' => $destination]); - } - else { - $error = t('The directory %directory is not writable.', ['%directory' => $destination]); - } - $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']); - if (!empty($error)) { - $description = $error . ' ' . $description; - $requirements['media']['description'] = $description; - $requirements['media']['severity'] = REQUIREMENT_ERROR; - } - } - } - elseif ($phase === 'runtime') { - $module_handler = \Drupal::service('module_handler'); - foreach (MediaType::loadMultiple() as $type) { - // Load the default display. - $display = \Drupal::service('entity_display.repository') - ->getViewDisplay('media', $type->id()); - - // Check for missing source field definition. - $source_field_definition = $type->getSource()->getSourceFieldDefinition($type); - if (empty($source_field_definition)) { - $requirements['media_missing_source_field_' . $type->id()] = [ - 'title' => t('Media'), - 'description' => t('The source field definition for the %type media type is missing.', - [ - '%type' => $type->label(), - ] - ), - 'severity' => REQUIREMENT_ERROR, - ]; - continue; - } - - // When a new media type with an image source is created we're - // configuring the default entity view display using the 'large' image - // style. Unfortunately, if a site builder has deleted the 'large' image - // style, we need some other image style to use, but at this point, we - // can't really know the site builder's intentions. So rather than do - // something surprising, we're leaving the embedded media without an - // image style and adding a warning that the site builder might want to - // add an image style. - // @see Drupal\media\Plugin\media\Source\Image::prepareViewDisplay - if (!is_a($source_field_definition->getItemDefinition()->getClass(), ImageItem::class, TRUE)) { - continue; - } - - $component = $display->getComponent($source_field_definition->getName()); - if (empty($component) || $component['type'] !== 'image' || !empty($component['settings']['image_style'])) { - continue; - } - - $action_item = ''; - if ($module_handler->moduleExists('field_ui') && \Drupal::currentUser()->hasPermission('administer media display')) { - $url = Url::fromRoute('entity.entity_view_display.media.default', [ - 'media_type' => $type->id(), - ])->toString(); - $action_item = new TranslatableMarkup('If you would like to change this, <a href=":display">add an image style to the %field_name field</a>.', - [ - '%field_name' => $source_field_definition->label(), - ':display' => $url, - ]); - } - $requirements['media_default_image_style_' . $type->id()] = [ - 'title' => t('Media'), - 'description' => new TranslatableMarkup('The default display for the %type media type is not currently using an image style on the %field_name field. Not using an image style can lead to much larger file downloads. @action_item', - [ - '%field_name' => $source_field_definition->label(), - '@action_item' => $action_item, - '%type' => $type->label(), - ] - ), - 'severity' => REQUIREMENT_WARNING, - ]; - } - - } - - return $requirements; -} - -/** * Implements hook_install(). */ #[ProceduralHookScanStop] diff --git a/core/modules/media/src/Hook/MediaHooks.php b/core/modules/media/src/Hook/MediaHooks.php index 9a80bfec1583..37f24658ce3b 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 new file mode 100644 index 000000000000..f431134b6f4b --- /dev/null +++ b/core/modules/media/src/Hook/MediaRequirementsHooks.php @@ -0,0 +1,99 @@ +<?php + +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; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Url; +use Drupal\image\Plugin\Field\FieldType\ImageItem; +use Drupal\media\Entity\MediaType; + +/** + * Requirements checks for Media module. + */ +class MediaRequirementsHooks { + + use StringTranslationTrait; + + public function __construct( + protected readonly AccountInterface $currentUser, + protected readonly ModuleHandlerInterface $moduleHandler, + protected readonly EntityDisplayRepositoryInterface $entityDisplayRepository, + ) {} + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + foreach (MediaType::loadMultiple() as $type) { + // Load the default display. + $display = $this->entityDisplayRepository->getViewDisplay('media', $type->id()); + + // Check for missing source field definition. + $source_field_definition = $type->getSource()->getSourceFieldDefinition($type); + if (empty($source_field_definition)) { + $requirements['media_missing_source_field_' . $type->id()] = [ + 'title' => $this->t('Media'), + 'description' => $this->t('The source field definition for the %type media type is missing.', + [ + '%type' => $type->label(), + ] + ), + 'severity' => RequirementSeverity::Error, + ]; + continue; + } + + // When a new media type with an image source is created we're + // configuring the default entity view display using the 'large' image + // style. Unfortunately, if a site builder has deleted the 'large' image + // style, we need some other image style to use, but at this point, we + // can't really know the site builder's intentions. So rather than do + // something surprising, we're leaving the embedded media without an + // image style and adding a warning that the site builder might want to + // add an image style. + // @see Drupal\media\Plugin\media\Source\Image::prepareViewDisplay + if (!is_a($source_field_definition->getItemDefinition()->getClass(), ImageItem::class, TRUE)) { + continue; + } + + $component = $display->getComponent($source_field_definition->getName()); + if (empty($component) || $component['type'] !== 'image' || !empty($component['settings']['image_style'])) { + continue; + } + + $action_item = ''; + if ($this->moduleHandler->moduleExists('field_ui') && $this->currentUser->hasPermission('administer media display')) { + $url = Url::fromRoute('entity.entity_view_display.media.default', [ + 'media_type' => $type->id(), + ])->toString(); + $action_item = new TranslatableMarkup('If you would like to change this, <a href=":display">add an image style to the %field_name field</a>.', + [ + '%field_name' => $source_field_definition->label(), + ':display' => $url, + ]); + } + $requirements['media_default_image_style_' . $type->id()] = [ + 'title' => $this->t('Media'), + 'description' => new TranslatableMarkup('The default display for the %type media type is not currently using an image style on the %field_name field. Not using an image style can lead to much larger file downloads. @action_item', + [ + '%field_name' => $source_field_definition->label(), + '@action_item' => $action_item, + '%type' => $type->label(), + ] + ), + 'severity' => RequirementSeverity::Warning, + ]; + } + + return $requirements; + } + +} diff --git a/core/modules/media/src/Install/Requirements/MediaRequirements.php b/core/modules/media/src/Install/Requirements/MediaRequirements.php new file mode 100644 index 000000000000..9fa100ab974d --- /dev/null +++ b/core/modules/media/src/Install/Requirements/MediaRequirements.php @@ -0,0 +1,40 @@ +<?php + +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; + +/** + * Install time requirements for the media module. + */ +class MediaRequirements implements InstallRequirementsInterface { + + /** + * {@inheritdoc} + */ + public static function getRequirements(): array { + $requirements = []; + $destination = 'public://media-icons/generic'; + \Drupal::service('file_system')->prepareDirectory($destination, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); + $is_writable = is_writable($destination); + $is_directory = is_dir($destination); + if (!$is_writable || !$is_directory) { + if (!$is_directory) { + $error = t('The directory %directory does not exist.', ['%directory' => $destination]); + } + else { + $error = t('The directory %directory is not writable.', ['%directory' => $destination]); + } + $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'] = RequirementSeverity::Error; + } + return $requirements; + } + +} diff --git a/core/modules/media/src/MediaSourceBase.php b/core/modules/media/src/MediaSourceBase.php index f7197963cca8..181ebed86253 100644 --- a/core/modules/media/src/MediaSourceBase.php +++ b/core/modules/media/src/MediaSourceBase.php @@ -321,7 +321,8 @@ abstract class MediaSourceBase extends PluginBase implements MediaSourceInterfac if ($tries) { $id .= '_' . $tries; - // Ensure the suffixed field name does not exceed the maximum allowed length. + // Ensure the suffixed field name does not exceed the maximum allowed + // length. if (strlen($id) > EntityTypeInterface::ID_MAX_LENGTH) { $id = substr($base_id, 0, (EntityTypeInterface::ID_MAX_LENGTH - strlen('_' . $tries))) . '_' . $tries; } diff --git a/core/modules/media/src/OEmbed/UrlResolver.php b/core/modules/media/src/OEmbed/UrlResolver.php index a672c5933d05..562dc430f772 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 5fa53b2b9e92..0230cc19b465 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 910dc4e94bea..4adc22db002e 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 abb19d895090..000000000000 --- 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 000000000000..ede5f6df253c --- /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 910318dd8681..000000000000 --- 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 000000000000..626d68f9812c --- /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/modules/media_test_views/config/install/views.view.test_media_bulk_form.yml b/core/modules/media/tests/modules/media_test_views/config/install/views.view.test_media_bulk_form.yml index 8f7f0c8493df..f194dfb53fa0 100644 --- a/core/modules/media/tests/modules/media_test_views/config/install/views.view.test_media_bulk_form.yml +++ b/core/modules/media/tests/modules/media_test_views/config/install/views.view.test_media_bulk_form.yml @@ -138,6 +138,8 @@ display: arguments: { } style: type: table + options: + class: '' row: type: fields relationships: { } diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterTestBase.php b/core/modules/media/tests/src/FunctionalJavascript/MediaEmbedFilterTestBase.php index 57d5278ee1d8..097ddb6b3102 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_library/config/install/image.style.media_library.yml b/core/modules/media_library/config/install/image.style.media_library.yml index 5da64cfdcc36..4383a8c2cbae 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/config/install/views.view.media_library.yml b/core/modules/media_library/config/install/views.view.media_library.yml index 5450c17c9379..70974811ee4f 100644 --- a/core/modules/media_library/config/install/views.view.media_library.yml +++ b/core/modules/media_library/config/install/views.view.media_library.yml @@ -1346,6 +1346,7 @@ display: options: row_class: 'media-library-item media-library-item--table js-media-library-item js-click-to-select' default_row_class: true + class: '' row: type: fields defaults: 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 3b36ee5e3773..33ffe1a39920 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/modules/media_library_test/src/Form/TestNodeFormOverride.php b/core/modules/media_library/tests/modules/media_library_test/src/Form/TestNodeFormOverride.php index 4dda6ff75444..50305332a25d 100644 --- a/core/modules/media_library/tests/modules/media_library_test/src/Form/TestNodeFormOverride.php +++ b/core/modules/media_library/tests/modules/media_library_test/src/Form/TestNodeFormOverride.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Drupal\media_library_test\Form; use Drupal\Core\Form\FormStateInterface; -use Drupal\node\NodeForm; +use Drupal\node\Form\NodeForm; /** * Override NodeForm to test media library form submission semantics. diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOEmbedTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOEmbedTest.php index 46277c473e6f..1d53128b7047 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOEmbedTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/WidgetOEmbedTest.php @@ -124,10 +124,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 +312,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/menu_link_content/tests/src/Kernel/MenuLinksTest.php b/core/modules/menu_link_content/tests/src/Kernel/MenuLinksTest.php index 34e0a0eee043..751b93dd92d5 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 9b32ffa18247..f36dcea82063 100644 --- a/core/modules/menu_ui/src/Hook/MenuUiHooks.php +++ b/core/modules/menu_ui/src/Hook/MenuUiHooks.php @@ -115,13 +115,14 @@ class MenuUiHooks { return $entity->access('update', NULL, TRUE)->andIf($entity->access('delete', NULL, TRUE)); } else { - // If the node has no corresponding menu link, users needs to permission to create one. + // If the node has no corresponding menu link, users needs to permission + // to create one. return $this->entityTypeManager->getAccessControlHandler('menu_link_content')->createAccess(NULL, NULL, [], TRUE); } } /** - * Implements hook_form_BASE_FORM_ID_alter() for \Drupal\node\NodeForm. + * Implements hook_form_BASE_FORM_ID_alter() for \Drupal\node\Form\NodeForm. * * Adds menu item fields to the node form. * @@ -231,7 +232,7 @@ class MenuUiHooks { } /** - * Implements hook_form_FORM_ID_alter() for \Drupal\node\NodeTypeForm. + * Implements hook_form_FORM_ID_alter() for \Drupal\node\Form\NodeTypeForm. * * Adds menu options to the node type form. * @@ -292,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/Kernel/MenuDeleteTest.php b/core/modules/menu_ui/tests/src/Kernel/MenuDeleteTest.php new file mode 100644 index 000000000000..3bb81874387c --- /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 1e58b0090ff2..5d2af7db180e 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 9adf60b46ffe..30cc28562e8d 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 961d7edd76b1..20c99bbd3e00 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 dc70496282f3..a5351c748620 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 3a1cb8a1b695..77c8b45d00f8 100644 --- a/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php +++ b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php @@ -609,15 +609,15 @@ abstract class SourcePluginBase extends PluginBase implements MigrateSourceInter * {@inheritdoc} */ public function preRollback(MigrateRollbackEvent $event) { - // Nothing to do in this implementation. + // Reset the high-water mark. + $this->saveHighWater(NULL); } /** * {@inheritdoc} */ public function postRollback(MigrateRollbackEvent $event) { - // Reset the high-water mark. - $this->saveHighWater(NULL); + // Nothing to do in this implementation. } /** diff --git a/core/modules/migrate/tests/src/Functional/MigrateMessageTestBase.php b/core/modules/migrate/tests/src/Functional/MigrateMessageTestBase.php index 6885ba378e9c..84dddc3c1825 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/MigrateSourceTestBase.php b/core/modules/migrate/tests/src/Kernel/MigrateSourceTestBase.php index 1999e3ad86f4..9e508da9b225 100644 --- a/core/modules/migrate/tests/src/Kernel/MigrateSourceTestBase.php +++ b/core/modules/migrate/tests/src/Kernel/MigrateSourceTestBase.php @@ -85,9 +85,9 @@ abstract class MigrateSourceTestBase extends KernelTestBase { * The fully qualified class name of the plugin to be tested. */ protected function getPluginClass() { - $covers = $this->getTestClassCovers(); - if (!empty($covers)) { - return $covers[0]; + $covers = $this->valueObjectForEvents()->metadata()->isCovers()->isClassLevel()->asArray(); + if (isset($covers[0])) { + return $covers[0]->target(); } else { $this->fail('No plugin class was specified'); diff --git a/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php b/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php index 2f0b85ffbc47..e344e3e23e84 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 ba9ab78cff66..ed223601abb3 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_drupal/src/Plugin/migrate/source/DrupalSqlBase.php b/core/modules/migrate_drupal/src/Plugin/migrate/source/DrupalSqlBase.php index 64ac2031a3fc..06d9bbccc974 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 0b83a099afb2..f99cd69e8983 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 ee9b6268ebbf..e2bcbddc8d3c 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 11323f4bf7a0..f9e6ef61d75c 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 56121db822ac..82d638fa3d8f 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 b1d409f4017a..1bb5b4f51bf5 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 1f54f94848ec..efe2b1509282 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 151b7fae2477..c7d40c4c2d9d 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 ca8a9a0d06b3..27ab60bc0c0c 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 61f77faa44ca..1348f7dc04b5 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 a28dada3e9cc..c6e76787f316 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 68071daba559..56d9e10a91f4 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 759f59b50a84..a6633fed5e9f 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 64dc7a1ea865..daf06a65468a 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 46b3447e159d..f9b702d22e3a 100644 --- a/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php +++ b/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php @@ -76,7 +76,7 @@ class Upgrade7Test extends MigrateUpgradeExecuteTestBase { */ protected function getEntityCounts(): array { return [ - 'block' => 27, + 'block' => 26, 'block_content' => 1, 'block_content_type' => 1, 'comment' => 4, diff --git a/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php b/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php index 7926175e0dcf..9f2a89ca6ea9 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 85e71af18d12..c3eb28584334 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; @@ -67,7 +67,7 @@ class Schema extends DatabaseSchema { } /** - * Build a condition to match a table name against a standard information_schema. + * Builds a condition to match a table name with the information schema. * * MySQL uses databases like schemas rather than catalogs so when we build a * condition to query the information_schema.tables, we set the default @@ -133,7 +133,7 @@ class Schema extends DatabaseSchema { } /** - * Create an SQL string for a field to be used in table creation or alteration. + * Creates an SQL string for a field used in table creation or alteration. * * @param string $name * Name of the field. @@ -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 new file mode 100644 index 000000000000..c3dfb10ca434 --- /dev/null +++ b/core/modules/mysql/src/Hook/MysqlRequirements.php @@ -0,0 +1,86 @@ +<?php + +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; + +/** + * Requirements for the MySQL module. + */ +class MysqlRequirements { + + use StringTranslationTrait; + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + // Test with MySql databases. + if (Database::isActiveConnection()) { + $connection = Database::getConnection(); + // Only show requirements when MySQL is the default database connection. + if (!($connection->driver() === 'mysql' && $connection->getProvider() === 'mysql')) { + 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/mysql/tests/src/Functional/RequirementsTest.php b/core/modules/mysql/tests/src/Functional/RequirementsTest.php index 5d054334b696..38617714bc8c 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 000000000000..38a9239f3e95 --- /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/mysql/mysql.install b/core/modules/mysqli/mysqli.install index 547f9cab1cd0..7f1147d63adb 100644 --- a/core/modules/mysql/mysql.install +++ b/core/modules/mysqli/mysqli.install @@ -2,24 +2,25 @@ /** * @file - * Install, update and uninstall functions for the mysql module. + * Install, update and uninstall functions for the mysqli module. */ use Drupal\Core\Database\Database; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Render\Markup; /** * Implements hook_requirements(). */ -function mysql_requirements($phase): array { +function mysqli_requirements($phase): array { $requirements = []; if ($phase === 'runtime') { // Test with MySql databases. if (Database::isActiveConnection()) { $connection = Database::getConnection(); - // Only show requirements when MySQL is the default database connection. - if (!($connection->driver() === 'mysql' && $connection->getProvider() === 'mysql')) { + // Only show requirements when MySQLi is the default database connection. + if (!($connection->driver() === 'mysqli' && $connection->getProvider() === 'mysqli')) { return []; } @@ -39,18 +40,18 @@ function mysql_requirements($phase): array { $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[] = t('This is not supported by Drupal.'); } $description[] = t('The recommended level for Drupal is "READ COMMITTED".'); diff --git a/core/modules/mysqli/mysqli.services.yml b/core/modules/mysqli/mysqli.services.yml new file mode 100644 index 000000000000..82a476ceb9e8 --- /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 000000000000..e41df23075a3 --- /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 000000000000..78e7a331f121 --- /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 000000000000..f27a083541e6 --- /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 000000000000..e6f2c86148c5 --- /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 000000000000..31386bc907bc --- /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 000000000000..2c5e57c3aa82 --- /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 000000000000..f3b4346992df --- /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 000000000000..90237fd6a43c --- /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 000000000000..5fae187d16c7 --- /dev/null +++ b/core/modules/mysqli/src/Hook/MysqliHooks.php @@ -0,0 +1,32 @@ +<?php + +namespace Drupal\mysqli\Hook; + +use Drupal\Core\Hook\Attribute\Hook; +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; + } + +} 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 000000000000..d1f1ca55f8f5 --- /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 000000000000..736381069606 --- /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 000000000000..c940eb919d33 --- /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 000000000000..42fa5d733dfd --- /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 000000000000..2e27fff09f5a --- /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 000000000000..ead54a27c012 --- /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 000000000000..894245826cb3 --- /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 000000000000..23fa565156fb --- /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 000000000000..7bbf1b85b391 --- /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 000000000000..7ccdcf1022f9 --- /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 000000000000..19539fa65877 --- /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 000000000000..60f6c27540dc --- /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 000000000000..a000a132e203 --- /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 a7bb04f963eb..a7940d1efa54 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 696960d455f5..1001c42c109f 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 87e29a14bf44..130cca22e1cb 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 3af3cbfdd39d..85bfb00883cf 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 9b40b8fcb460..0e1d5f3bdd57 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 91063f3f09d9..c56c7322b6f4 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 ae9655a7ad0e..844e0ba048a7 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 25fd9ca1cc02..8e8636d8a855 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 0b13b08252e1..55ffd803e693 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 f21bf044137a..eca3b63b27e4 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 6c462a08ba86..91cd88d0a5fc 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 33e2af3bcb9a..9b411c7cba75 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 7f2c98950cc1..60fc599d7122 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 11b50e9459be..8a852a0fc703 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 9689014aab43..9e0f401fb4a5 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 76f587a325e6..532e873d96e6 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 6a8b2a019c24..a12ebe955edb 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 65a489cd78b7..134b0a506c75 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 b55604dfa569..0a34a4b17dae 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 c9e2ecb9cae9..6c32da0b6f6c 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.install b/core/modules/navigation/navigation.install index e4280b472ac2..77390203123a 100644 --- a/core/modules/navigation/navigation.install +++ b/core/modules/navigation/navigation.install @@ -22,25 +22,6 @@ function navigation_install(bool $is_syncing): void { } /** - * Implements hook_requirements(). - */ -function navigation_requirements($phase): array { - $requirements = []; - - if ($phase === 'runtime') { - if (\Drupal::moduleHandler()->moduleExists('toolbar')) { - $requirements['toolbar'] = [ - 'title' => t('Toolbar and Navigation modules are both installed'), - 'value' => 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, - ]; - } - } - - return $requirements; -} - -/** * Reorganizes the values for the logo settings. */ function navigation_update_11001(array &$sandbox): void { diff --git a/core/modules/navigation/navigation.services.yml b/core/modules/navigation/navigation.services.yml index 88b6826409a2..925efe58a4e1 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 ed4eb8283668..540a2659af53 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 new file mode 100644 index 000000000000..f72877c04e43 --- /dev/null +++ b/core/modules/navigation/src/Hook/NavigationRequirements.php @@ -0,0 +1,39 @@ +<?php + +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; + +/** + * Requirements for the navigation module. + */ +class NavigationRequirements { + + use StringTranslationTrait; + + public function __construct( + protected readonly ModuleHandlerInterface $moduleHandler, + ) {} + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + if ($this->moduleHandler->moduleExists('toolbar')) { + $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' => 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 000000000000..75fefdf15417 --- /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 380cc8540aa3..8b75563b92f1 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/Plugin/TopBarItem/PageContext.php b/core/modules/navigation/src/Plugin/TopBarItem/PageContext.php index 76448d0f6a65..60c6dbeb4b83 100644 --- a/core/modules/navigation/src/Plugin/TopBarItem/PageContext.php +++ b/core/modules/navigation/src/Plugin/TopBarItem/PageContext.php @@ -85,24 +85,22 @@ class PageContext extends TopBarItemBase implements ContainerFactoryPluginInterf return $build; } - $build += [ - [ - '#type' => 'component', - '#component' => 'navigation:title', - '#props' => [ - 'icon' => 'database', - 'html_tag' => 'span', - 'modifiers' => ['ellipsis', 'xs'], - 'extra_classes' => ['top-bar__title'], - ], - '#slots' => [ - 'content' => $entity->label(), - ], + $build[] = [ + '#type' => 'component', + '#component' => 'navigation:title', + '#props' => [ + 'icon' => 'database', + 'html_tag' => 'span', + 'modifiers' => ['ellipsis', 'xs'], + 'extra_classes' => ['top-bar__title'], + ], + '#slots' => [ + 'content' => $entity->label(), ], ]; if ($label = $this->getBadgeLabel($entity)) { - $build += [ + $build[] = [ '#type' => 'component', '#component' => 'navigation:badge', '#props' => [ diff --git a/core/modules/navigation/src/WorkspacesLazyBuilder.php b/core/modules/navigation/src/WorkspacesLazyBuilder.php index 3134fa4745ed..146f7d83dc9b 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 6efdeed523f9..319f97f67478 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 3c7eb2fade87..000000000000 --- 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 000000000000..9020deed81d9 --- /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 000000000000..48de404b65ae --- /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/PerformanceTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php index 3264d769c195..5bf9d2477f09 100644 --- a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php +++ b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php @@ -73,14 +73,14 @@ class PerformanceTest extends PerformanceTestBase { $expected = [ 'QueryCount' => 4, - 'CacheGetCount' => 49, + 'CacheGetCount' => 47, 'CacheGetCountByBin' => [ 'config' => 11, 'data' => 4, 'discovery' => 10, 'bootstrap' => 6, 'dynamic_page_cache' => 1, - 'render' => 16, + 'render' => 14, 'menu' => 1, ], 'CacheSetCount' => 2, @@ -89,9 +89,9 @@ class PerformanceTest extends PerformanceTestBase { ], 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, - 'CacheTagLookupQueryCount' => 14, + 'CacheTagLookupQueryCount' => 13, 'ScriptCount' => 3, - 'ScriptBytes' => 215500, + 'ScriptBytes' => 167569, 'StylesheetCount' => 2, 'StylesheetBytes' => 46000, ]; diff --git a/core/modules/navigation/tests/src/Kernel/NavigationMenuBlockTest.php b/core/modules/navigation/tests/src/Kernel/NavigationMenuBlockTest.php index 496b7d1f7af5..d9cf1f70a090 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 000000000000..ce9acfacc514 --- /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 000000000000..257033b5feac --- /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/config/optional/views.view.content.yml b/core/modules/node/config/optional/views.view.content.yml index d0bbb09cf5a0..09ba3dcc35ae 100644 --- a/core/modules/node/config/optional/views.view.content.yml +++ b/core/modules/node/config/optional/views.view.content.yml @@ -625,6 +625,7 @@ display: empty_table: true caption: '' description: '' + class: '' row: type: fields query: diff --git a/core/modules/node/config/optional/views.view.glossary.yml b/core/modules/node/config/optional/views.view.glossary.yml index 2698473d5271..868d51fb9e26 100644 --- a/core/modules/node/config/optional/views.view.glossary.yml +++ b/core/modules/node/config/optional/views.view.glossary.yml @@ -343,6 +343,7 @@ display: summary: '' order: asc empty_table: false + class: '' row: type: fields options: diff --git a/core/modules/node/config/schema/node.schema.yml b/core/modules/node/config/schema/node.schema.yml index 08fe92cc401e..8c81f68ae604 100644 --- a/core/modules/node/config/schema/node.schema.yml +++ b/core/modules/node/config/schema/node.schema.yml @@ -24,7 +24,7 @@ node.type.*: label: 'Machine-readable name' constraints: # Node type machine names are specifically limited to 32 characters. - # @see \Drupal\node\NodeTypeForm::form() + # @see \Drupal\node\Form\NodeTypeForm::form() Length: max: 32 description: @@ -50,7 +50,7 @@ node.type.*: constraints: # These are the values of the DRUPAL_DISABLED, DRUPAL_OPTIONAL, and # DRUPAL_REQUIRED constants. - # @see \Drupal\node\NodeTypeForm::form() + # @see \Drupal\node\Form\NodeTypeForm::form() Choice: [0, 1, 2] display_submitted: type: boolean diff --git a/core/modules/node/js/node.preview.js b/core/modules/node/js/node.preview.js index 50bc58ade774..e23be0b71e22 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.install b/core/modules/node/node.install index fb5c14c47675..4e232bdc2de9 100644 --- a/core/modules/node/node.install +++ b/core/modules/node/node.install @@ -5,137 +5,10 @@ * Install, update and uninstall functions for the node module. */ -use Drupal\Core\Link; -use Drupal\Core\StringTranslation\PluralTranslatableMarkup; -use Drupal\Core\Url; use Drupal\Core\Database\Database; use Drupal\user\RoleInterface; /** - * Implements hook_requirements(). - */ -function node_requirements($phase): array { - $requirements = []; - if ($phase === 'runtime') { - // Only show rebuild button if there are either 0, or 2 or more, rows - // in the {node_access} table, or if there are modules that - // implement hook_node_grants(). - $grant_count = \Drupal::entityTypeManager()->getAccessControlHandler('node')->countGrants(); - $has_node_grants_implementations = \Drupal::moduleHandler()->hasImplementations('node_grants'); - if ($grant_count != 1 || $has_node_grants_implementations) { - $value = \Drupal::translation()->formatPlural($grant_count, 'One permission in use', '@count permissions in use', ['@count' => $grant_count]); - } - else { - $value = t('Disabled'); - } - - $requirements['node_access'] = [ - 'title' => t('Node Access Permissions'), - 'value' => $value, - 'description' => t('If the site is experiencing problems with permissions to content, you may have to rebuild the permissions cache. Rebuilding will remove all privileges to content and replace them with permissions based on the current modules and settings. Rebuilding may take some time if there is a lot of content or complex permission settings. After rebuilding has completed, content will automatically use the new permissions. <a href=":rebuild">Rebuild permissions</a>', [ - ':rebuild' => Url::fromRoute('node.configure_rebuild_confirm')->toString(), - ]), - ]; - - // Report when the "Published status or admin user" has no impact on the - // result of dependent views due to active node access modules. - // @see https://www.drupal.org/node/3472976 - if ($has_node_grants_implementations && \Drupal::moduleHandler()->moduleExists('views')) { - $node_status_filter_problematic_views = []; - $active_view_ids = \Drupal::entityQuery('view') - ->condition('status', TRUE) - ->accessCheck(FALSE) - ->execute(); - - $views_storage = \Drupal::entityTypeManager()->getStorage('view'); - foreach ($views_storage->loadMultiple($active_view_ids) as $view) { - foreach ($view->get('display') as $display_id => $display) { - if (array_key_exists('filters', $display['display_options'])) { - foreach ($display['display_options']['filters'] as $filter) { - if (array_key_exists('plugin_id', $filter) && $filter['plugin_id'] === 'node_status') { - $node_status_filter_problematic_views[$view->id()][$display_id] = [ - 'view_label' => $view->label(), - 'display_name' => $display['display_title'] ?? $display_id, - ]; - break; - } - } - } - } - } - - if ($node_status_filter_problematic_views !== []) { - $node_access_implementations = []; - $module_data = \Drupal::service('extension.list.module')->getAllInstalledInfo(); - foreach (['node_grants', 'node_grants_alter'] as $hook) { - \Drupal::moduleHandler()->invokeAllWith( - $hook, - static function (callable $hook, string $module) use (&$node_access_implementations, $module_data) { - $node_access_implementations[$module] = $module_data[$module]['name']; - } - ); - } - uasort($node_access_implementations, 'strnatcasecmp'); - $views_ui_enabled = \Drupal::moduleHandler()->moduleExists('views_ui'); - $node_status_filter_problematic_views_list = []; - foreach ($node_status_filter_problematic_views as $view_id => $displays) { - foreach ($displays as $display_id => $info) { - $text = "{$info['view_label']} ({$info['display_name']})"; - if ($views_ui_enabled) { - $url = Url::fromRoute('entity.view.edit_display_form', [ - 'view' => $view_id, - 'display_id' => $display_id, - ]); - if ($url->access()) { - $node_status_filter_problematic_views_list[] = Link::fromTextAndUrl($text, $url)->toString(); - } - else { - $node_status_filter_problematic_views_list[] = $text; - } - } - else { - $node_status_filter_problematic_views_list[] = $text; - } - } - } - - $node_status_filter_problematic_views_count = count($node_status_filter_problematic_views_list); - $node_status_filter_description_arguments = [ - '%modules' => implode(', ', $node_access_implementations), - '%status_filter' => t('Published status or admin user'), - ]; - - if ($node_status_filter_problematic_views_count > 1) { - $node_status_filter_problematic_views_list = [ - '#theme' => 'item_list', - '#items' => $node_status_filter_problematic_views_list, - ]; - $node_status_filter_description_arguments['@views'] = \Drupal::service('renderer')->renderInIsolation($node_status_filter_problematic_views_list); - } - else { - $node_status_filter_description_arguments['%view'] = reset($node_status_filter_problematic_views_list); - } - - $node_status_filter_description = new PluralTranslatableMarkup( - $node_status_filter_problematic_views_count, - 'The %view view uses the %status_filter filter but it has no effect because the following module(s) control access: %modules. Review and consider removing the filter.', - 'The following views use the %status_filter filter but it has no effect because the following module(s) control access: %modules. Review and consider removing the filter from these views: @views', - $node_status_filter_description_arguments, - ); - - $requirements['node_status_filter'] = [ - 'title' => t('Content status filter'), - 'value' => t('Redundant filters detected'), - 'description' => $node_status_filter_description, - 'severity' => REQUIREMENT_WARNING, - ]; - } - } - } - return $requirements; -} - -/** * Implements hook_schema(). */ function node_schema(): array { diff --git a/core/modules/node/node.module b/core/modules/node/node.module index 4ee3c48a00b2..d68a04fcbc8a 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(); } @@ -387,6 +395,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 +671,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/node.routing.yml b/core/modules/node/node.routing.yml index 4ceb843af183..aa53d913cf7e 100644 --- a/core/modules/node/node.routing.yml +++ b/core/modules/node/node.routing.yml @@ -45,6 +45,7 @@ entity.node.preview: requirements: _node_preview_access: '{node_preview}' options: + no_cache: TRUE parameters: node_preview: type: 'node_preview' diff --git a/core/modules/node/node.services.yml b/core/modules/node/node.services.yml index e5daad354296..83eb19f7355d 100644 --- a/core/modules/node/node.services.yml +++ b/core/modules/node/node.services.yml @@ -1,3 +1,16 @@ +parameters: + node.moved_classes: + 'Drupal\node\NodeForm': + class: 'Drupal\node\Form\NodeForm' + deprecation_version: drupal:11.2.0 + removed_version: drupal:12.0.0 + change_record: https://www.drupal.org/node/3517871 + 'Drupal\node\NodeTypeForm': + class: 'Drupal\node\Form\NodeTypeForm' + deprecation_version: drupal:11.2.0 + removed_version: drupal:12.0.0 + change_record: https://www.drupal.org/node/3517871 + services: _defaults: autoconfigure: true @@ -23,13 +36,6 @@ services: tags: - { name: paramconverter } lazy: true - node.page_cache_response_policy.deny_node_preview: - class: Drupal\node\PageCache\DenyNodePreview - arguments: ['@current_route_match'] - public: false - tags: - - { name: page_cache_response_policy } - - { name: dynamic_page_cache_response_policy } cache_context.user.node_grants: class: Drupal\node\Cache\NodeAccessGrantsCacheContext arguments: ['@current_user'] diff --git a/core/modules/node/src/Controller/NodeController.php b/core/modules/node/src/Controller/NodeController.php index e860d0c1d2a0..d5a35f64285a 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/Entity/Node.php b/core/modules/node/src/Entity/Node.php index f4c519a43c8a..0f61a74f1c01 100644 --- a/core/modules/node/src/Entity/Node.php +++ b/core/modules/node/src/Entity/Node.php @@ -11,8 +11,8 @@ use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\node\Form\DeleteMultiple; use Drupal\node\Form\NodeDeleteForm; +use Drupal\node\Form\NodeForm; use Drupal\node\NodeAccessControlHandler; -use Drupal\node\NodeForm; use Drupal\node\NodeInterface; use Drupal\node\NodeListBuilder; use Drupal\node\NodeStorage; diff --git a/core/modules/node/src/Entity/NodeType.php b/core/modules/node/src/Entity/NodeType.php index 35b911d7ffb7..48d295635f3d 100644 --- a/core/modules/node/src/Entity/NodeType.php +++ b/core/modules/node/src/Entity/NodeType.php @@ -9,7 +9,7 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\node\Form\NodeTypeDeleteConfirm; use Drupal\node\NodeTypeAccessControlHandler; -use Drupal\node\NodeTypeForm; +use Drupal\node\Form\NodeTypeForm; use Drupal\node\NodeTypeInterface; use Drupal\node\NodeTypeListBuilder; use Drupal\user\Entity\EntityPermissionsRouteProvider; diff --git a/core/modules/node/src/NodeForm.php b/core/modules/node/src/Form/NodeForm.php index ab81a4e3f928..295e9ab78ce0 100644 --- a/core/modules/node/src/NodeForm.php +++ b/core/modules/node/src/Form/NodeForm.php @@ -1,6 +1,6 @@ <?php -namespace Drupal\node; +namespace Drupal\node\Form; use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Datetime\DateFormatterInterface; @@ -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/NodeTypeForm.php b/core/modules/node/src/Form/NodeTypeForm.php index 3328ade970da..93d510d4387e 100644 --- a/core/modules/node/src/NodeTypeForm.php +++ b/core/modules/node/src/Form/NodeTypeForm.php @@ -1,6 +1,6 @@ <?php -namespace Drupal\node; +namespace Drupal\node\Form; use Drupal\Core\Entity\BundleEntityFormBase; use Drupal\Core\Entity\EntityFieldManagerInterface; diff --git a/core/modules/node/src/Hook/NodeHooks.php b/core/modules/node/src/Hook/NodeHooks.php index d5f84e0359ba..8a6b4d887c89 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 8e25f2eb066c..d2dbf545c3ca 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 new file mode 100644 index 000000000000..84f74aee98cf --- /dev/null +++ b/core/modules/node/src/Hook/NodeRequirements.php @@ -0,0 +1,155 @@ +<?php + +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; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Link; +use Drupal\Core\StringTranslation\PluralTranslatableMarkup; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\Core\Url; + +/** + * Requirements for the Node module. + */ +class NodeRequirements { + + use StringTranslationTrait; + + public function __construct( + protected readonly EntityTypeManagerInterface $entityTypeManager, + protected readonly ModuleHandlerInterface $moduleHandler, + protected readonly TranslationInterface $translation, + protected readonly ModuleExtensionList $moduleExtensionList, + ) {} + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + // Only show rebuild button if there are either 0, or 2 or more, rows + // in the {node_access} table, or if there are modules that + // implement hook_node_grants(). + $grant_count = $this->entityTypeManager->getAccessControlHandler('node')->countGrants(); + $has_node_grants_implementations = $this->moduleHandler->hasImplementations('node_grants'); + if ($grant_count != 1 || $has_node_grants_implementations) { + $value = $this->translation->formatPlural($grant_count, 'One permission in use', '@count permissions in use', ['@count' => $grant_count]); + } + else { + $value = $this->t('Disabled'); + } + + $requirements['node_access'] = [ + 'title' => $this->t('Node Access Permissions'), + 'value' => $value, + 'description' => $this->t('If the site is experiencing problems with permissions to content, you may have to rebuild the permissions cache. Rebuilding will remove all privileges to content and replace them with permissions based on the current modules and settings. Rebuilding may take some time if there is a lot of content or complex permission settings. After rebuilding has completed, content will automatically use the new permissions. <a href=":rebuild">Rebuild permissions</a>', [ + ':rebuild' => Url::fromRoute('node.configure_rebuild_confirm')->toString(), + ]), + ]; + + // Report when the "Published status or admin user" has no impact on the + // result of dependent views due to active node access modules. + // @see https://www.drupal.org/node/3472976 + if ($has_node_grants_implementations && $this->moduleHandler->moduleExists('views')) { + $node_status_filter_problematic_views = []; + $query = $this->entityTypeManager->getStorage('view')->getQuery(); + $query->condition('status', TRUE); + $query->accessCheck(FALSE); + $active_view_ids = $query->execute(); + + $views_storage = $this->entityTypeManager->getStorage('view'); + foreach ($views_storage->loadMultiple($active_view_ids) as $view) { + foreach ($view->get('display') as $display_id => $display) { + if (array_key_exists('filters', $display['display_options'])) { + foreach ($display['display_options']['filters'] as $filter) { + if (array_key_exists('plugin_id', $filter) && $filter['plugin_id'] === 'node_status') { + $node_status_filter_problematic_views[$view->id()][$display_id] = [ + 'view_label' => $view->label(), + 'display_name' => $display['display_title'] ?? $display_id, + ]; + break; + } + } + } + } + } + + if ($node_status_filter_problematic_views !== []) { + $node_access_implementations = []; + $module_data = $this->moduleExtensionList->getAllInstalledInfo(); + foreach (['node_grants', 'node_grants_alter'] as $hook) { + $this->moduleHandler->invokeAllWith( + $hook, + static function (callable $hook, string $module) use (&$node_access_implementations, $module_data) { + $node_access_implementations[$module] = $module_data[$module]['name']; + } + ); + } + uasort($node_access_implementations, 'strnatcasecmp'); + $views_ui_enabled = $this->moduleHandler->moduleExists('views_ui'); + $node_status_filter_problematic_views_list = []; + foreach ($node_status_filter_problematic_views as $view_id => $displays) { + foreach ($displays as $display_id => $info) { + $text = "{$info['view_label']} ({$info['display_name']})"; + if ($views_ui_enabled) { + $url = Url::fromRoute('entity.view.edit_display_form', [ + 'view' => $view_id, + 'display_id' => $display_id, + ]); + if ($url->access()) { + $node_status_filter_problematic_views_list[] = Link::fromTextAndUrl($text, $url)->toString(); + } + else { + $node_status_filter_problematic_views_list[] = $text; + } + } + else { + $node_status_filter_problematic_views_list[] = $text; + } + } + } + + $node_status_filter_problematic_views_count = count($node_status_filter_problematic_views_list); + $node_status_filter_description_arguments = [ + '%modules' => implode(', ', $node_access_implementations), + '%status_filter' => $this->t('Published status or admin user'), + ]; + + if ($node_status_filter_problematic_views_count > 1) { + $node_status_filter_problematic_views_list = [ + '#theme' => 'item_list', + '#items' => $node_status_filter_problematic_views_list, + ]; + $node_status_filter_description_arguments['@views'] = \Drupal::service('renderer')->renderInIsolation($node_status_filter_problematic_views_list); + } + else { + $node_status_filter_description_arguments['%view'] = reset($node_status_filter_problematic_views_list); + } + + $node_status_filter_description = new PluralTranslatableMarkup( + $node_status_filter_problematic_views_count, + 'The %view view uses the %status_filter filter but it has no effect because the following module(s) control access: %modules. Review and consider removing the filter.', + 'The following views use the %status_filter filter but it has no effect because the following module(s) control access: %modules. Review and consider removing the filter from these views: @views', + $node_status_filter_description_arguments, + ); + + $requirements['node_status_filter'] = [ + 'title' => $this->t('Content status filter'), + 'value' => $this->t('Redundant filters detected'), + 'description' => $node_status_filter_description, + 'severity' => RequirementSeverity::Warning, + ]; + } + } + return $requirements; + } + +} diff --git a/core/modules/node/src/Hook/NodeThemeHooks.php b/core/modules/node/src/Hook/NodeThemeHooks.php index 7ee443c458f8..7ed0ef91f5fd 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 963ab53ded41..7121f62e2837 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 588391394eec..0d67cfb7bd69 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 eea6cc100127..fbaab21a2119 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 cce744765630..d343a2f350b4 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 e913f5326f38..5f6518301929 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/PageCache/DenyNodePreview.php b/core/modules/node/src/PageCache/DenyNodePreview.php deleted file mode 100644 index 0325f52b2017..000000000000 --- a/core/modules/node/src/PageCache/DenyNodePreview.php +++ /dev/null @@ -1,44 +0,0 @@ -<?php - -namespace Drupal\node\PageCache; - -use Drupal\Core\PageCache\ResponsePolicyInterface; -use Drupal\Core\Routing\RouteMatchInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; - -/** - * Cache policy for node preview page. - * - * This policy rule denies caching of responses generated by the - * entity.node.preview route. - */ -class DenyNodePreview implements ResponsePolicyInterface { - - /** - * The current route match. - * - * @var \Drupal\Core\Routing\RouteMatchInterface - */ - protected $routeMatch; - - /** - * Constructs a deny node preview page cache policy. - * - * @param \Drupal\Core\Routing\RouteMatchInterface $route_match - * The current route match. - */ - public function __construct(RouteMatchInterface $route_match) { - $this->routeMatch = $route_match; - } - - /** - * {@inheritdoc} - */ - public function check(Response $response, Request $request) { - if ($this->routeMatch->getRouteName() === 'entity.node.preview') { - return static::DENY; - } - } - -} diff --git a/core/modules/node/src/Plugin/Block/SyndicateBlock.php b/core/modules/node/src/Plugin/Block/SyndicateBlock.php index b10c63527e5b..45cfe1eb45c6 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 000000000000..5cbf21d56d42 --- /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 982152080a6b..9be0cc9d7b6f 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 4934a2f2e635..4d579f687ce4 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 b7f186fa07d1..cf962a2897e0 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/src/Plugin/views/wizard/Node.php b/core/modules/node/src/Plugin/views/wizard/Node.php index 84a21c3f7f2e..d66fa956f9aa 100644 --- a/core/modules/node/src/Plugin/views/wizard/Node.php +++ b/core/modules/node/src/Plugin/views/wizard/Node.php @@ -89,11 +89,7 @@ class Node extends WizardPluginBase { } /** - * Overrides Drupal\views\Plugin\views\wizard\WizardPluginBase::getAvailableSorts(). - * - * @return array - * An array whose keys are the available sort options and whose - * corresponding values are human readable labels. + * {@inheritdoc} */ public function getAvailableSorts() { // You can't execute functions in properties, so override the method @@ -238,9 +234,7 @@ class Node extends WizardPluginBase { } /** - * Overrides Drupal\views\Plugin\views\wizard\WizardPluginBase::buildFilters(). - * - * Add some options for filter by taxonomy terms. + * {@inheritdoc} */ protected function buildFilters(&$form, FormStateInterface $form_state) { parent::buildFilters($form, $form_state); diff --git a/core/modules/node/src/Plugin/views/wizard/NodeRevision.php b/core/modules/node/src/Plugin/views/wizard/NodeRevision.php index c10389cc2392..96422504cf5e 100644 --- a/core/modules/node/src/Plugin/views/wizard/NodeRevision.php +++ b/core/modules/node/src/Plugin/views/wizard/NodeRevision.php @@ -29,11 +29,10 @@ class NodeRevision extends WizardPluginBase { protected $createdColumn = 'changed'; /** - * Overrides Drupal\views\Plugin\views\wizard\WizardPluginBase::rowStyleOptions(). - * - * Node revisions do not support full posts or teasers, so remove them. + * {@inheritdoc} */ protected function rowStyleOptions() { + // Node revisions do not support full posts or teasers, so remove them. $options = parent::rowStyleOptions(); unset($options['teasers']); unset($options['full_posts']); diff --git a/core/modules/node/tests/modules/node_no_default_author/node_no_default_author.info.yml b/core/modules/node/tests/modules/node_no_default_author/node_no_default_author.info.yml new file mode 100644 index 000000000000..c4f56344370f --- /dev/null +++ b/core/modules/node/tests/modules/node_no_default_author/node_no_default_author.info.yml @@ -0,0 +1,5 @@ +name: 'Node no default author' +type: module +description: 'Disables the default value callback for the uid field on node.' +package: Testing +version: VERSION diff --git a/core/modules/node/tests/modules/node_no_default_author/src/Hook/NodeNoDefaultAuthorHooks.php b/core/modules/node/tests/modules/node_no_default_author/src/Hook/NodeNoDefaultAuthorHooks.php new file mode 100644 index 000000000000..700e82236adc --- /dev/null +++ b/core/modules/node/tests/modules/node_no_default_author/src/Hook/NodeNoDefaultAuthorHooks.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\node_no_default_author\Hook; + +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Hook implementations for node_no_default_author. + */ +class NodeNoDefaultAuthorHooks { + + /** + * 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() === 'node') { + $fields['uid']->setDefaultValueCallback(static::class . '::noDefaultAuthor'); + } + } + + /** + * An empty callback to set for the default value callback of uid. + */ + public static function noDefaultAuthor(): void { + } + +} 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 1887bc56ea6c..201e781d1963 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/modules/node_test_views/test_views/views.view.test_node_access_join.yml b/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_access_join.yml index 766f656fc2bf..65ca46658711 100644 --- a/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_access_join.yml +++ b/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_access_join.yml @@ -68,6 +68,16 @@ display: quantity: 9 style: type: table + options: + grouping: { } + class: '' + row_class: '' + default_row_class: true + override: true + sticky: false + caption: '' + summary: '' + description: '' row: type: fields fields: diff --git a/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_bulk_form.yml b/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_bulk_form.yml index d7a3fa080fed..107165a27fb6 100644 --- a/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_bulk_form.yml +++ b/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_bulk_form.yml @@ -19,6 +19,8 @@ display: display_options: style: type: table + options: + class: '' row: type: fields fields: 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 000000000000..0d49a7c416ce --- /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 f0192966a1b5..7e99e3ba2ec1 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 bc8cfa927e43..f661ae18ebbb 100644 --- a/core/modules/node/tests/src/Functional/NodeEditFormTest.php +++ b/core/modules/node/tests/src/Functional/NodeEditFormTest.php @@ -251,10 +251,25 @@ 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>'); + } + + /** + * Tests the node form when the author is NULL. + */ + public function testNodeFormNullAuthor(): void { + \Drupal::service('module_installer')->install(['node_no_default_author']); + $this->drupalLogin($this->adminUser); + $this->drupalGet('node/add/page'); + $this->assertSession()->statusCodeEquals(200); } /** 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 000000000000..5a930df3e2df --- /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 201d4b6c7d25..88fe3e34e3ed 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 c3a3d46b4960..f8d52b06ecb3 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 2bb252f7c6e1..ac1e8664badf 100644 --- a/core/modules/node/tests/src/Functional/NodeTranslationUITest.php +++ b/core/modules/node/tests/src/Functional/NodeTranslationUITest.php @@ -242,21 +242,19 @@ class NodeTranslationUITest extends ContentTranslationUITestBase { // Set up the default admin theme and use it for node editing. $this->container->get('theme_installer')->install(['claro']); - $edit = []; - $edit['admin_theme'] = 'claro'; - $edit['use_admin_theme'] = TRUE; - $this->drupalGet('admin/appearance'); - $this->submitForm($edit, 'Save configuration'); - $this->drupalGet('node/' . $article->id() . '/translations'); + $this->config('system.theme')->set('admin', 'claro')->save(); + // Verify that translation uses the admin theme if edit is admin. + $this->drupalGet('node/' . $article->id() . '/translations'); $this->assertSession()->responseContains('core/themes/claro/css/base/elements.css'); // Turn off admin theme for editing, assert inheritance to translations. - $edit['use_admin_theme'] = FALSE; - $this->drupalGet('admin/appearance'); - $this->submitForm($edit, 'Save configuration'); - $this->drupalGet('node/' . $article->id() . '/translations'); + $this->config('node.settings')->set('use_admin_theme', FALSE)->save(); + // Changing node.settings:use_admin_theme requires a route rebuild. + $this->container->get('router.builder')->rebuild(); + // Verify that translation uses the frontend theme if edit is frontend. + $this->drupalGet('node/' . $article->id() . '/translations'); $this->assertSession()->responseNotContains('core/themes/claro/css/base/elements.css'); // Assert presence of translation page itself (vs. DisabledBundle below). @@ -561,12 +559,10 @@ class NodeTranslationUITest extends ContentTranslationUITestBase { 'translatable' => TRUE, ])->save(); - $this->drupalLogin($this->administrator); // Make the image field a multi-value field in order to display a // details form element. - $edit = ['field_storage[subform][cardinality_number]' => 2]; - $this->drupalGet('admin/structure/types/manage/article/fields/node.article.field_image'); - $this->submitForm($edit, 'Save'); + $fieldStorage = FieldStorageConfig::loadByName('node', 'field_image'); + $fieldStorage->setCardinality(2)->save(); // Enable the display of the image field. EntityFormDisplay::load('node.article.default') diff --git a/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php b/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php index cbe9b346623e..ac47588d5ec4 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 6ddb8c8c987d..b86b69e8ad1e 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']); } /** @@ -197,8 +198,7 @@ class NodeRequirementsStatusFilterWarningTest extends KernelTestBase { * The requirements raised by the Node module. */ private function getRequirements(): array { - $this->container->get('module_handler')->loadInclude('node', 'install'); - return node_requirements('runtime'); + return $this->container->get('module_handler')->invoke('node', 'runtime_requirements'); } /** diff --git a/core/modules/node/tests/src/Unit/PageCache/DenyNodePreviewTest.php b/core/modules/node/tests/src/Unit/PageCache/DenyNodePreviewTest.php deleted file mode 100644 index 1baf081ed000..000000000000 --- a/core/modules/node/tests/src/Unit/PageCache/DenyNodePreviewTest.php +++ /dev/null @@ -1,92 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\Tests\node\Unit\PageCache; - -use Drupal\Core\PageCache\ResponsePolicyInterface; -use Drupal\node\PageCache\DenyNodePreview; -use Drupal\Tests\UnitTestCase; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; - -/** - * @coversDefaultClass \Drupal\node\PageCache\DenyNodePreview - * @group node - */ -class DenyNodePreviewTest extends UnitTestCase { - - /** - * The response policy under test. - * - * @var \Drupal\node\PageCache\DenyNodePreview - */ - protected $policy; - - /** - * A request object. - * - * @var \Symfony\Component\HttpFoundation\Request - */ - protected $request; - - /** - * A response object. - * - * @var \Symfony\Component\HttpFoundation\Response - */ - protected $response; - - /** - * The current route match. - * - * @var \Drupal\Core\Routing\RouteMatch|\PHPUnit\Framework\MockObject\MockObject - */ - protected $routeMatch; - - /** - * {@inheritdoc} - */ - protected function setUp(): void { - parent::setUp(); - - $this->routeMatch = $this->createMock('Drupal\Core\Routing\RouteMatchInterface'); - $this->policy = new DenyNodePreview($this->routeMatch); - $this->response = new Response(); - $this->request = new Request(); - } - - /** - * Asserts that caching is denied on the node preview route. - * - * @dataProvider providerPrivateImageStyleDownloadPolicy - * @covers ::check - */ - public function testPrivateImageStyleDownloadPolicy($expected_result, $route_name): void { - $this->routeMatch->expects($this->once()) - ->method('getRouteName') - ->willReturn($route_name); - - $actual_result = $this->policy->check($this->response, $this->request); - $this->assertSame($expected_result, $actual_result); - } - - /** - * Provides data and expected results for the test method. - * - * @return array - * Data and expected results. - */ - public static function providerPrivateImageStyleDownloadPolicy() { - return [ - [ResponsePolicyInterface::DENY, 'entity.node.preview'], - [NULL, 'some.other.route'], - [NULL, NULL], - [NULL, FALSE], - [NULL, TRUE], - [NULL, new \stdClass()], - [NULL, [1, 2, 3]], - ]; - } - -} diff --git a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php index 80f92c2d2868..28dc50ef60f1 100644 --- a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php +++ b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php @@ -321,13 +321,7 @@ JS; ], 'List string' => [ 'list_string', - ['first' => 'First', 'second' => 'Second', 'third' => 'Third'], - TRUE, - ], - // Example with empty key and label values like string '0'. - 'List string with 0 value' => [ - 'list_string', - ['0' => '0', '1' => '1', '2' => '2'], + ['0' => '0', '1' => '1', 'two' => 'two'], TRUE, ], ]; diff --git a/core/modules/package_manager/package_manager.api.php b/core/modules/package_manager/package_manager.api.php index 216737e15731..9fa34742ef99 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.install b/core/modules/package_manager/package_manager.install deleted file mode 100644 index 9c5c65773dde..000000000000 --- a/core/modules/package_manager/package_manager.install +++ /dev/null @@ -1,82 +0,0 @@ -<?php - -/** - * @file - * Contains install and update functions for Package Manager. - */ - -declare(strict_types=1); - -use Drupal\Core\Site\Settings; -use Drupal\package_manager\ComposerInspector; -use Drupal\package_manager\Exception\FailureMarkerExistsException; -use Drupal\package_manager\FailureMarker; -use PhpTuf\ComposerStager\API\Exception\ExceptionInterface; -use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; - -/** - * Implements hook_requirements(). - */ -function package_manager_requirements(string $phase): array { - $requirements = []; - - if (Settings::get('testing_package_manager', FALSE) === FALSE) { - $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, - ]; - return $requirements; - } - - // If we're able to check for the presence of the failure marker at all, do it - // irrespective of the current run phase. If the failure marker is there, the - // site is in an indeterminate state and should be restored from backup ASAP. - $service_id = FailureMarker::class; - if (\Drupal::hasService($service_id)) { - try { - \Drupal::service($service_id)->assertNotExists(NULL); - } - catch (FailureMarkerExistsException $exception) { - $requirements['package_manager_failure_marker'] = [ - 'title' => t('Failed Package Manager update detected'), - 'description' => $exception->getMessage(), - 'severity' => REQUIREMENT_ERROR, - ]; - } - } - - if ($phase !== 'runtime') { - return $requirements; - } - /** @var \PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface $executable_finder */ - $executable_finder = \Drupal::service(ExecutableFinderInterface::class); - - // Report the Composer version in use, as well as its path. - $title = t('Composer version'); - try { - $requirements['package_manager_composer'] = [ - 'title' => $title, - 'description' => t('@version (<code>@path</code>)', [ - '@version' => \Drupal::service(ComposerInspector::class)->getVersion(), - '@path' => $executable_finder->find('composer'), - ]), - 'severity' => REQUIREMENT_INFO, - ]; - } - catch (\Throwable $e) { - // All Composer Stager exceptions are translatable. - $message = $e instanceof ExceptionInterface - ? $e->getTranslatableMessage() - : $e->getMessage(); - - $requirements['package_manager_composer'] = [ - 'title' => $title, - 'description' => t('Composer was not found. The error message was: @message', [ - '@message' => $message, - ]), - 'severity' => REQUIREMENT_ERROR, - ]; - } - return $requirements; -} diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml index 54c8fb846e0c..059be5542701 100644 --- a/core/modules/package_manager/package_manager.services.yml +++ b/core/modules/package_manager/package_manager.services.yml @@ -47,6 +47,7 @@ services: Drupal\package_manager\EventSubscriber\ChangeLogger: calls: - [setLogger, ['@logger.channel.package_manager_change_log']] + Drupal\package_manager\EventSubscriber\DirectWriteSubscriber: {} Drupal\package_manager\ComposerInspector: {} # Validators. @@ -201,3 +202,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 000000000000..d41de1a87e48 --- /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 69d307388503..32bde1002ea9 100644 --- a/core/modules/package_manager/src/ComposerInspector.php +++ b/core/modules/package_manager/src/ComposerInspector.php @@ -54,7 +54,7 @@ class ComposerInspector implements LoggerAwareInterface { * * @var string */ - final public const SUPPORTED_VERSION = '^2.6'; + final public const SUPPORTED_VERSION = '^2.7'; public function __construct( private readonly ComposerProcessRunnerInterface $runner, diff --git a/core/modules/package_manager/src/DirectWritePreconditionBypass.php b/core/modules/package_manager/src/DirectWritePreconditionBypass.php new file mode 100644 index 000000000000..ef346b0ae254 --- /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 0dad6829486b..df5bc1c2bbce 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 703dbf4603b1..c8c19324c87a 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 000000000000..c2340c397838 --- /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 new file mode 100644 index 000000000000..52cc10bc4e94 --- /dev/null +++ b/core/modules/package_manager/src/Hook/PackageManagerRequirementsHooks.php @@ -0,0 +1,137 @@ +<?php + +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; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Exception\FailureMarkerExistsException; +use Drupal\package_manager\FailureMarker; +use PhpTuf\ComposerStager\API\Exception\ExceptionInterface; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; + +/** + * Requirements checks for Package Manager. + */ +class PackageManagerRequirementsHooks { + + use StringTranslationTrait; + + public function __construct( + protected readonly ComposerInspector $composerInspector, + protected ExecutableFinderInterface $executableFinder, + ) {} + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + $requirements = $this->checkSettings($requirements); + $requirements = $this->checkFailure($requirements); + + // Report the Composer version in use, as well as its path. + $title = $this->t('Composer version'); + try { + $requirements['package_manager_composer'] = [ + 'title' => $title, + 'description' => $this->t('@version (<code>@path</code>)', [ + '@version' => $this->composerInspector->getVersion(), + '@path' => $this->executableFinder->find('composer'), + ]), + 'severity' => RequirementSeverity::Info, + ]; + } + catch (\Throwable $e) { + // All Composer Stager exceptions are translatable. + $message = $e instanceof ExceptionInterface + ? $e->getTranslatableMessage() + : $e->getMessage(); + + $requirements['package_manager_composer'] = [ + 'title' => $title, + 'description' => $this->t('Composer was not found. The error message was: @message', [ + '@message' => $message, + ]), + 'severity' => RequirementSeverity::Error, + ]; + } + + return $requirements; + } + + /** + * Implements hook_update_requirements(). + */ + #[Hook('update_requirements')] + public function update(): array { + $requirements = []; + $requirements = $this->checkSettings($requirements); + $requirements = $this->checkFailure($requirements); + return $requirements; + } + + /** + * Check that package manager has an explicit setting to allow installation. + * + * @param array $requirements + * The requirements array that has been processed so far. + * + * @return array + * Requirements array. + * + * @see hook_runtime_requirements + * @see hook_update_requirements + */ + public function checkSettings($requirements): array { + if (Settings::get('testing_package_manager', FALSE) === FALSE) { + $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' => RequirementSeverity::Error, + ]; + } + + return $requirements; + } + + /** + * Check for a failed update. + * + * This is run during requirements to allow restoring from backup. + * + * @param array $requirements + * The requirements array that has been processed so far. + * + * @return array + * Requirements array. + * + * @see hook_runtime_requirements + * @see hook_update_requirements + */ + public function checkFailure(array $requirements): array { + // If we're able to check for the presence of the failure marker at all, do + // it irrespective of the current run phase. If the failure marker is there, + // the site is in an indeterminate state and should be restored from backup + // ASAP. + $service_id = FailureMarker::class; + if (\Drupal::hasService($service_id)) { + try { + \Drupal::service($service_id)->assertNotExists(NULL); + } + catch (FailureMarkerExistsException $exception) { + $requirements['package_manager_failure_marker'] = [ + 'title' => $this->t('Failed Package Manager update detected'), + 'description' => $exception->getMessage(), + 'severity' => RequirementSeverity::Error, + ]; + } + } + + return $requirements; + } + +} diff --git a/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php b/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php new file mode 100644 index 000000000000..aac542e62751 --- /dev/null +++ b/core/modules/package_manager/src/Install/Requirements/PackageManagerRequirements.php @@ -0,0 +1,53 @@ +<?php + +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; + +/** + * Install time requirements for the package_manager module. + */ +class PackageManagerRequirements implements InstallRequirementsInterface { + + /** + * {@inheritdoc} + */ + public static function getRequirements(): array { + $requirements = []; + + if (Settings::get('testing_package_manager', FALSE) === FALSE) { + $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' => RequirementSeverity::Error, + ]; + } + + // If we're able to check for the presence of the failure marker at all, do + // it irrespective of the current run phase. If the failure marker is there, + // the site is in an indeterminate state and should be restored from backup + // ASAP. + $service_id = FailureMarker::class; + if (\Drupal::hasService($service_id)) { + try { + \Drupal::service($service_id)->assertNotExists(NULL); + } + catch (FailureMarkerExistsException $exception) { + $requirements['package_manager_failure_marker'] = [ + 'title' => t('Failed Package Manager update detected'), + 'description' => $exception->getMessage(), + 'severity' => RequirementSeverity::Error, + ]; + } + } + + return $requirements; + } + +} diff --git a/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php b/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php index 9408e874c978..f8f23d0b86ac 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/SandboxManagerBase.php b/core/modules/package_manager/src/SandboxManagerBase.php index 4b3c60654326..15836def8f8d 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 be540eb7a733..3c29c2cc013c 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 765fccd20cfc..9de2911fb23b 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/ComposerPluginsValidator.php b/core/modules/package_manager/src/Validator/ComposerPluginsValidator.php index dc0924e895f4..1297e6a711ed 100644 --- a/core/modules/package_manager/src/Validator/ComposerPluginsValidator.php +++ b/core/modules/package_manager/src/Validator/ComposerPluginsValidator.php @@ -62,8 +62,6 @@ final class ComposerPluginsValidator implements EventSubscriberInterface { * for those plugins that this validator explicitly supports. */ private const SUPPORTED_PLUGINS_THAT_DO_MODIFY = [ - // @see \Drupal\package_manager\Validator\ComposerPatchesValidator - 'cweagans/composer-patches' => '^1.7.3 || ^2', // @see \Drupal\package_manager\PathExcluder\VendorHardeningExcluder 'drupal/core-vendor-hardening' => '*', 'php-http/discovery' => '*', @@ -80,6 +78,7 @@ final class ComposerPluginsValidator implements EventSubscriberInterface { 'composer/installers' => '^2.0', 'dealerdirect/phpcodesniffer-composer-installer' => '^0.7.1 || ^1.0.0', 'drupal/core-composer-scaffold' => '*', + 'drupal/core-recipe-unpack' => '*', 'drupal/core-project-message' => '*', 'phpstan/extension-installer' => '^1.1', PhpTufValidator::PLUGIN_NAME => '^1', diff --git a/core/modules/package_manager/src/Validator/LockFileValidator.php b/core/modules/package_manager/src/Validator/LockFileValidator.php index ead8740ba843..c63b283b238a 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 37fe6eb76a50..eeb3f3a8b56f 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 be088454061b..20194d5c678e 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 61e50a1f81a2..7179299efd00 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 000000000000..307d17cdcdb3 --- /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 18c87b11956b..4cc4405d4c81 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 ec53f485dfb7..283c6aefa2cf 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 2b9ef4aa894e..da6fc5eae320 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 16cd486ad750..dcc5b879a2d1 100644 --- a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php +++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php @@ -347,7 +347,7 @@ END; $this->assertDirectoryIsWritable($log); $log .= '/' . str_replace('\\', '_', static::class) . '-' . $this->name(); if ($this->usesDataProvider()) { - $log .= '-' . preg_replace('/[^a-z0-9]+/i', '_', $this->dataName()); + $log .= '-' . preg_replace('/[^a-z0-9]+/i', '_', (string) $this->dataName()); } $code .= <<<END \$config['package_manager.settings']['log'] = '$log-package_manager.log'; @@ -441,6 +441,8 @@ END; $requirements['symfony/polyfill-php81'], $requirements['symfony/polyfill-php82'], $requirements['symfony/polyfill-php83'], + // Needed for PHP 8.4 features while PHP 8.3 is the minimum. + $requirements['symfony/polyfill-php84'], ); // If this package requires any Drupal core packages, ensure it allows // any version. @@ -719,6 +721,9 @@ END; $this->serverErrorLog, ); $this->assertSame(200, $session->getStatusCode(), $message); + // Sometimes we get a 200 response after a PHP timeout or OOM error, so we + // also check the page content to ensure it's what we expect. + $this->assertSame('Finished', $session->getPage()->getText()); } /** diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php index 0411978a175c..61f922824bdf 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/ComposerPatchesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php index e8bbea1283b4..149ea37ac44c 100644 --- a/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php @@ -28,6 +28,20 @@ class ComposerPatchesValidatorTest extends PackageManagerKernelTestBase { const REQUIRE_PACKAGE_INDIRECTLY = 8; /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // The composer-patches plugin is not allowed by default. + $this->config('package_manager.settings') + ->set('additional_trusted_composer_plugins', [ + 'cweagans/composer-patches', + ]) + ->save(); + } + + /** * Data provider for testErrorDuringPreCreate(). * * @return mixed[][] diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTestBase.php b/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTestBase.php index d9b91b1a7605..3100ce4bbf7a 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 000000000000..8c34ae002732 --- /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 3c2e32b1e7cd..5bcc43a81388 100644 --- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -12,6 +12,7 @@ use Drupal\Core\Queue\QueueFactory; use Drupal\Core\Site\Settings; use Drupal\fixture_manipulator\StageFixtureManipulator; use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\Attribute\AllowDirectWrite; use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Event\SandboxValidationEvent; use Drupal\package_manager\Exception\SandboxEventException; @@ -173,11 +174,15 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { /** * Creates a stage object for testing purposes. * + * @param class-string $class + * (optional) The class of the sandbox manager to create. Defaults to + * \Drupal\Tests\package_manager\Kernel\TestSandboxManager. + * * @return \Drupal\Tests\package_manager\Kernel\TestSandboxManager * A stage object, with test-only modifications. */ - protected function createStage(): TestSandboxManager { - return new TestSandboxManager( + protected function createStage(?string $class = TestSandboxManager::class): TestSandboxManager { + return new $class( $this->container->get(PathLocator::class), $this->container->get(BeginnerInterface::class), $this->container->get(StagerInterface::class), @@ -476,6 +481,19 @@ class TestSandboxManager extends SandboxManagerBase { } /** + * Defines a test-only sandbox manager that allows direct-write. + */ +#[AllowDirectWrite] +class TestDirectWriteSandboxManager extends TestSandboxManager { + + /** + * {@inheritdoc} + */ + protected string $type = 'package_manager:test_direct_write'; + +} + +/** * A test version of the disk space validator to bypass system-level functions. */ class TestDiskSpaceValidator extends DiskSpaceValidator { diff --git a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php index 188c654929dd..02be8f298aa2 100644 --- a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php @@ -76,4 +76,13 @@ class RsyncValidatorTest extends PackageManagerKernelTestBase { $this->assertResults([$result], PreCreateEvent::class); } + /** + * Tests that the presence of rsync is not checked in direct-write mode. + */ + public function testRsyncNotNeededForDirectWrite(): void { + $this->executableFinder->find('rsync')->shouldNotBeCalled(); + $this->setSetting('package_manager_allow_direct_write', TRUE); + $this->createStage(TestDirectWriteSandboxManager::class)->create(); + } + } diff --git a/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php index 90348cdfdd3c..2e9a0977fa36 100644 --- a/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php @@ -13,7 +13,6 @@ use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; * @coversDefaultClass \Drupal\package_manager\Validator\SupportedReleaseValidator * @group #slow * @group package_manager - * @group #slow * @internal */ class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase { diff --git a/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php index 2b46e1de9c83..00366b8c318d 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 41f2e8b8e4ff..da6d22bfb055 100644 --- a/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php +++ b/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php @@ -155,7 +155,6 @@ class PageCacheTagsIntegrationTest extends BrowserTestBase { 'config:block.block.olivero_messages', 'config:block.block.olivero_primary_local_tasks', 'config:block.block.olivero_secondary_local_tasks', - 'config:block.block.olivero_syndicate', 'config:block.block.olivero_primary_admin_actions', 'config:block.block.olivero_page_title', 'node_view', @@ -195,7 +194,6 @@ class PageCacheTagsIntegrationTest extends BrowserTestBase { 'config:block.block.olivero_messages', 'config:block.block.olivero_primary_local_tasks', 'config:block.block.olivero_secondary_local_tasks', - 'config:block.block.olivero_syndicate', 'config:block.block.olivero_primary_admin_actions', 'config:block.block.olivero_page_title', 'node_view', diff --git a/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php b/core/modules/path/tests/src/Kernel/Migrate/d6/MigrateUrlAliasTest.php index d5cc9759ab18..be5d811fe543 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/path_alias/path_alias.services.yml b/core/modules/path_alias/path_alias.services.yml index a8c7f2dfeee0..e9002aac295f 100644 --- a/core/modules/path_alias/path_alias.services.yml +++ b/core/modules/path_alias/path_alias.services.yml @@ -20,6 +20,7 @@ services: tags: - { name: backend_overridable } Drupal\path_alias\AliasRepositoryInterface: '@path_alias.repository' + # cspell:ignore whitelist path_alias.whitelist: class: Drupal\path_alias\AliasWhitelist tags: diff --git a/core/modules/path_alias/src/AliasManager.php b/core/modules/path_alias/src/AliasManager.php index c5ed8fdc2ad8..334e6730ee01 100644 --- a/core/modules/path_alias/src/AliasManager.php +++ b/core/modules/path_alias/src/AliasManager.php @@ -260,6 +260,8 @@ class AliasManager implements AliasManagerInterface { * Use \Drupal\path_alias\AliasManager::pathAliasPrefixListRebuild instead. * * @see https://www.drupal.org/node/3467559 + * + * cspell:ignore whitelist */ protected function pathAliasWhitelistRebuild($path = NULL) { @trigger_error(__METHOD__ . '() is deprecated in drupal:11.1.0 and is removed from drupal:12.0.0. Use \Drupal\path_alias\AliasManager::pathAliasPrefixListRebuild() instead. See https://www.drupal.org/node/3467559', E_USER_DEPRECATED); diff --git a/core/modules/path_alias/src/AliasWhitelist.php b/core/modules/path_alias/src/AliasWhitelist.php index df68b35c4a25..69b0c84bad95 100644 --- a/core/modules/path_alias/src/AliasWhitelist.php +++ b/core/modules/path_alias/src/AliasWhitelist.php @@ -2,6 +2,8 @@ namespace Drupal\path_alias; +// cspell:ignore whitelist + /** * Cache a list of valid alias prefixes. * diff --git a/core/modules/path_alias/src/AliasWhitelistInterface.php b/core/modules/path_alias/src/AliasWhitelistInterface.php index ed4ff988680b..b3172995ae25 100644 --- a/core/modules/path_alias/src/AliasWhitelistInterface.php +++ b/core/modules/path_alias/src/AliasWhitelistInterface.php @@ -2,6 +2,8 @@ namespace Drupal\path_alias; +// cspell:ignore whitelist + /** * Cache a list of valid alias prefixes. * diff --git a/core/modules/pgsql/pgsql.install b/core/modules/pgsql/pgsql.install index 3adeb4f5dff3..682ef7d605b5 100644 --- a/core/modules/pgsql/pgsql.install +++ b/core/modules/pgsql/pgsql.install @@ -5,42 +5,6 @@ * Install, update and uninstall functions for the pgsql module. */ -use Drupal\Core\Database\Database; - -/** - * Implements hook_requirements(). - */ -function pgsql_requirements(): array { - $requirements = []; - // Test with PostgreSQL databases for the status of the pg_trgm extension. - if (Database::isActiveConnection()) { - $connection = Database::getConnection(); - - // Set the requirement just for postgres. - if ($connection->driver() == 'pgsql') { - $requirements['pgsql_extension_pg_trgm'] = [ - 'severity' => REQUIREMENT_OK, - 'title' => t('PostgreSQL pg_trgm extension'), - 'value' => t('Available'), - 'description' => 'The pg_trgm PostgreSQL extension is present.', - ]; - - // 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']['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 10 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', - ':requirements' => 'https://www.drupal.org/docs/system-requirements/database-server-requirements', - ]); - } - - } - } - - return $requirements; -} - /** * Implements hook_update_last_removed(). */ diff --git a/core/modules/pgsql/src/Driver/Database/pgsql/Schema.php b/core/modules/pgsql/src/Driver/Database/pgsql/Schema.php index a4585e15da7a..440dc7179dbf 100644 --- a/core/modules/pgsql/src/Driver/Database/pgsql/Schema.php +++ b/core/modules/pgsql/src/Driver/Database/pgsql/Schema.php @@ -1072,7 +1072,8 @@ EOD; /** * Calculates a base-64 encoded PostgreSQL-safe sha-256 hash. * - * The hash is modified to according to @link https://www.postgresql.org/docs/current/sql-syntax-lexical.html PostgreSQL Lexical Structure@endlink. + * The hash is modified to according to PostgreSQL Lexical Structure. See + * https://www.postgresql.org/docs/current/sql-syntax-lexical.html. * * @param string $data * String to be hashed. diff --git a/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php b/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php new file mode 100644 index 000000000000..65fa78a5e71c --- /dev/null +++ b/core/modules/pgsql/src/Hook/PgsqlRequirementsHooks.php @@ -0,0 +1,55 @@ +<?php + +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; + +/** + * Hook implementations for pgsql module. + */ +class PgsqlRequirementsHooks { + + use StringTranslationTrait; + + /** + * Implements hook_update_requirements(). + * + * Implements hook_runtime_requirements(). + */ + #[Hook('update_requirements')] + #[Hook('runtime_requirements')] + public function checkRequirements(): array { + $requirements = []; + // Test with PostgreSQL databases for the status of the pg_trgm extension. + if (Database::isActiveConnection()) { + $connection = Database::getConnection(); + + // Set the requirement just for postgres. + if ($connection->driver() == 'pgsql') { + $requirements['pgsql_extension_pg_trgm'] = [ + 'severity' => RequirementSeverity::OK, + 'title' => $this->t('PostgreSQL pg_trgm extension'), + 'value' => $this->t('Available'), + 'description' => $this->t('The pg_trgm PostgreSQL extension is present.'), + ]; + + // If the extension is not available, set the requirement error. + if (!$connection->schema()->extensionExists('pg_trgm')) { + $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', + ':requirements' => 'https://www.drupal.org/docs/system-requirements/database-server-requirements', + ]); + } + + } + } + + return $requirements; + } + +} diff --git a/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php b/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php new file mode 100644 index 000000000000..ab4b936dcba4 --- /dev/null +++ b/core/modules/pgsql/src/Install/Requirements/PgsqlRequirements.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +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. + */ +class PgsqlRequirements implements InstallRequirementsInterface { + + /** + * {@inheritdoc} + */ + public static function getRequirements(): array { + $requirements = []; + // Test with PostgreSQL databases for the status of the pg_trgm extension. + if (Database::isActiveConnection()) { + $connection = Database::getConnection(); + + // Set the requirement just for postgres. + if ($connection->driver() == 'pgsql') { + $requirements['pgsql_extension_pg_trgm'] = [ + 'severity' => RequirementSeverity::OK, + 'title' => t('PostgreSQL pg_trgm extension'), + 'value' => t('Available'), + 'description' => t('The pg_trgm PostgreSQL extension is present.'), + ]; + + // If the extension is not available, set the requirement error. + if (!$connection->schema()->extensionExists('pg_trgm')) { + $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', + ':requirements' => 'https://www.drupal.org/docs/system-requirements/database-server-requirements', + ]); + } + + } + } + + return $requirements; + } + +} diff --git a/core/modules/pgsql/tests/src/Kernel/EntityQueryServiceDeprecation.php b/core/modules/pgsql/tests/src/Kernel/EntityQueryServiceDeprecationTest.php index db2c1b88ea37..043b5838d5ab 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 5d862a864216..5c5e6be58382 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/rest/tests/src/Functional/CookieResourceTestTrait.php b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php index b296446affbd..07bee95122cd 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/search.install b/core/modules/search/search.install index 8873e1fbbf99..6e3e2691aa0f 100644 --- a/core/modules/search/search.install +++ b/core/modules/search/search.install @@ -122,35 +122,3 @@ function search_schema(): array { return $schema; } - -/** - * Implements hook_requirements(). - * - * For the Status Report, return information about search index status. - */ -function search_requirements($phase): array { - $requirements = []; - - if ($phase == 'runtime') { - $remaining = 0; - $total = 0; - $search_page_repository = \Drupal::service('search.search_page_repository'); - foreach ($search_page_repository->getIndexableSearchPages() as $entity) { - $status = $entity->getPlugin()->indexStatus(); - $remaining += $status['remaining']; - $total += $status['total']; - } - - $done = $total - $remaining; - // Use floor() to calculate the percentage, so if it is not quite 100%, it - // will show as 99%, to indicate "almost done". - $percent = ($total > 0 ? floor(100 * $done / $total) : 100); - $requirements['search_status'] = [ - 'title' => t('Search index progress'), - 'value' => t('@percent% (@remaining remaining)', ['@percent' => $percent, '@remaining' => $remaining]), - 'severity' => REQUIREMENT_INFO, - ]; - } - - return $requirements; -} diff --git a/core/modules/search/src/Entity/SearchPage.php b/core/modules/search/src/Entity/SearchPage.php index 894da73bbac0..f6c87fce2c66 100644 --- a/core/modules/search/src/Entity/SearchPage.php +++ b/core/modules/search/src/Entity/SearchPage.php @@ -214,7 +214,9 @@ class SearchPage extends ConfigEntityBase implements SearchPageInterface, Entity } /** - * Helper callback for uasort() to sort search page entities by status, weight and label. + * Sorts search page entities by status, weight and label. + * + * Callback for uasort(). */ public static function sort(ConfigEntityInterface $a, ConfigEntityInterface $b) { /** @var \Drupal\search\SearchPageInterface $a */ diff --git a/core/modules/search/src/Hook/SearchRequirements.php b/core/modules/search/src/Hook/SearchRequirements.php new file mode 100644 index 000000000000..14e7dcb1649f --- /dev/null +++ b/core/modules/search/src/Hook/SearchRequirements.php @@ -0,0 +1,51 @@ +<?php + +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; + +/** + * Requirements for the Search module. + */ +class SearchRequirements { + + use StringTranslationTrait; + + public function __construct( + protected readonly SearchPageRepositoryInterface $searchPageRepository, + ) {} + + /** + * Implements hook_runtime_requirements(). + * + * For the Status Report, return information about search index status. + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + $remaining = 0; + $total = 0; + foreach ($this->searchPageRepository->getIndexableSearchPages() as $entity) { + $status = $entity->getPlugin()->indexStatus(); + $remaining += $status['remaining']; + $total += $status['total']; + } + + $done = $total - $remaining; + // Use floor() to calculate the percentage, so if it is not quite 100%, it + // will show as 99%, to indicate "almost done". + $percent = ($total > 0 ? floor(100 * $done / $total) : 100); + $requirements['search_status'] = [ + 'title' => $this->t('Search index progress'), + 'value' => $this->t('@percent% (@remaining remaining)', ['@percent' => $percent, '@remaining' => $remaining]), + 'severity' => RequirementSeverity::Info, + ]; + return $requirements; + } + +} diff --git a/core/modules/search/src/Plugin/ConfigurableSearchPluginBase.php b/core/modules/search/src/Plugin/ConfigurableSearchPluginBase.php index 7ad95f16823b..acc2e49a5cb6 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 d21af735ecad..000000000000 --- 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 000000000000..89036be010bb --- /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 9363baad5ecb..28070a0f7273 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 61b1fa268282..5655111787ce 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/SettingsTrayTestBase.php b/core/modules/settings_tray/tests/src/FunctionalJavascript/SettingsTrayTestBase.php index 15b14a771e89..4b7ba9ca6980 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 d7a5fa146f71..d98e6f1864b3 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 8158de67c504..46bea0731d00 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 3779e7625195..fa526a9f5d03 100644 --- a/core/modules/sqlite/src/Driver/Database/sqlite/Schema.php +++ b/core/modules/sqlite/src/Driver/Database/sqlite/Schema.php @@ -149,7 +149,7 @@ class Schema extends DatabaseSchema { } /** - * Create an SQL string for a field to be used in table creation or alteration. + * Create an SQL string for a field to be used in table create or alter. * * Before passing a field out of a schema definition into this function it has * to be processed by self::processField(). @@ -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; } @@ -647,7 +652,7 @@ class Schema extends DatabaseSchema { } /** - * Utility method: rename columns in an index definition according to a new mapping. + * Renames columns in an index definition according to a new mapping. * * @param array $key_definition * The key definition. diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php b/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php index 1c7378a01737..c3060a572348 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 88f34652b986..cfd2f82952cc 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 2d23ee5bd335..000000000000 --- 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 ae209f3aa614..000000000000 --- 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/system-status-report-counters.css b/core/modules/system/css/components/system-status-report-counters.css index 7040c257a0f2..54ffabe5fc1c 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 131b6a075d5b..2f5ae0512040 100644 --- a/core/modules/system/src/Controller/DbUpdateController.php +++ b/core/modules/system/src/Controller/DbUpdateController.php @@ -7,6 +7,7 @@ use Drupal\Core\Batch\BatchBuilder; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface; use Drupal\Core\Render\BareHtmlPageRendererInterface; use Drupal\Core\Session\AccountInterface; @@ -166,8 +167,8 @@ class DbUpdateController extends ControllerBase { $regions = []; $requirements = update_check_requirements(); - $severity = drupal_requirements_severity($requirements); - if ($severity == REQUIREMENT_ERROR || ($severity == REQUIREMENT_WARNING && !$request->getSession()->has('update_ignore_warnings'))) { + $severity = RequirementSeverity::maxSeverityFromRequirements($requirements); + if ($severity === RequirementSeverity::Error || ($severity === RequirementSeverity::Warning && !$request->getSession()->has('update_ignore_warnings'))) { $regions['sidebar_first'] = $this->updateTasksList('requirements'); $output = $this->requirements($severity, $requirements, $request); } @@ -543,7 +544,7 @@ class DbUpdateController extends ControllerBase { * A render array. */ public function requirements($severity, array $requirements, Request $request) { - $options = $severity == REQUIREMENT_WARNING ? ['continue' => 1] : []; + $options = $severity === RequirementSeverity::Warning ? ['continue' => 1] : []; // @todo Revisit once https://www.drupal.org/node/2548095 is in. Something // like Url::fromRoute('system.db_update')->setOptions() should then be // possible. diff --git a/core/modules/system/src/Controller/SystemController.php b/core/modules/system/src/Controller/SystemController.php index b340555c6289..fc253ac2f7cf 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 90a878831ead..2d6494f2fe39 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/AccessRouteAlterSubscriber.php b/core/modules/system/src/EventSubscriber/AccessRouteAlterSubscriber.php index c9250047f1fa..777efab6b0c5 100644 --- a/core/modules/system/src/EventSubscriber/AccessRouteAlterSubscriber.php +++ b/core/modules/system/src/EventSubscriber/AccessRouteAlterSubscriber.php @@ -23,7 +23,7 @@ class AccessRouteAlterSubscriber implements EventSubscriberInterface { } /** - * Adds _access_admin_menu_block_page requirement to routes pointing to SystemController::systemAdminMenuBlockPage. + * Adds requirements to some System Controller routes. * * @param \Drupal\Core\Routing\RouteBuildEvent $event * The event to process. diff --git a/core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php b/core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php index 737b9a7a53e8..c9722a5e4e12 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 90dc9ead38b7..13297f3578c3 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 c999f37fe23f..97f76abe40e3 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 fb6335f90c31..3f271571ede1 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 ae3e8d710747..e1be05077e29 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 000000000000..49d318eb9bff --- /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 000000000000..ee6a1d7a8028 --- /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 670fbc06cf7e..2ce434e0fd3d 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/Plugin/migrate/process/d6/SystemUpdate7000.php b/core/modules/system/src/Plugin/migrate/process/d6/SystemUpdate7000.php index ee1550984f77..2fbfd980d344 100644 --- a/core/modules/system/src/Plugin/migrate/process/d6/SystemUpdate7000.php +++ b/core/modules/system/src/Plugin/migrate/process/d6/SystemUpdate7000.php @@ -16,7 +16,7 @@ class SystemUpdate7000 extends ProcessPluginBase { /** * {@inheritdoc} * - * Rename blog and forum permissions to be consistent with other content types. + * Makes blog and forum permissions to be consistent with other content types. */ public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) { $value = preg_replace('/(?<=^|,\ )create\ blog\ entries(?=,|$)/', 'create blog content', $value); diff --git a/core/modules/system/src/SystemManager.php b/core/modules/system/src/SystemManager.php index 5534e70147b4..43a53fe05425 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 35e682600b89..5bbaa8a5436f 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 whitelist. 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 03baf83d3bbc..acb02dd1f4b7 100644 --- a/core/modules/system/system.libraries.yml +++ b/core/modules/system/system.libraries.yml @@ -7,9 +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: diff --git a/core/modules/system/system.module b/core/modules/system/system.module index c7ed1808206f..0fbcf9b6c1fd 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -223,7 +223,7 @@ function template_preprocess_entity_add_list(&$variables): void { * Optional string to use as the page title once redirected to authorize.php. */ function system_authorized_init($callback, $file, $arguments = [], $page_title = NULL): void { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); $session = \Drupal::request()->getSession(); // First, figure out what file transfer backends the site supports, and put @@ -255,7 +255,7 @@ function system_authorized_init($callback, $file, $arguments = [], $page_title = * @see system_authorized_init() */ function system_authorized_get_url(array $options = []) { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); // core/authorize.php is an unrouted URL, so using the base: scheme is // the correct usage for this case. @@ -275,7 +275,7 @@ function system_authorized_get_url(array $options = []) { * The full URL for the authorize.php script with batch processing options. */ function system_authorized_batch_processing_url(array $options = []) { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); $options['query'] = ['batch' => '1']; return system_authorized_get_url($options); @@ -287,7 +287,7 @@ function system_authorized_batch_processing_url(array $options = []) { * @see system_authorized_init() */ function system_authorized_run($callback, $file, $arguments = [], $page_title = NULL) { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); system_authorized_init($callback, $file, $arguments, $page_title); return new RedirectResponse(system_authorized_get_url()->toString()); @@ -299,7 +299,7 @@ function system_authorized_run($callback, $file, $arguments = [], $page_title = * @see batch_process() */ function system_authorized_batch_process() { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); $finish_url = system_authorized_get_url(); $process_url = system_authorized_batch_processing_url(); @@ -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/system.post_update.php b/core/modules/system/system.post_update.php index b1ed3dbe019f..ce110e2c2f36 100644 --- a/core/modules/system/system.post_update.php +++ b/core/modules/system/system.post_update.php @@ -108,7 +108,7 @@ function system_post_update_remove_path_key(): void { } /** - * Updates all entity_form_mode configuration entities to set description from empty string to null. + * Updates entity_form_mode descriptions from empty string to null. */ function system_post_update_convert_empty_description_entity_form_modes_to_null(array &$sandbox): void { \Drupal::classResolver(ConfigEntityUpdater::class) diff --git a/core/modules/system/templates/authorize-report.html.twig b/core/modules/system/templates/authorize-report.html.twig index 914458684775..f6f443c58075 100644 --- a/core/modules/system/templates/authorize-report.html.twig +++ b/core/modules/system/templates/authorize-report.html.twig @@ -12,6 +12,11 @@ * @see template_preprocess_authorize_report() * * @ingroup themeable + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. Use composer to manage the code for your site. + * + * @see https://www.drupal.org/node/3522119 */ #} {% if messages %} diff --git a/core/modules/system/templates/details.html.twig b/core/modules/system/templates/details.html.twig index 20e4ea7193e3..dcb1cf354ce5 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 832b9f61794a..ecd268690b46 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 1497678b50ad..2bef0a02e6f0 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 6411eaa3d07b..1f6d19d6c3e7 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 f6091fd3b956..d9144e6a154b 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 1462cf41ae0f..c2babdab978e 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 748ed5a3aa4a..06fb6065f7a4 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 0eb03a9534ab..e0280d5fcbc0 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 ec02a8d530c4..b2a743940a77 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 199f0578dbdc..75047c1b95f2 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 219e14b0a4be..ddcaaa192df4 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 cfcb0bf976c0..6a73cc1152a8 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 5d8c99744690..077d0645ddc7 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 423f49a1d409..5db0b3a5aaeb 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/common_test/common_test.module b/core/modules/system/tests/modules/common_test/common_test.module index 5d7fdc3dc6b8..5a17e7a6fcdb 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 a3e65453b04e..aa93bfb5083c 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 000000000000..f47116e8920d --- /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 000000000000..46411d2ea544 --- /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 000000000000..5c8e0aff74ea --- /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 c543a1d3e533..f444b69a427b 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 f54ad8f9a9a3..c052c5a488fb 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/Element/DeprecatedExtendsFormElement.php b/core/modules/system/tests/modules/element_info_test/src/Element/DeprecatedExtendsFormElement.php index 44308c3f6a9c..33dba9b0bf6f 100644 --- a/core/modules/system/tests/modules/element_info_test/src/Element/DeprecatedExtendsFormElement.php +++ b/core/modules/system/tests/modules/element_info_test/src/Element/DeprecatedExtendsFormElement.php @@ -7,13 +7,14 @@ namespace Drupal\element_info_test\Element; use Drupal\Core\Render\Attribute\FormElement; use Drupal\Core\Render\Element\FormElement as FormElementDeprecated; +// @phpcs:disable /** * Provides render element that extends deprecated FormElement for testing. - * - * @phpstan-ignore class.extendsDeprecatedClass */ #[FormElement('deprecated_extends_form')] +// @phpstan-ignore class.extendsDeprecatedClass class DeprecatedExtendsFormElement extends FormElementDeprecated { +// @phpcs:enable /** * {@inheritdoc} diff --git a/core/modules/system/tests/modules/element_info_test/src/Element/DeprecatedExtendsRenderElement.php b/core/modules/system/tests/modules/element_info_test/src/Element/DeprecatedExtendsRenderElement.php index 514a3b01a4bc..72a608bfc5e1 100644 --- a/core/modules/system/tests/modules/element_info_test/src/Element/DeprecatedExtendsRenderElement.php +++ b/core/modules/system/tests/modules/element_info_test/src/Element/DeprecatedExtendsRenderElement.php @@ -7,13 +7,14 @@ namespace Drupal\element_info_test\Element; use Drupal\Core\Render\Attribute\RenderElement; use Drupal\Core\Render\Element\RenderElement as RenderElementDeprecated; +// @phpcs:disable /** * Provides render element that extends deprecated RenderElement for testing. - * - * @phpstan-ignore class.extendsDeprecatedClass */ #[RenderElement('deprecated_extends_render')] +// @phpstan-ignore class.extendsDeprecatedClass class DeprecatedExtendsRenderElement extends RenderElementDeprecated { +// @phpcs:enable /** * {@inheritdoc} 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 ea72bd033fb9..53150495cb9b 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 000000000000..8e4d84558ca4 --- /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 c552a268952a..8e16b55b4c69 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/entity_reference_test_views/test_views/views.view.test_entity_reference_group_by_empty_relationships.yml b/core/modules/system/tests/modules/entity_reference_test_views/test_views/views.view.test_entity_reference_group_by_empty_relationships.yml index 39d5a0f81252..25c13b4cd4cd 100644 --- a/core/modules/system/tests/modules/entity_reference_test_views/test_views/views.view.test_entity_reference_group_by_empty_relationships.yml +++ b/core/modules/system/tests/modules/entity_reference_test_views/test_views/views.view.test_entity_reference_group_by_empty_relationships.yml @@ -77,6 +77,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true 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 beaa3cd15b70..000000000000 --- 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 000000000000..53834f77c0e1 --- /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 542c4e162e2d..78328f9f8e4d 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 9babda83ddc0..226eb705802d 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 cda2b92b3477..441ebaa1d128 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 908d0d8d4545..09dbf982cf79 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 8b34072bd659..000000000000 --- 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 000000000000..d9b37274d46c --- /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 4d7367ff4141..b6a532a18022 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 0fa4b2f6f804..31358a595d77 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 c0cee6f1905e..5ba5f5614ca7 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 1cbb9e6b422b..db923382a21b 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 f0666222f142..073baba95c9f 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/render_placeholder_message_test/src/RenderPlaceholderMessageTestController.php b/core/modules/system/tests/modules/render_placeholder_message_test/src/RenderPlaceholderMessageTestController.php index 1528a0839816..6877871e2d29 100644 --- a/core/modules/system/tests/modules/render_placeholder_message_test/src/RenderPlaceholderMessageTestController.php +++ b/core/modules/system/tests/modules/render_placeholder_message_test/src/RenderPlaceholderMessageTestController.php @@ -112,7 +112,9 @@ class RenderPlaceholderMessageTestController implements TrustedCallbackInterface $reordered = []; foreach ($placeholder_order as $placeholder) { - $reordered[$placeholder] = $build['#attached']['placeholders'][$placeholder]; + if (isset($build['#attached']['placeholders'][$placeholder])) { + $reordered[$placeholder] = $build['#attached']['placeholders'][$placeholder]; + } } $build['#attached']['placeholders'] = $reordered; 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 70767c16544d..a93f726fafd0 100644 --- a/core/modules/system/tests/modules/requirements1_test/requirements1_test.install +++ b/core/modules/system/tests/modules/requirements1_test/requirements1_test.install @@ -7,8 +7,12 @@ declare(strict_types=1); +use Drupal\Core\Extension\Requirement\RequirementSeverity; + /** * Implements hook_requirements(). + * + * This tests the procedural implementations for this hook. */ function requirements1_test_requirements($phase): array { $requirements = []; @@ -17,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 c766f6f423ae..ce3eebfb35b7 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 195903e9ee74..0c89a0740a6b 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 65b3c472096b..d0d4f8c73b46 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-with-variant-prop/my-cta-with-variant-prop.component.yml b/core/modules/system/tests/modules/sdc_test/components/my-cta-with-variant-prop/my-cta-with-variant-prop.component.yml new file mode 100644 index 000000000000..d283c7c0feaf --- /dev/null +++ b/core/modules/system/tests/modules/sdc_test/components/my-cta-with-variant-prop/my-cta-with-variant-prop.component.yml @@ -0,0 +1,35 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Call to Action With Existing Variant Prop +description: Call to action link. +props: + type: object + required: + - text + properties: + text: + type: string + title: Title + description: The title for the cta + examples: + - Press + - Submit now + href: + type: string + title: URL + format: uri + target: + type: string + title: Target + enum: + - '' + - _blank + variant: + type: string + title: Variant Prop + enum: + - primary + - secondary + - tertiary + attributes: + type: Drupal\Core\Template\Attribute + name: Attributes diff --git a/core/modules/system/tests/modules/sdc_test/components/my-cta-with-variant-prop/my-cta-with-variant-prop.twig b/core/modules/system/tests/modules/sdc_test/components/my-cta-with-variant-prop/my-cta-with-variant-prop.twig new file mode 100644 index 000000000000..e1a326ccf078 --- /dev/null +++ b/core/modules/system/tests/modules/sdc_test/components/my-cta-with-variant-prop/my-cta-with-variant-prop.twig @@ -0,0 +1,10 @@ +{% if target is not empty %} + {% set attributes = attributes.setAttribute('target', target) %} +{% endif %} +{% if variant is not empty %} + {% set attributes = attributes.addClass('my-cta-' ~ variant) %} +{% endif %} + +<a {{ attributes }} href="{{ href }}"> + {{ text }} +</a> 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 015b3e16319b..6d16be49cf74 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,33 @@ 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 +variants: + primary: + title: Primary + description: My primary variant + secondary: + title: Secondary + description: My secondary variant + tertiary: + title: Tertiary + description: My tertiary variant diff --git a/core/modules/system/tests/modules/sdc_test/components/my-cta/my-cta.twig b/core/modules/system/tests/modules/sdc_test/components/my-cta/my-cta.twig index bf8b240b673c..e1a326ccf078 100644 --- a/core/modules/system/tests/modules/sdc_test/components/my-cta/my-cta.twig +++ b/core/modules/system/tests/modules/sdc_test/components/my-cta/my-cta.twig @@ -1,6 +1,10 @@ {% if target is not empty %} {% set attributes = attributes.setAttribute('target', target) %} {% endif %} +{% if variant is not empty %} + {% set attributes = attributes.addClass('my-cta-' ~ variant) %} +{% endif %} + <a {{ attributes }} href="{{ href }}"> {{ text }} </a> 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 b87f1180111e..053387cf9c4f 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 fe85de11032d..f11bd86b4d78 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 000000000000..a1438a0108ed --- /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 9c7bb97e24b8..461581abaa70 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 000000000000..b57e4c883972 --- /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 000000000000..75b757dbe3c9 --- /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 000000000000..5ca13501ceed --- /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 000000000000..9045a4c8b9cc --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\test_htmx\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Url; + +/** + * Returns responses for HTMX Test Attachments routes. + */ +final class HtmxTestAttachmentsController extends ControllerBase { + + /** + * Builds the response. + * + * @return mixed[] + * A render array. + */ + public function page(): array { + return self::generateHtmxButton(); + } + + /** + * Builds the HTMX response. + * + * @return mixed[] + * A render array. + */ + public function replace(): array { + $build['content'] = [ + '#type' => 'container', + '#attached' => [ + 'library' => ['test_htmx/assets'], + ], + '#attributes' => [ + 'class' => ['ajax-content'], + ], + 'example' => ['#markup' => 'Initial Content'], + ]; + + return $build; + } + + /** + * Static helper to for reusable render array. + * + * @return array + * The render array. + */ + public static function generateHtmxButton(): array { + $url = Url::fromRoute('test_htmx.attachments.replace'); + $build['replace'] = [ + '#type' => 'html_tag', + '#tag' => 'button', + '#attributes' => [ + 'type' => 'button', + 'name' => 'replace', + 'data-hx-get' => $url->toString(), + 'data-hx-select' => 'div.ajax-content', + 'data-hx-target' => '[data-drupal-htmx-target]', + ], + '#value' => 'Click this', + '#attached' => [ + 'library' => [ + 'core/drupal.htmx', + ], + ], + ]; + + $build['content'] = [ + '#type' => 'container', + '#attributes' => [ + 'data-drupal-htmx-target' => TRUE, + 'class' => ['htmx-test-container'], + ], + ]; + + return $build; + } + +} diff --git a/core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestAjaxForm.php b/core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestAjaxForm.php new file mode 100644 index 000000000000..8fffbbc5f40b --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestAjaxForm.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\test_htmx\Form; + +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\test_htmx\Controller\HtmxTestAttachmentsController; + +/** + * A small form used to insert an HTMX powered element using ajax API. + */ +class HtmxTestAjaxForm extends FormBase { + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'htmx_test_ajax_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $build = [ + 'ajax-button' => [ + '#type' => 'button', + '#value' => 'Trigger Ajax', + '#submit_button' => FALSE, + '#ajax' => [ + 'callback' => [ + HtmxTestAttachmentsController::class, + 'generateHtmxButton', + ], + 'wrapper' => 'ajax-test-container', + ], + ], + '#suffix' => '<div id="ajax-test-container"></div>', + ]; + + return $build; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void {} + +} diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml new file mode 100644 index 000000000000..c713e0624d93 --- /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 000000000000..31ac1d2b8abe --- /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 000000000000..406c3027f3b1 --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml @@ -0,0 +1,23 @@ +test_htmx.attachments.page: + path: '/htmx-test-attachments/page' + defaults: + _title: 'Page' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::page' + requirements: + _permission: 'access content' + +test_htmx.attachments.replace: + path: '/htmx-test-attachments/replace' + defaults: + _title: 'Ajax Content' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::replace' + requirements: + _permission: 'access content' + +test_htmx.attachments.ajax: + path: '/htmx-test-attachments/ajax' + defaults: + _title: 'Ajax' + _form: '\Drupal\test_htmx\Form\HtmxTestAjaxForm' + requirements: + _permission: 'access content' diff --git a/core/modules/system/tests/modules/theme_suggestions_test/src/Hook/ThemeSuggestionsTestHooks.php b/core/modules/system/tests/modules/theme_suggestions_test/src/Hook/ThemeSuggestionsTestHooks.php index 8f5d5ee18a8f..ff4086a0c2a8 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 00b51bc72b57..1d953ff8c337 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 8e27524449f1..9b3fe6cd66a5 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 fc48756de51a..7bfc10ef0ef9 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 272ad65eff3a..f5d0c1501189 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 new file mode 100644 index 000000000000..e93fe8bb80fd --- /dev/null +++ b/core/modules/system/tests/modules/update_script_test/src/Hook/UpdateScriptTestRequirements.php @@ -0,0 +1,60 @@ +<?php + +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; + +/** + * Requirements for the Update Script Test module. + */ +class UpdateScriptTestRequirements { + + public function __construct( + protected readonly ConfigFactoryInterface $configFactory, + ) {} + + /** + * Implements hook_update_requirements(). + */ + #[Hook('update_requirements')] + public function update(): array { + $requirements = []; + // 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 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' => RequirementSeverity::Warning, + ]; + break; + + 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' => RequirementSeverity::Error, + ]; + break; + } + return $requirements; + } + + /** + * Implements hook_update_requirements_alter(). + */ + #[Hook('update_requirements_alter')] + public function updateAlter(array &$requirements): void { + 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_script_test/update_script_test.install b/core/modules/system/tests/modules/update_script_test/update_script_test.install index 5a641fd95644..c380aabd7ff7 100644 --- a/core/modules/system/tests/modules/update_script_test/update_script_test.install +++ b/core/modules/system/tests/modules/update_script_test/update_script_test.install @@ -8,48 +8,6 @@ declare(strict_types=1); /** - * Implements hook_requirements(). - */ -function update_script_test_requirements($phase): array { - $requirements = []; - - if ($phase == 'update') { - // Set a requirements warning or error when the test requests it. - $requirement_type = \Drupal::config('update_script_test.settings')->get('requirement_type'); - switch ($requirement_type) { - case REQUIREMENT_WARNING: - $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, - ]; - break; - - case REQUIREMENT_ERROR: - $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, - ]; - break; - } - } - - return $requirements; -} - -/** - * Implements hook_requirements_alter(). - */ -function update_script_test_requirements_alter(array &$requirements): void { - if (isset($requirements['update_script_test']) && $requirements['update_script_test']['severity'] === REQUIREMENT_ERROR) { - $requirements['update_script_test']['description'] = 'This is a requirements error provided by the update_script_test module.'; - } -} - -/** * Implements hook_update_last_removed(). */ function update_script_test_update_last_removed(): int { 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 new file mode 100644 index 000000000000..3199527bd05c --- /dev/null +++ b/core/modules/system/tests/modules/update_test_schema/src/Hook/UpdateTestSchemaRequirements.php @@ -0,0 +1,34 @@ +<?php + +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; + +/** + * Requirements for the Update Test Schema module. + */ +class UpdateTestSchemaRequirements { + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + $requirements['path_alias_test'] = [ + 'title' => 'Path alias test', + 'value' => 'Check a path alias for the admin page', + '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(), + ]), + ]; + return $requirements; + } + +} diff --git a/core/modules/system/tests/modules/update_test_schema/update_test_schema.install b/core/modules/system/tests/modules/update_test_schema/update_test_schema.install index 305d2cc7913e..0ad88c618bd0 100644 --- a/core/modules/system/tests/modules/update_test_schema/update_test_schema.install +++ b/core/modules/system/tests/modules/update_test_schema/update_test_schema.install @@ -12,25 +12,6 @@ use Drupal\Core\Url; use Drupal\Component\Render\FormattableMarkup; /** - * Implements hook_requirements(). - */ -function update_test_schema_requirements($phase): array { - $requirements = []; - if ($phase === 'runtime') { - $requirements['path_alias_test'] = [ - 'title' => 'Path alias test', - 'value' => 'Check a path alias for the admin page', - 'severity' => REQUIREMENT_INFO, - 'description' => new FormattableMarkup('Visit <a href=":link">the structure page</a> to do many useful things.', [ - ':link' => Url::fromRoute('system.admin_structure')->toString(), - ]), - ]; - } - - return $requirements; -} - -/** * Implements hook_schema(). * * The schema defined here will vary on state to allow for update hook testing. 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 cbef10d726ec..000000000000 --- 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 77eaa48575b0..f570d8031761 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 e130b89bf327..94221d8165fe 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 2a0886550191..fa6bda652deb 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/FileTransfer/TestFileTransfer.php b/core/modules/system/tests/src/Functional/FileTransfer/TestFileTransfer.php index 42605c114ffa..dcf46af35c9d 100644 --- a/core/modules/system/tests/src/Functional/FileTransfer/TestFileTransfer.php +++ b/core/modules/system/tests/src/Functional/FileTransfer/TestFileTransfer.php @@ -50,9 +50,11 @@ class TestFileTransfer extends FileTransfer { * Establishes a mock connection for file transfer. */ public function connect() { + // @phpstan-ignore property.deprecatedClass $this->connection = new MockTestConnection(); // Access the connection via the property. The property used to be set via a // magic method and this can cause problems if coded incorrectly. + // @phpstan-ignore property.deprecatedClass $this->connection->connectionString = 'test://' . urlencode($this->username) . ':' . urlencode($this->password) . "@$this->host:$this->port/"; } @@ -60,6 +62,7 @@ class TestFileTransfer extends FileTransfer { * Copies a file within the jailed environment. */ public function copyFileJailed($source, $destination) { + // @phpstan-ignore property.deprecatedClass $this->connection->run("copyFile $source $destination"); } @@ -67,6 +70,7 @@ class TestFileTransfer extends FileTransfer { * Removes a directory within the jailed environment. */ protected function removeDirectoryJailed($directory) { + // @phpstan-ignore property.deprecatedClass $this->connection->run("rmdir $directory"); } @@ -74,6 +78,7 @@ class TestFileTransfer extends FileTransfer { * Creates a directory within the jailed environment. */ public function createDirectoryJailed($directory) { + // @phpstan-ignore property.deprecatedClass $this->connection->run("mkdir $directory"); } @@ -81,6 +86,7 @@ class TestFileTransfer extends FileTransfer { * Removes a file within the jailed environment. */ public function removeFileJailed($destination) { + // @phpstan-ignore property.deprecatedClass $this->connection->run("rm $destination"); } diff --git a/core/modules/system/tests/src/Functional/Form/ElementTest.php b/core/modules/system/tests/src/Functional/Form/ElementTest.php index 4a9755fac7f5..0ebf9e4ce774 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 6812903dccc6..f45e45e61592 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 000000000000..59d69d1242cb --- /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 d6b44d4bb9d9..b5e208474668 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 7b3754bc34cb..d22f433a4147 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 aeace5bb488b..6d1b93cc50db 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 7f3af598cfb8..1a98b7f246fa 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/Render/PlaceholderMessageTest.php b/core/modules/system/tests/src/Functional/Render/PlaceholderMessageTest.php index 4b2779ead48a..63e325c51111 100644 --- a/core/modules/system/tests/src/Functional/Render/PlaceholderMessageTest.php +++ b/core/modules/system/tests/src/Functional/Render/PlaceholderMessageTest.php @@ -17,7 +17,10 @@ class PlaceholderMessageTest extends BrowserTestBase { /** * {@inheritdoc} */ - protected static $modules = ['render_placeholder_message_test']; + protected static $modules = [ + 'render_placeholder_message_test', + 'big_pipe_messages_test', + ]; /** * {@inheritdoc} diff --git a/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php b/core/modules/system/tests/src/Functional/SecurityAdvisories/SecurityAdvisoryTest.php index 388e83f6fcc4..b297647194a9 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 000000000000..84ab1ed9d5b4 --- /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 41d60b8a42a0..32487fa86045 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/Theme/FastTest.php b/core/modules/system/tests/src/Functional/Theme/FastTest.php deleted file mode 100644 index 5cbb7d8f277b..000000000000 --- 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 ffaaba1119aa..32ca94f100b0 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 f0f78b23c99c..5be7e48289f7 100644 --- a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php +++ b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Functional\UpdateSystem; use Drupal\Component\Serialization\Yaml; +use Drupal\Core\Extension\Requirement\RequirementSeverity; use Drupal\Core\Url; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\BrowserTestBase; @@ -149,7 +150,7 @@ class UpdateScriptTest extends BrowserTestBase { // First, run this test with pending updates to make sure they can be run // successfully. $this->drupalLogin($this->updateUser); - $update_script_test_config->set('requirement_type', REQUIREMENT_WARNING)->save(); + $update_script_test_config->set('requirement_type', RequirementSeverity::Warning->value)->save(); /** @var \Drupal\Core\Update\UpdateHookRegistry $update_registry */ $update_registry = \Drupal::service('update.update_hook_registry'); $update_registry->setInstalledVersion('update_script_test', $update_registry->getInstalledVersion('update_script_test') - 1); @@ -177,7 +178,7 @@ class UpdateScriptTest extends BrowserTestBase { // If there is a requirements error, it should be displayed even after // clicking the link to proceed (since the problem that triggered the error // has not been fixed). - $update_script_test_config->set('requirement_type', REQUIREMENT_ERROR)->save(); + $update_script_test_config->set('requirement_type', RequirementSeverity::Error->value)->save(); $this->drupalGet($this->updateUrl, ['external' => TRUE]); $this->assertSession()->pageTextContains('This is a requirements error provided by the update_script_test module.'); $this->clickLink('try again'); @@ -185,7 +186,7 @@ class UpdateScriptTest extends BrowserTestBase { // Ensure that changes to a module's requirements that would cause errors // are displayed correctly. - $update_script_test_config->set('requirement_type', REQUIREMENT_OK)->save(); + $update_script_test_config->set('requirement_type', RequirementSeverity::OK->value)->save(); \Drupal::state()->set('update_script_test.system_info_alter', ['dependencies' => ['a_module_that_does_not_exist']]); $this->drupalGet($this->updateUrl, ['external' => TRUE]); $this->assertSession()->responseContains('a_module_that_does_not_exist (Missing)'); diff --git a/core/modules/system/tests/src/Kernel/Block/StubPathMatcher.php b/core/modules/system/tests/src/Kernel/Block/StubPathMatcher.php new file mode 100644 index 000000000000..c2bd82782b05 --- /dev/null +++ b/core/modules/system/tests/src/Kernel/Block/StubPathMatcher.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Kernel\Block; + +use Drupal\Core\Path\PathMatcher; + +/** + * A class extending PatchMatcher for testing purposes. + */ +class StubPathMatcher extends PathMatcher { + + /** + * {@inheritdoc} + */ + public function isFrontPage(): bool { + return FALSE; + } + +} diff --git a/core/modules/system/tests/src/Kernel/Block/SystemMenuBlockTest.php b/core/modules/system/tests/src/Kernel/Block/SystemMenuBlockTest.php index e2ceead3475d..6d4ff90d5f46 100644 --- a/core/modules/system/tests/src/Kernel/Block/SystemMenuBlockTest.php +++ b/core/modules/system/tests/src/Kernel/Block/SystemMenuBlockTest.php @@ -341,6 +341,13 @@ class SystemMenuBlockTest extends KernelTestBase { * @dataProvider configExpandedTestCases */ public function testConfigExpanded($active_route, $menu_block_level, $expected_items): void { + // Replace the path.matcher service so it always returns FALSE when + // checking whether a route is the front page. Otherwise, the default + // service throws an exception when checking routes because all of these + // are mocked. + $service_definition = $this->container->getDefinition('path.matcher'); + $service_definition->setClass(StubPathMatcher::class); + $block = $this->blockManager->createInstance('system_menu_block:' . $this->menu->id(), [ 'region' => 'footer', 'id' => 'machine_name', diff --git a/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php b/core/modules/system/tests/src/Kernel/DateFormatAccessControlHandlerTest.php index 6c8c42da59e8..82d866e985e4 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 000000000000..630a3a997dde --- /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 2258b08bc494..c22529a72db0 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 96a02f8f1644..069a26c3eb5f 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 af027b48051e..e39e509cb141 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 79b9f52812e8..2cca7450089a 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 ab42b418125a..8a2cfe22f03c 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/sdc_theme_test/components/my-card/my-card.component.yml b/core/modules/system/tests/themes/sdc_theme_test/components/my-card/my-card.component.yml index 67d3a01657a8..4188362279bd 100644 --- a/core/modules/system/tests/themes/sdc_theme_test/components/my-card/my-card.component.yml +++ b/core/modules/system/tests/themes/sdc_theme_test/components/my-card/my-card.component.yml @@ -1,6 +1,11 @@ $schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json name: Card status: experimental +variants: + horizontal: + title: Horizontal + vertical: + title: Vertical props: type: object required: 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 80214af02d0b..d0b3b2b71bf1 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 4cc0a6f6eb61..82a88ce8a198 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 bf83b7067c52..b41086d059e2 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 04c00e36b6c2..28e54b11f71b 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 7747682a42ec..b751f6b52ba6 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 1d9654dd5050..511778daf200 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 8d9465f61a3f..7fcb764eac3e 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 1fb8b819fdc7..3499a404ac95 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 16b7549681c1..289fbbd332cd 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/toolbar/css/toolbar.theme.css b/core/modules/toolbar/css/toolbar.theme.css index ea108f658098..d6def4739400 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 2d76991e9dc5..f7956befe233 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 89f472f0eafa..00bd236973f6 100644 --- a/core/modules/toolbar/js/views/ToolbarVisualView.js +++ b/core/modules/toolbar/js/views/ToolbarVisualView.js @@ -210,7 +210,7 @@ // Deactivate the previous tab. $(this.model.previous('activeTab')) .removeClass('is-active') - .prop('aria-pressed', false); + .attr('aria-pressed', false); // Deactivate the previous tray. $(this.model.previous('activeTray')).removeClass('is-active'); @@ -222,7 +222,7 @@ $tab .addClass('is-active') // Mark the tab as pressed. - .prop('aria-pressed', true); + .attr('aria-pressed', true); const name = $tab.attr('data-toolbar-tray'); // Store the active tab name or remove the setting. const id = $tab.get(0).id; diff --git a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php index c315f9f6ebb0..dcf0ff6d79c3 100644 --- a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php +++ b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\Tests\toolbar\FunctionalJavascript; +use Behat\Mink\Element\NodeElement; use Drupal\FunctionalJavascriptTests\WebDriverTestBase; /** @@ -43,12 +44,22 @@ class ToolbarIntegrationTest extends WebDriverTestBase { $page = $this->getSession()->getPage(); // Test that it is possible to toggle the toolbar tray. - $content = $page->findLink('Content'); - $this->assertTrue($content->isVisible(), 'Toolbar tray is open by default.'); - $page->clickLink('Manage'); - $this->assertFalse($content->isVisible(), 'Toolbar tray is closed after clicking the "Manage" link.'); - $page->clickLink('Manage'); - $this->assertTrue($content->isVisible(), 'Toolbar tray is visible again after clicking the "Manage" button a second time.'); + $content_link = $page->findLink('Content'); + $manage_link = $page->find('css', '#toolbar-item-administration'); + + // Start with open tray. + $this->waitAndAssertAriaPressedState($manage_link, TRUE); + $this->assertTrue($content_link->isVisible(), 'Toolbar tray is open by default.'); + + // Click to close. + $manage_link->click(); + $this->waitAndAssertAriaPressedState($manage_link, FALSE); + $this->assertFalse($content_link->isVisible(), 'Toolbar tray is closed after clicking the "Manage" link.'); + + // Click to open. + $manage_link->click(); + $this->waitAndAssertAriaPressedState($manage_link, TRUE); + $this->assertTrue($content_link->isVisible(), 'Toolbar tray is visible again after clicking the "Manage" button a second time.'); // Test toggling the toolbar tray between horizontal and vertical. $tray = $page->findById('toolbar-item-administration-tray'); @@ -87,4 +98,33 @@ class ToolbarIntegrationTest extends WebDriverTestBase { $this->assertFalse($button->isVisible(), 'Orientation toggle from other tray is not visible'); } + /** + * Asserts that an element's `aria-pressed` attribute matches expected state. + * + * Uses `waitFor()` to pause until either the condition is met or the timeout + * of `1` second has passed. + * + * @param \Behat\Mink\Element\NodeElement $element + * The element to be tested. + * @param bool $expected + * The expected value of `aria-pressed`, as a boolean. + * + * @throws ExpectationFailedException + */ + private function waitAndAssertAriaPressedState(NodeElement $element, bool $expected): void { + $this->assertTrue( + $this + ->getSession() + ->getPage() + ->waitFor(1, function () use ($element, $expected): bool { + // Get boolean representation of `aria-pressed`. + // TRUE if `aria-pressed="true"`, FALSE otherwise. + $actual = $element->getAttribute('aria-pressed') == 'true'; + + // Exit `waitFor()` when $actual == $expected. + return $actual == $expected; + }) + ); + } + } diff --git a/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js b/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js index cbba417abe3a..0bed815f330b 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 000000000000..47dd0e6e50a4 --- /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 293882f01246..6577f7f1fc28 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,8 +78,7 @@ class UpdateHooks { $verbose = TRUE; break; } - \Drupal::moduleHandler()->loadInclude('update', 'install'); - $status = update_requirements('runtime'); + $status = \Drupal::moduleHandler()->invoke('update', 'runtime_requirements'); foreach (['core', 'contrib'] as $report_type) { $type = 'update_' . $report_type; // hook_requirements() supports render arrays therefore we need to @@ -89,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']); } } @@ -178,8 +178,6 @@ class UpdateHooks { \Drupal::moduleHandler()->loadInclude('update', 'inc', 'update.fetch'); _update_cron_notify(); } - // Clear garbage from disk. - update_clear_update_disk_cache(); } /** diff --git a/core/modules/update/src/Hook/UpdateRequirements.php b/core/modules/update/src/Hook/UpdateRequirements.php new file mode 100644 index 000000000000..2f51f205b1a0 --- /dev/null +++ b/core/modules/update/src/Hook/UpdateRequirements.php @@ -0,0 +1,162 @@ +<?php + +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; +use Drupal\Core\Url; +use Drupal\update\ProjectSecurityData; +use Drupal\update\ProjectSecurityRequirement; +use Drupal\update\UpdateFetcherInterface; +use Drupal\update\UpdateManagerInterface; + +/** + * Requirements for the update module. + */ +class UpdateRequirements { + + use StringTranslationTrait; + + public function __construct( + protected readonly ModuleHandlerInterface $moduleHandler, + ) {} + + /** + * Implements hook_runtime_requirements(). + * + * Describes the status of the site regarding available updates. If + * there is no update data, only one record will be returned, indicating that + * the status of core can't be determined. If data is available, there will + * be two records: one for core, and another for all of contrib (assuming + * there are any contributed modules or themes installed on the site). In + * addition to the fields expected by hook_requirements ('value', 'severity', + * and optionally 'description'), this array will contain a 'reason' + * attribute, which is an integer constant to indicate why the given status + * is being returned (UPDATE_NOT_SECURE, UPDATE_NOT_CURRENT, or + * UPDATE_UNKNOWN). This is used for generating the appropriate email + * notification messages during update_cron(), and might be useful for other + * modules that invoke update_runtime_requirements() to find out if the site + * is up to date or not. + * + * @see _update_message_text() + * @see _update_cron_notify() + * @see \Drupal\update\UpdateManagerInterface + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + if ($available = update_get_available(FALSE)) { + $this->moduleHandler->loadInclude('update', 'inc', 'update.compare'); + $data = update_calculate_project_data($available); + // First, populate the requirements for core: + $requirements['update_core'] = $this->requirementCheck($data['drupal'], 'core'); + if (!empty($available['drupal']['releases'])) { + $security_data = ProjectSecurityData::createFromProjectDataAndReleases($data['drupal'], $available['drupal']['releases'])->getCoverageInfo(); + if ($core_coverage_requirement = ProjectSecurityRequirement::createFromProjectDataAndSecurityCoverageInfo($data['drupal'], $security_data)->getRequirement()) { + $requirements['coverage_core'] = $core_coverage_requirement; + } + } + + // We don't want to check drupal a second time. + unset($data['drupal']); + if (!empty($data)) { + // Now, sort our $data array based on each project's status. The + // status constants are numbered in the right order of precedence, so + // we just need to make sure the projects are sorted in ascending + // order of status, and we can look at the first project we find. + uasort($data, '_update_project_status_sort'); + $first_project = reset($data); + $requirements['update_contrib'] = $this->requirementCheck($first_project, 'contrib'); + } + } + 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'] = RequirementSeverity::Warning; + $requirements['update_core']['reason'] = UpdateFetcherInterface::UNKNOWN; + $requirements['update_core']['description'] = _update_no_data(); + } + return $requirements; + } + + /** + * Fills in the requirements array. + * + * This is shared for both core and contrib to generate the right elements in + * the array for hook_runtime_requirements(). + * + * @param array $project + * Array of information about the project we're testing as returned by + * update_calculate_project_data(). + * @param string $type + * What kind of project this is ('core' or 'contrib'). + * + * @return array + * An array to be included in the nested $requirements array. + * + * @see hook_requirements() + * @see update_requirements() + * @see update_calculate_project_data() + */ + protected function requirementCheck($project, $type): array { + $requirement = []; + if ($type == 'core') { + $requirement['title'] = $this->t('Drupal core update status'); + } + else { + $requirement['title'] = $this->t('Module and theme update status'); + } + $status = $project['status']; + if ($status != UpdateManagerInterface::CURRENT) { + $requirement['reason'] = $status; + $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. + $requirement['description'][] = ['#markup' => _update_message_text($type, $status)]; + if (!in_array($status, [UpdateFetcherInterface::UNKNOWN, UpdateFetcherInterface::NOT_CHECKED, UpdateFetcherInterface::NOT_FETCHED, UpdateFetcherInterface::FETCH_PENDING])) { + $requirement['description'][] = ['#prefix' => ' ', '#markup' => $this->t('See the <a href=":available_updates">available updates</a> page for more information.', [':available_updates' => Url::fromRoute('update.status')->toString()])]; + } + } + switch ($status) { + case UpdateManagerInterface::NOT_SECURE: + $requirement_label = $this->t('Not secure!'); + break; + + case UpdateManagerInterface::REVOKED: + $requirement_label = $this->t('Revoked!'); + break; + + case UpdateManagerInterface::NOT_SUPPORTED: + $requirement_label = $this->t('Unsupported release'); + break; + + case UpdateManagerInterface::NOT_CURRENT: + $requirement_label = $this->t('Out of date'); + $requirement['severity'] = RequirementSeverity::Warning; + break; + + case UpdateFetcherInterface::UNKNOWN: + case UpdateFetcherInterface::NOT_CHECKED: + case UpdateFetcherInterface::NOT_FETCHED: + case UpdateFetcherInterface::FETCH_PENDING: + $requirement_label = $project['reason'] ?? $this->t('Can not determine status'); + $requirement['severity'] = RequirementSeverity::Warning; + break; + + default: + $requirement_label = $this->t('Up to date'); + } + if ($status != UpdateManagerInterface::CURRENT && $type == 'core' && isset($project['recommended'])) { + $requirement_label .= ' ' . $this->t('(version @version available)', ['@version' => $project['recommended']]); + } + $requirement['value'] = Link::fromTextAndUrl($requirement_label, Url::fromRoute('update.status'))->toString(); + return $requirement; + } + +} diff --git a/core/modules/update/src/ProjectSecurityRequirement.php b/core/modules/update/src/ProjectSecurityRequirement.php index cc6fed789fea..331c65537c85 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/src/UpdateRoot.php b/core/modules/update/src/UpdateRoot.php index a2f1619d6ea6..604e9ff0f529 100644 --- a/core/modules/update/src/UpdateRoot.php +++ b/core/modules/update/src/UpdateRoot.php @@ -6,7 +6,12 @@ use Drupal\Core\DrupalKernelInterface; use Symfony\Component\HttpFoundation\RequestStack; /** - * Gets the root path used by the Update Manager to install or update projects. + * Gets the root path used by the legacy Update Manager. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. Use composer to manage the code for your site. + * + * @see https://www.drupal.org/node/3522119 */ class UpdateRoot { @@ -32,7 +37,7 @@ class UpdateRoot { protected $updateRoot; /** - * Constructs an UpdateRootFactory instance. + * Constructs an UpdateRoot instance. * * @param \Drupal\Core\DrupalKernelInterface $drupal_kernel * The Drupal kernel. @@ -40,6 +45,7 @@ class UpdateRoot { * The request stack. */ public function __construct(DrupalKernelInterface $drupal_kernel, RequestStack $request_stack) { + @trigger_error(__CLASS__ . ' is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3522119', E_USER_DEPRECATED); $this->drupalKernel = $drupal_kernel; $this->requestStack = $request_stack; } diff --git a/core/modules/update/tests/src/Functional/UpdateManagerTest.php b/core/modules/update/tests/src/Functional/UpdateManagerTest.php new file mode 100644 index 000000000000..a69e06a72fdd --- /dev/null +++ b/core/modules/update/tests/src/Functional/UpdateManagerTest.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\update\Functional; + +/** + * Tests legacy Update Manager functionality of the Update Status module. + * + * @group legacy + * @group update + */ +class UpdateManagerTest extends UpdateTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Checks that clearing the disk cache works. + */ + public function testClearDiskCache(): void { + $directories = [ + _update_manager_cache_directory(FALSE), + _update_manager_extract_directory(FALSE), + ]; + // Check that update directories does not exists. + foreach ($directories as $directory) { + $this->assertDirectoryDoesNotExist($directory); + } + + // Method must not fail if update directories do not exists. + update_clear_update_disk_cache(); + } + +} diff --git a/core/modules/update/tests/src/Functional/UpdateMiscTest.php b/core/modules/update/tests/src/Functional/UpdateMiscTest.php index 5b544ea36b0e..3a06d965ee5e 100644 --- a/core/modules/update/tests/src/Functional/UpdateMiscTest.php +++ b/core/modules/update/tests/src/Functional/UpdateMiscTest.php @@ -41,23 +41,6 @@ class UpdateMiscTest extends UpdateTestBase { } /** - * Checks that clearing the disk cache works. - */ - public function testClearDiskCache(): void { - $directories = [ - _update_manager_cache_directory(FALSE), - _update_manager_extract_directory(FALSE), - ]; - // Check that update directories does not exists. - foreach ($directories as $directory) { - $this->assertDirectoryDoesNotExist($directory); - } - - // Method must not fail if update directories do not exists. - update_clear_update_disk_cache(); - } - - /** * Tests the Update Status module when the update server returns 503 errors. */ public function testServiceUnavailable(): void { diff --git a/core/modules/update/tests/src/Functional/UpdateSemverContribTestBase.php b/core/modules/update/tests/src/Functional/UpdateSemverContribTestBase.php index 2f5c7c038b93..bd554c0e8503 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 b9d3a46a68e8..f336562c4794 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/tests/src/Kernel/UpdateDeleteFileIfStaleTest.php b/core/modules/update/tests/src/Kernel/UpdateDeleteFileIfStaleTest.php index ff5a8b02d457..1e33d81d6141 100644 --- a/core/modules/update/tests/src/Kernel/UpdateDeleteFileIfStaleTest.php +++ b/core/modules/update/tests/src/Kernel/UpdateDeleteFileIfStaleTest.php @@ -10,6 +10,7 @@ use Drupal\KernelTests\KernelTestBase; * Tests the update_delete_file_if_stale() function. * * @group update + * @group legacy */ class UpdateDeleteFileIfStaleTest extends KernelTestBase { diff --git a/core/modules/update/update.authorize.inc b/core/modules/update/update.authorize.inc index 64f8fe7b014a..cab253303bbb 100644 --- a/core/modules/update/update.authorize.inc +++ b/core/modules/update/update.authorize.inc @@ -33,7 +33,7 @@ use Drupal\Core\Url; */ #[ProceduralHookScanStop] function update_authorize_run_update($filetransfer, $projects) { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); $batch_builder = (new BatchBuilder()) ->setFile(\Drupal::service('extension.list.module')->getPath('update') . '/update.authorize.inc') @@ -79,7 +79,7 @@ function update_authorize_run_update($filetransfer, $projects) { * Reference to an array used for Batch API storage. */ function update_authorize_batch_copy_project($project, $updater_name, $local_url, $filetransfer, &$context): void { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); // Initialize some variables in the Batch API $context array. if (!isset($context['results']['log'])) { @@ -100,6 +100,7 @@ function update_authorize_batch_copy_project($project, $updater_name, $local_url // though the connection itself is now gone. So, although it's ugly, we have // to unset the connection variable at this point so that the FileTransfer // object will re-initiate the actual connection. + // @phpstan-ignore property.deprecatedClass unset($filetransfer->connection); if (!empty($context['results']['log'][$project]['#abort'])) { @@ -107,6 +108,7 @@ function update_authorize_batch_copy_project($project, $updater_name, $local_url return; } + // @phpstan-ignore getDeprecatedService.deprecated $updater = new $updater_name($local_url, \Drupal::getContainer()->get('update.root')); try { @@ -149,7 +151,7 @@ function update_authorize_batch_copy_project($project, $updater_name, $local_url * An associative array of results from the batch operation. */ function update_authorize_update_batch_finished($success, $results): void { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); foreach ($results['log'] as $messages) { if (!empty($messages['#abort'])) { @@ -233,7 +235,7 @@ function update_authorize_update_batch_finished($success, $results): void { * if there were errors. Defaults to TRUE. */ function _update_batch_create_message(&$project_results, $message, $success = TRUE): void { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); $project_results[] = ['message' => $message, 'success' => $success]; } @@ -252,7 +254,7 @@ function _update_batch_create_message(&$project_results, $message, $success = TR * @see update_storage_clear() */ function _update_authorize_clear_update_status(): void { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); \Drupal::keyValueExpirable('update')->deleteAll(); \Drupal::keyValueExpirable('update_available_release')->deleteAll(); diff --git a/core/modules/update/update.fetch.inc b/core/modules/update/update.fetch.inc index 296dfbd3d06d..12295b97d986 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,14 +20,13 @@ use Drupal\update\UpdateManagerInterface; #[ProceduralHookScanStop] function _update_cron_notify(): void { $update_config = \Drupal::config('update.settings'); - \Drupal::moduleHandler()->loadInclude('update', 'install'); - $status = update_requirements('runtime'); + $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/update/update.install b/core/modules/update/update.install index 3ad55265aab1..ccd73e993bc6 100644 --- a/core/modules/update/update.install +++ b/core/modules/update/update.install @@ -6,70 +6,6 @@ */ use Drupal\Core\Hook\Attribute\ProceduralHookScanStop; -use Drupal\Core\Link; -use Drupal\Core\Url; -use Drupal\update\ProjectSecurityData; -use Drupal\update\ProjectSecurityRequirement; -use Drupal\update\UpdateFetcherInterface; -use Drupal\update\UpdateManagerInterface; - -/** - * Implements hook_requirements(). - * - * Describes the status of the site regarding available updates. If - * there is no update data, only one record will be returned, indicating that - * the status of core can't be determined. If data is available, there will be - * two records: one for core, and another for all of contrib (assuming there - * are any contributed modules or themes installed on the site). In addition to - * the fields expected by hook_requirements ('value', 'severity', and - * optionally 'description'), this array will contain a 'reason' attribute, - * which is an integer constant to indicate why the given status is being - * returned (UPDATE_NOT_SECURE, UPDATE_NOT_CURRENT, or UPDATE_UNKNOWN). This - * is used for generating the appropriate email notification messages during - * update_cron(), and might be useful for other modules that invoke - * update_requirements() to find out if the site is up to date or not. - * - * @see _update_message_text() - * @see _update_cron_notify() - * @see \Drupal\update\UpdateManagerInterface - */ -function update_requirements($phase): array { - $requirements = []; - if ($phase == 'runtime') { - if ($available = update_get_available(FALSE)) { - \Drupal::moduleHandler()->loadInclude('update', 'inc', 'update.compare'); - $data = update_calculate_project_data($available); - // First, populate the requirements for core: - $requirements['update_core'] = _update_requirement_check($data['drupal'], 'core'); - if (!empty($available['drupal']['releases'])) { - $security_data = ProjectSecurityData::createFromProjectDataAndReleases($data['drupal'], $available['drupal']['releases'])->getCoverageInfo(); - if ($core_coverage_requirement = ProjectSecurityRequirement::createFromProjectDataAndSecurityCoverageInfo($data['drupal'], $security_data)->getRequirement()) { - $requirements['coverage_core'] = $core_coverage_requirement; - } - } - - // We don't want to check drupal a second time. - unset($data['drupal']); - if (!empty($data)) { - // Now, sort our $data array based on each project's status. The - // status constants are numbered in the right order of precedence, so - // we just need to make sure the projects are sorted in ascending - // order of status, and we can look at the first project we find. - uasort($data, '_update_project_status_sort'); - $first_project = reset($data); - $requirements['update_contrib'] = _update_requirement_check($first_project, 'contrib'); - } - } - else { - $requirements['update_core']['title'] = t('Drupal core update status'); - $requirements['update_core']['value'] = t('No update data available'); - $requirements['update_core']['severity'] = REQUIREMENT_WARNING; - $requirements['update_core']['reason'] = UpdateFetcherInterface::UNKNOWN; - $requirements['update_core']['description'] = _update_no_data(); - } - } - return $requirements; -} /** * Implements hook_install(). @@ -92,81 +28,6 @@ function update_uninstall(): void { } /** - * Fills in the requirements array. - * - * This is shared for both core and contrib to generate the right elements in - * the array for hook_requirements(). - * - * @param array $project - * Array of information about the project we're testing as returned by - * update_calculate_project_data(). - * @param string $type - * What kind of project this is ('core' or 'contrib'). - * - * @return array - * An array to be included in the nested $requirements array. - * - * @see hook_requirements() - * @see update_requirements() - * @see update_calculate_project_data() - */ -function _update_requirement_check($project, $type): array { - $requirement = []; - if ($type == 'core') { - $requirement['title'] = t('Drupal core update status'); - } - else { - $requirement['title'] = t('Module and theme update status'); - } - $status = $project['status']; - if ($status != UpdateManagerInterface::CURRENT) { - $requirement['reason'] = $status; - $requirement['severity'] = REQUIREMENT_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. - $requirement['description'][] = ['#markup' => _update_message_text($type, $status)]; - if (!in_array($status, [UpdateFetcherInterface::UNKNOWN, UpdateFetcherInterface::NOT_CHECKED, UpdateFetcherInterface::NOT_FETCHED, UpdateFetcherInterface::FETCH_PENDING])) { - $requirement['description'][] = ['#prefix' => ' ', '#markup' => t('See the <a href=":available_updates">available updates</a> page for more information.', [':available_updates' => Url::fromRoute('update.status')->toString()])]; - } - } - switch ($status) { - case UpdateManagerInterface::NOT_SECURE: - $requirement_label = t('Not secure!'); - break; - - case UpdateManagerInterface::REVOKED: - $requirement_label = t('Revoked!'); - break; - - case UpdateManagerInterface::NOT_SUPPORTED: - $requirement_label = t('Unsupported release'); - break; - - case UpdateManagerInterface::NOT_CURRENT: - $requirement_label = t('Out of date'); - $requirement['severity'] = REQUIREMENT_WARNING; - break; - - case UpdateFetcherInterface::UNKNOWN: - case UpdateFetcherInterface::NOT_CHECKED: - case UpdateFetcherInterface::NOT_FETCHED: - case UpdateFetcherInterface::FETCH_PENDING: - $requirement_label = $project['reason'] ?? t('Can not determine status'); - $requirement['severity'] = REQUIREMENT_WARNING; - break; - - default: - $requirement_label = t('Up to date'); - } - if ($status != UpdateManagerInterface::CURRENT && $type == 'core' && isset($project['recommended'])) { - $requirement_label .= ' ' . t('(version @version available)', ['@version' => $project['recommended']]); - } - $requirement['value'] = Link::fromTextAndUrl($requirement_label, Url::fromRoute('update.status'))->toString(); - return $requirement; -} - -/** * Implements hook_update_last_removed(). */ function update_update_last_removed(): int { diff --git a/core/modules/update/update.manager.inc b/core/modules/update/update.manager.inc index f4b18321002b..de29e229dd46 100644 --- a/core/modules/update/update.manager.inc +++ b/core/modules/update/update.manager.inc @@ -26,7 +26,7 @@ use Psr\Http\Client\ClientExceptionInterface; */ #[ProceduralHookScanStop] function _update_manager_check_backends(&$form, $operation) { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); // If file transfers will be performed locally, we do not need to display any // warnings or notices to the user and should automatically continue the @@ -94,7 +94,7 @@ function _update_manager_check_backends(&$form, $operation) { * @throws Exception */ function update_manager_archive_extract($file, $directory) { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); /** @var \Drupal\Core\Archiver\ArchiverInterface $archiver */ $archiver = \Drupal::service('plugin.manager.archiver')->getInstance([ @@ -145,7 +145,7 @@ function update_manager_archive_extract($file, $directory) { * are no errors, it will be an empty array. */ function update_manager_archive_verify($project, $archive_file, $directory) { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); return \Drupal::moduleHandler()->invokeAllDeprecated('There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', 'verify_update_archive', [$project, $archive_file, $directory]); } @@ -162,7 +162,7 @@ function update_manager_archive_verify($project, $archive_file, $directory) { * Path to local file, or FALSE if it could not be retrieved. */ function update_manager_file_get($url) { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); $parsed_url = parse_url($url); $remote_schemes = ['http', 'https', 'ftp', 'ftps', 'smb', 'nfs']; @@ -172,9 +172,11 @@ function update_manager_file_get($url) { } // Check the cache and download the file if needed. + // @phpstan-ignore function.deprecated $cache_directory = _update_manager_cache_directory(); $local = $cache_directory . '/' . \Drupal::service('file_system')->basename($parsed_url['path']); + // @phpstan-ignore function.deprecated if (!file_exists($local) || update_delete_file_if_stale($local)) { try { $data = (string) \Drupal::httpClient()->get($url)->getBody(); @@ -212,7 +214,7 @@ function update_manager_file_get($url) { * @see update_manager_download_page() */ function update_manager_batch_project_get($project, $url, &$context): void { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); // This is here to show the user that we are in the process of downloading. if (!isset($context['sandbox']['started'])) { @@ -229,6 +231,7 @@ function update_manager_batch_project_get($project, $url, &$context): void { } // Extract it. + // @phpstan-ignore function.deprecated $extract_directory = _update_manager_extract_directory(); try { update_manager_archive_extract($local_cache, $extract_directory); @@ -274,7 +277,7 @@ function update_manager_batch_project_get($project, $url, &$context): void { * @see install_check_requirements() */ function update_manager_local_transfers_allowed() { - @trigger_error(__METHOD__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3512364', E_USER_DEPRECATED); $file_system = \Drupal::service('file_system'); // Compare the owner of a webserver-created temporary file to the owner of diff --git a/core/modules/update/update.module b/core/modules/update/update.module index e06ac21ea317..89e94fe7b1d7 100644 --- a/core/modules/update/update.module +++ b/core/modules/update/update.module @@ -276,8 +276,15 @@ function update_storage_clear(): void { * * @return string * An eight character string uniquely identifying this Drupal installation. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. Use composer to manage the code for your site. + * + * @see https://www.drupal.org/node/3522119 */ function _update_manager_unique_identifier() { + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3522119', E_USER_DEPRECATED); + static $id; if (!isset($id)) { $id = substr(hash('sha256', Settings::getHashSalt()), 0, 8); @@ -295,8 +302,15 @@ function _update_manager_unique_identifier() { * @return string * The full path to the temporary directory where update file archives should * be extracted. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. Use composer to manage the code for your site. + * + * @see https://www.drupal.org/node/3522119 */ function _update_manager_extract_directory($create = TRUE) { + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3522119', E_USER_DEPRECATED); + static $directory; if (!isset($directory)) { $directory = 'temporary://update-extraction-' . _update_manager_unique_identifier(); @@ -317,8 +331,15 @@ function _update_manager_extract_directory($create = TRUE) { * @return string * The full path to the temporary directory where update file archives should * be cached. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. Use composer to manage the code for your site. + * + * @see https://www.drupal.org/node/3522119 */ function _update_manager_cache_directory($create = TRUE) { + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3522119', E_USER_DEPRECATED); + static $directory; if (!isset($directory)) { $directory = 'temporary://update-cache-' . _update_manager_unique_identifier(); @@ -331,8 +352,15 @@ function _update_manager_cache_directory($create = TRUE) { /** * Clears the temporary files and directories based on file age from disk. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. Use composer to manage the code for your site. + * + * @see https://www.drupal.org/node/3522119 */ function update_clear_update_disk_cache(): void { + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3522119', E_USER_DEPRECATED); + // List of update module cache directories. Do not create the directories if // they do not exist. $directories = [ @@ -368,8 +396,15 @@ function update_clear_update_disk_cache(): void { * * @return bool * TRUE if the file is stale and deleted successfully, FALSE otherwise. + * + * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no + * replacement. Use composer to manage the code for your site. + * + * @see https://www.drupal.org/node/3522119 */ function update_delete_file_if_stale($path) { + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3522119', E_USER_DEPRECATED); + if (file_exists($path)) { $filectime = filectime($path); $max_age = \Drupal::config('system.file')->get('temporary_maximum_age'); diff --git a/core/modules/update/update.post_update.php b/core/modules/update/update.post_update.php index 3462899d989b..34ddba396858 100644 --- a/core/modules/update/update.post_update.php +++ b/core/modules/update/update.post_update.php @@ -5,6 +5,8 @@ * Post update functions for Update Status. */ +use Drupal\Core\Site\Settings; + /** * Implements hook_removed_post_updates(). */ @@ -14,3 +16,24 @@ function update_remove_post_updates() { 'update_post_update_set_blank_fetch_url_to_null' => '11.0.0', ]; } + +/** + * Removes the legacy 'Update Manager' disk cache. + */ +function update_post_update_clear_disk_cache(): void { + // @see _update_manager_unique_id() + $id = substr(hash('sha256', Settings::getHashSalt()), 0, 8); + // List of legacy 'Update Manager' cache directories. + $directories = [ + // @see _update_manager_cache_directory() + "temporary://update-cache-$id", + // @see _update_manager_extract_directory() + "temporary://update-extraction-$id", + ]; + foreach ($directories as $directory) { + if (is_dir($directory)) { + \Drupal::service('file_system')->deleteRecursive($directory); + } + } + +} diff --git a/core/modules/update/update.services.yml b/core/modules/update/update.services.yml index 465df135529a..bebf0398151c 100644 --- a/core/modules/update/update.services.yml +++ b/core/modules/update/update.services.yml @@ -21,6 +21,7 @@ services: update.root: class: Drupal\update\UpdateRoot arguments: ['@kernel', '@request_stack'] + deprecated: The "%service_id%" service is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3522119 logger.channel.update: parent: logger.channel_base arguments: [ 'update' ] diff --git a/core/modules/user/config/optional/views.view.user_admin_people.yml b/core/modules/user/config/optional/views.view.user_admin_people.yml index 5f9c0667cace..001c3e20cfb9 100644 --- a/core/modules/user/config/optional/views.view.user_admin_people.yml +++ b/core/modules/user/config/optional/views.view.user_admin_people.yml @@ -851,6 +851,7 @@ display: sticky: false summary: '' empty_table: true + class: '' row: type: fields query: diff --git a/core/modules/user/config/schema/user.schema.yml b/core/modules/user/config/schema/user.schema.yml index ac54b6986d7d..d58cb3dc4ea2 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 881244f6f53c..d0c5e8d2d9b2 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 new file mode 100644 index 000000000000..46155e55e3cb --- /dev/null +++ b/core/modules/user/src/Hook/UserRequirements.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +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; + +/** + * Requirements for the User module. + */ +class UserRequirements { + + use StringTranslationTrait; + + public function __construct( + protected readonly EntityTypeManagerInterface $entityTypeManager, + protected readonly Connection $connection, + ) {} + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + + $result = (bool) $this->entityTypeManager->getStorage('user')->getQuery() + ->accessCheck(FALSE) + ->condition('uid', 0) + ->range(0, 1) + ->execute(); + + if ($result === FALSE) { + $requirements['anonymous user'] = [ + 'title' => $this->t('Anonymous user'), + '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' => RequirementSeverity::Warning, + ]; + } + + $query = $this->connection->select('users_field_data'); + $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(); + + if ($conflicts > 0) { + $requirements['conflicting emails'] = [ + 'title' => $this->t('Conflicting user emails'), + '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' => RequirementSeverity::Warning, + ]; + } + + return $requirements; + } + +} diff --git a/core/modules/user/src/Plugin/views/field/Roles.php b/core/modules/user/src/Plugin/views/field/Roles.php index 096e161a3f9e..bc1ab4fb2841 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 f49008597f10..106199f413fe 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/modules/user_test_views/test_views/views.view.test_user_bulk_form.yml b/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_bulk_form.yml index 7858370e137d..24e851e3f2a7 100644 --- a/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_bulk_form.yml +++ b/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_bulk_form.yml @@ -19,6 +19,8 @@ display: display_options: style: type: table + options: + class: '' row: type: fields fields: diff --git a/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_fields_access.yml b/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_fields_access.yml index ce16bacc3aae..c0b9b9d7879e 100644 --- a/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_fields_access.yml +++ b/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_fields_access.yml @@ -63,6 +63,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/user/tests/src/Functional/UserEditTest.php b/core/modules/user/tests/src/Functional/UserEditTest.php index f9a8dfb1a75c..ff86fa249084 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/Kernel/UserRequirementsTest.php b/core/modules/user/tests/src/Kernel/UserRequirementsTest.php index 88b406bc30e4..746370a15d61 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; /** @@ -22,12 +23,20 @@ class UserRequirementsTest extends KernelTestBase { protected static $modules = ['user']; /** + * Module handler for invoking user requirements. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** * {@inheritdoc} */ protected function setUp(): void { parent::setUp(); - $this->container->get('module_handler')->loadInclude('user', 'install'); + $this->moduleHandler = $this->container->get('module_handler'); $this->installEntitySchema('user'); + include_once $this->root . '/core/includes/install.inc'; } /** @@ -37,13 +46,13 @@ class UserRequirementsTest extends KernelTestBase { */ public function testConflictingUserEmails(): void { - $output = \user_requirements('runtime'); + $output = $this->moduleHandler->invoke('user', 'runtime_requirements'); $this->assertArrayNotHasKey('conflicting emails', $output); $this->createUser([], 'User A', FALSE, ['mail' => 'unique@example.com']); $this->createUser([], 'User B', FALSE, ['mail' => 'UNIQUE@example.com']); - $output = \user_requirements('runtime'); + $output = $this->moduleHandler->invoke('user', 'runtime_requirements'); $this->assertArrayHasKey('conflicting emails', $output); } @@ -52,13 +61,30 @@ class UserRequirementsTest extends KernelTestBase { */ public function testBlankUserEmails(): void { - $output = \user_requirements('runtime'); + $output = $this->moduleHandler->invoke('user', 'runtime_requirements'); $this->assertArrayNotHasKey('conflicting emails', $output); $this->createUser([], 'User A', FALSE, ['mail' => '']); $this->createUser([], 'User B', FALSE, ['mail' => '']); - $output = \user_requirements('runtime'); + $output = $this->moduleHandler->invoke('user', 'runtime_requirements'); + $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/user/user.install b/core/modules/user/user.install index 99ede9e59b76..747e61253226 100644 --- a/core/modules/user/user.install +++ b/core/modules/user/user.install @@ -92,51 +92,6 @@ function user_install(): void { } /** - * Implements hook_requirements(). - */ -function user_requirements($phase): array { - if ($phase !== 'runtime') { - return []; - } - $return = []; - - $result = (bool) \Drupal::entityQuery('user') - ->accessCheck(FALSE) - ->condition('uid', 0) - ->range(0, 1) - ->execute(); - - if ($result === FALSE) { - $return['anonymous user'] = [ - 'title' => t('Anonymous user'), - 'description' => 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, - ]; - } - - $query = \Drupal::database()->select('users_field_data'); - $query->addExpression('LOWER(mail)', 'lower_mail'); - $query->isNotNull('mail'); - $query->groupBy('lower_mail'); - $query->having('COUNT(uid) > :matches', [':matches' => 1]); - $conflicts = $query->countQuery()->execute()->fetchField(); - - if ($conflicts > 0) { - $return['conflicting emails'] = [ - 'title' => t('Conflicting user emails'), - 'description' => 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, - ]; - } - - return $return; -} - -/** * Implements hook_update_last_removed(). */ function user_update_last_removed(): int { 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 0e011d73dc15..31c6b2f6479e 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 eb360227e029..072dbc5b87e0 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/config/schema/views.schema.yml b/core/modules/views/config/schema/views.schema.yml index 8ddd73931f2c..12187c6b9465 100644 --- a/core/modules/views/config/schema/views.schema.yml +++ b/core/modules/views/config/schema/views.schema.yml @@ -140,16 +140,29 @@ views.view.*: views_block: type: block_settings label: 'View block' + constraints: + FullyValidatable: ~ mapping: views_label: type: label label: 'Title' + requiredKey: false items_per_page: - type: string + type: integer label: 'Items per block' + constraints: + Range: + min: 1 + # Will only be respected if the associated View is configured to allow this to be overridden. + # @see \Drupal\views\Plugin\views\display\Block::blockForm() + requiredKey: false + # NULL to use the default defined by the view. + nullable: true block.settings.views_block:*: type: views_block + constraints: + FullyValidatable: ~ block.settings.views_exposed_filter_block:*: type: views_block diff --git a/core/modules/views/js/ajax_view.js b/core/modules/views/js/ajax_view.js index dd3da9b83507..8e646697d83e 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 cd1b2a0a42e1..f6bb32cec877 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/FieldViewsDataProvider.php b/core/modules/views/src/FieldViewsDataProvider.php index c0b9d50b2d9c..fad34ff460e2 100644 --- a/core/modules/views/src/FieldViewsDataProvider.php +++ b/core/modules/views/src/FieldViewsDataProvider.php @@ -139,8 +139,8 @@ class FieldViewsDataProvider { if (!empty($translatable_configs) && empty($untranslatable_configs)) { $translation_join_type = 'language'; } - // If the field is translatable only on certain bundles, there will be a join - // on langcode OR bundle name. + // If the field is translatable only on certain bundles, there will be a + // join on langcode OR bundle name. elseif (!empty($translatable_configs) && !empty($untranslatable_configs)) { foreach ($untranslatable_configs as $config) { $untranslatable_config_bundles[] = $config->getTargetBundle(); @@ -268,8 +268,8 @@ class FieldViewsDataProvider { 'help' => $this->t('Appears in: @bundles.', ['@bundles' => implode(', ', $bundles_names)]), ]; - // Go through and create a list of aliases for all possible combinations of - // entity type + name. + // Go through and create a list of aliases for all possible combinations + // of entity type + name. $aliases = []; $also_known = []; foreach ($all_labels as $label_name => $true) { @@ -296,15 +296,15 @@ class FieldViewsDataProvider { } if ($aliases) { $data[$table_alias][$field_alias]['aliases'] = $aliases; - // The $also_known variable contains markup that is HTML escaped and that - // loses safeness when imploded. The help text is used in #description - // and therefore XSS admin filtered by default. Escaped HTML is not - // altered by XSS filtering, therefore it is safe to just concatenate the - // strings. Afterwards we mark the entire string as safe, so it won't be - // escaped, no matter where it is used. + // The $also_known variable contains markup that is HTML escaped and + // that loses safeness when imploded. The help text is used in + // #description and therefore XSS admin filtered by default. Escaped + // HTML is not altered by XSS filtering, therefore it is safe to just + // concatenate the strings. Afterwards we mark the entire string as + // safe, so it won't be escaped, no matter where it is used. // Considering the dual use of this help data (both as metadata and as - // help text), other patterns such as use of #markup would not be correct - // here. + // help text), other patterns such as use of #markup would not be + // correct here. $data[$table_alias][$field_alias]['help'] = Markup::create($data[$table_alias][$field_alias]['help'] . ' ' . $this->t('Also known as:') . ' ' . implode(', ', $also_known)); } @@ -328,7 +328,8 @@ class FieldViewsDataProvider { foreach ($field_columns as $column => $attributes) { $allow_sort = TRUE; - // Identify likely filters and arguments for each column based on field type. + // Identify likely filters and arguments for each column based on field + // type. switch ($attributes['type']) { case 'int': case 'mediumint': @@ -387,8 +388,8 @@ class FieldViewsDataProvider { 'help' => $this->t('Appears in: @bundles.', ['@bundles' => implode(', ', $bundles_names)]), ]; - // Go through and create a list of aliases for all possible combinations of - // entity type + name. + // Go through and create a list of aliases for all possible combinations + // of entity type + name. $aliases = []; $also_known = []; foreach ($all_labels as $label_name => $true) { diff --git a/core/modules/views/src/Form/ViewsExposedForm.php b/core/modules/views/src/Form/ViewsExposedForm.php index d68b1dd5363c..9f90160ff55f 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 6facc63de6de..b309887723c3 100644 --- a/core/modules/views/src/Hook/ViewsHooks.php +++ b/core/modules/views/src/Hook/ViewsHooks.php @@ -2,6 +2,7 @@ namespace Drupal\views\Hook; +use Drupal\block\BlockInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\views\ViewsConfigUpdater; use Drupal\views\ViewEntityInterface; @@ -378,4 +379,19 @@ class ViewsHooks { $config_updater->updateAll($view); } + /** + * Implements hook_ENTITY_TYPE_presave() for blocks. + */ + #[Hook('block_presave')] + public function blockPresave(BlockInterface $block): void { + if (str_starts_with($block->getPluginId(), 'views_block:')) { + $settings = $block->get('settings'); + if (isset($settings['items_per_page']) && $settings['items_per_page'] === 'none') { + @trigger_error('Saving a views block with "none" items per page is deprecated in drupal:11.2.0 and removed in drupal:12.0.0. To use the items per page defined by the view, use NULL. See https://www.drupal.org/node/3522240', E_USER_DEPRECATED); + $settings['items_per_page'] = NULL; + $block->set('settings', $settings); + } + } + } + } diff --git a/core/modules/views/src/Hook/ViewsViewsHooks.php b/core/modules/views/src/Hook/ViewsViewsHooks.php index 531f6c754fa2..4f10f689646c 100644 --- a/core/modules/views/src/Hook/ViewsViewsHooks.php +++ b/core/modules/views/src/Hook/ViewsViewsHooks.php @@ -143,8 +143,9 @@ class ViewsViewsHooks { } } // Registers an action bulk form per entity. + $all_actions = \Drupal::entityTypeManager()->getStorage('action')->loadMultiple(); foreach (\Drupal::entityTypeManager()->getDefinitions() as $entity_type => $entity_info) { - $actions = array_filter(\Drupal::entityTypeManager()->getStorage('action')->loadMultiple(), function (ActionConfigEntityInterface $action) use ($entity_type) { + $actions = array_filter($all_actions, function (ActionConfigEntityInterface $action) use ($entity_type) { return $action->getType() == $entity_type; }); if (empty($actions)) { @@ -183,6 +184,7 @@ class ViewsViewsHooks { if (is_array($result)) { $data = NestedArray::mergeDeep($result, $data); } + \Drupal::moduleHandler()->invoke($field_storage->getTypeProvider(), 'field_views_data_views_data_alter', [&$data, $field_storage]); } } } @@ -190,28 +192,6 @@ class ViewsViewsHooks { } /** - * Implements hook_views_data_alter(). - * - * Field modules can implement hook_field_views_data_views_data_alter() to - * alter the views data on a per field basis. This is weirdly named so as not - * to conflict with the \Drupal::moduleHandler()->alter('field_views_data') in - * views_views_data(). - */ - #[Hook('views_data_alter')] - public function viewsDataAlter(&$data): void { - $entity_type_manager = \Drupal::entityTypeManager(); - if (!$entity_type_manager->hasDefinition('field_storage_config')) { - return; - } - /** @var \Drupal\field\FieldStorageConfigInterface $field_storage */ - foreach ($entity_type_manager->getStorage('field_storage_config')->loadMultiple() as $field_storage) { - if (\Drupal::service('views.field_data_provider')->getSqlStorageForField($field_storage)) { - \Drupal::moduleHandler()->invoke($field_storage->getTypeProvider(), 'field_views_data_views_data_alter', [&$data, $field_storage]); - } - } - } - - /** * Implements hook_field_views_data(). * * The function implements the hook on behalf of 'core' because it adds a diff --git a/core/modules/views/src/Plugin/views/argument/DayDate.php b/core/modules/views/src/Plugin/views/argument/DayDate.php index 884355a72a4b..a4a94da06998 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/LanguageArgument.php b/core/modules/views/src/Plugin/views/argument/LanguageArgument.php index 23980568f764..81a2181dd5a2 100644 --- a/core/modules/views/src/Plugin/views/argument/LanguageArgument.php +++ b/core/modules/views/src/Plugin/views/argument/LanguageArgument.php @@ -15,21 +15,19 @@ use Drupal\views\Attribute\ViewsArgument; class LanguageArgument extends ArgumentPluginBase { /** - * Overrides \Drupal\views\Plugin\views\argument\ArgumentPluginBase::summaryName(). - * - * Gets the user-friendly version of the language name. + * {@inheritdoc} */ public function summaryName($data) { + // Gets the user-friendly version of the language name. return $this->language($data->{$this->name_alias}); } /** - * Overrides \Drupal\views\Plugin\views\argument\ArgumentPluginBase::title(). - * - * Gets the user friendly version of the language name for display as a - * title placeholder. + * {@inheritdoc} */ public function title() { + // Gets the user friendly version of the language name for display as a + // title placeholder. return $this->language($this->argument); } diff --git a/core/modules/views/src/Plugin/views/argument/MonthDate.php b/core/modules/views/src/Plugin/views/argument/MonthDate.php index cec2159c9a3d..a24f23f120d9 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 16410ad25564..b82071f6d6ae 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/Block.php b/core/modules/views/src/Plugin/views/display/Block.php index 6532990b8d3e..5692ee1df013 100644 --- a/core/modules/views/src/Plugin/views/display/Block.php +++ b/core/modules/views/src/Plugin/views/display/Block.php @@ -120,7 +120,7 @@ class Block extends DisplayPluginBase { * @see \Drupal\views\Plugin\Block\ViewsBlock::defaultConfiguration() */ public function blockSettings(array $settings) { - $settings['items_per_page'] = 'none'; + $settings['items_per_page'] = NULL; return $settings; } @@ -315,7 +315,7 @@ class Block extends DisplayPluginBase { 40 => 40, 48 => 48, ], - '#default_value' => $block_configuration['items_per_page'], + '#default_value' => $block_configuration['items_per_page'] ?? 'none', ]; break; } @@ -353,7 +353,7 @@ class Block extends DisplayPluginBase { */ public function blockSubmit(ViewsBlock $block, $form, FormStateInterface $form_state) { if ($items_per_page = $form_state->getValue(['override', 'items_per_page'])) { - $block->setConfigurationValue('items_per_page', $items_per_page); + $block->setConfigurationValue('items_per_page', $items_per_page === 'none' ? NULL : intval($items_per_page)); } $form_state->unsetValue(['override', 'items_per_page']); } @@ -366,8 +366,9 @@ class Block extends DisplayPluginBase { */ public function preBlockBuild(ViewsBlock $block) { $config = $block->getConfiguration(); - if ($config['items_per_page'] !== 'none') { - $this->view->setItemsPerPage($config['items_per_page']); + if (is_numeric($config['items_per_page']) && $config['items_per_page'] > 0) { + // @todo Delete the intval() in https://www.drupal.org/project/drupal/issues/3521221 + $this->view->setItemsPerPage(intval($config['items_per_page'])); } } diff --git a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php index fc4a983f9299..d3adc61de5ab 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/field/Boolean.php b/core/modules/views/src/Plugin/views/field/Boolean.php index f2eb8f639b87..0c91fdc59509 100644 --- a/core/modules/views/src/Plugin/views/field/Boolean.php +++ b/core/modules/views/src/Plugin/views/field/Boolean.php @@ -16,8 +16,9 @@ use Drupal\views\Plugin\views\display\DisplayPluginBase; * Allows for display of true/false, yes/no, on/off, enabled/disabled. * * Definition terms: - * - output formats: An array where the first entry is displayed on boolean true - * and the second is displayed on boolean false. An example for sticky is: + * - output formats: An array where the first entry is displayed on boolean + * true and the second is displayed on boolean false. An example for sticky + * is: * @code * 'output formats' => [ * 'sticky' => [t('Sticky'), ''], diff --git a/core/modules/views/src/Plugin/views/field/Url.php b/core/modules/views/src/Plugin/views/field/Url.php index 18e40a61f0e9..7f5dd0a33653 100644 --- a/core/modules/views/src/Plugin/views/field/Url.php +++ b/core/modules/views/src/Plugin/views/field/Url.php @@ -9,7 +9,7 @@ use Drupal\views\Attribute\ViewsField; use Drupal\views\ResultRow; /** - * Field handler to provide simple renderer that turns a URL into a clickable link. + * Field handler to provide a renderer that turns a URL into a clickable link. * * @ingroup views_field_handlers */ diff --git a/core/modules/views/src/Plugin/views/filter/FilterPluginBase.php b/core/modules/views/src/Plugin/views/filter/FilterPluginBase.php index e3b5b87ac2ef..0f526735d6e3 100644 --- a/core/modules/views/src/Plugin/views/filter/FilterPluginBase.php +++ b/core/modules/views/src/Plugin/views/filter/FilterPluginBase.php @@ -889,7 +889,7 @@ abstract class FilterPluginBase extends HandlerBase implements CacheableDependen * (optional) The form element to set any errors on. * * @return string - * Returns an error message if validation fails, or NULL if validation passes. + * The error message if validation fails, or NULL if validation passes. */ protected function validateIdentifier($identifier, ?FormStateInterface $form_state = NULL, &$form_group = []) { $error = ''; @@ -1226,9 +1226,11 @@ abstract class FilterPluginBase extends HandlerBase implements CacheableDependen continue; } // Each rows contains three widgets: - // a) The title, where users define how they identify a pair of operator | value - // b) The operator - // c) The value (or values) to use in the filter with the selected operator + // - The title, where users define how they identify a pair of + // operator | value. + // - The operator. + // - The value (or values) to use in the filter with the selected + // operator. // In each row, we have to display the operator form and the value from // $row acts as a fake form to render each widget in a row. diff --git a/core/modules/views/src/Plugin/views/pager/Full.php b/core/modules/views/src/Plugin/views/pager/Full.php index 0176fc6e7f90..ed8f7d7566a2 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 0f95f7a0d2f6..e17aa7fabdd7 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 3356d703c93d..5796488746cc 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 3f555cfec9fd..ca8df3ba8fef 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/Plugin/views/style/Table.php b/core/modules/views/src/Plugin/views/style/Table.php index 48adbf427ede..561628ac6820 100644 --- a/core/modules/views/src/Plugin/views/style/Table.php +++ b/core/modules/views/src/Plugin/views/style/Table.php @@ -71,7 +71,7 @@ class Table extends StylePluginBase implements CacheableDependencyInterface { $options = parent::defineOptions(); $options['columns'] = ['default' => []]; - $options['class'] = ['default' => []]; + $options['class'] = ['default' => '']; $options['default'] = ['default' => '']; $options['info'] = ['default' => []]; $options['override'] = ['default' => TRUE]; diff --git a/core/modules/views/src/Plugin/views/wizard/WizardPluginBase.php b/core/modules/views/src/Plugin/views/wizard/WizardPluginBase.php index ef8568203cd4..d53e93cecec1 100644 --- a/core/modules/views/src/Plugin/views/wizard/WizardPluginBase.php +++ b/core/modules/views/src/Plugin/views/wizard/WizardPluginBase.php @@ -208,7 +208,8 @@ abstract class WizardPluginBase extends PluginBase implements WizardInterface { * Gets the availableSorts property. * * @return array - * An array of available sorts, keyed by sort ID, containing sort information. + * An array whose keys are the available sort options and whose + * corresponding values are human readable labels. */ public function getAvailableSorts() { return $this->availableSorts; @@ -483,7 +484,7 @@ abstract class WizardPluginBase extends PluginBase implements WizardInterface { } /** - * Gets the current value of a #select element, from within a form constructor function. + * Gets the current value of a #select element. * * This function is intended for use in highly dynamic forms (in particular * the add view wizard) which are rebuilt in different ways depending on which diff --git a/core/modules/views/src/ViewExecutable.php b/core/modules/views/src/ViewExecutable.php index 55e3a8d91453..407260ed285d 100644 --- a/core/modules/views/src/ViewExecutable.php +++ b/core/modules/views/src/ViewExecutable.php @@ -348,7 +348,7 @@ class ViewExecutable { public $footer; /** - * Stores the area handlers for the empty text which are initialized on this view. + * The area handlers for the empty text which are initialized on this view. * * An array containing Drupal\views\Plugin\views\area\AreaPluginBase objects. * @@ -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 3b2709cc60c2..9c5d38e10ac5 100644 --- a/core/modules/views/src/ViewsConfigUpdater.php +++ b/core/modules/views/src/ViewsConfigUpdater.php @@ -134,6 +134,9 @@ class ViewsConfigUpdater implements ContainerInjectionInterface { if ($this->processRememberRolesUpdate($handler, $handler_type)) { $changed = TRUE; } + if ($this->processTableCssClassUpdate($view)) { + $changed = TRUE; + } return $changed; }); } @@ -335,6 +338,7 @@ class ViewsConfigUpdater implements ContainerInjectionInterface { if ( isset($display['display_options']['style']) && $display['display_options']['style']['type'] === 'table' && + isset($display['display_options']['style']['options']) && !isset($display['display_options']['style']['options']['class']) ) { $display['display_options']['style']['options']['class'] = ''; @@ -346,6 +350,12 @@ class ViewsConfigUpdater implements ContainerInjectionInterface { $view->set('display', $displays); } + $deprecations_triggered = &$this->triggeredDeprecations['table_css_class'][$view->id()]; + if ($this->deprecationsEnabled && $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); + } + return $changed; } diff --git a/core/modules/views/tests/fixtures/update/views-block-items-per-page.php b/core/modules/views/tests/fixtures/update/views-block-items-per-page.php new file mode 100644 index 000000000000..542992a24df2 --- /dev/null +++ b/core/modules/views/tests/fixtures/update/views-block-items-per-page.php @@ -0,0 +1,48 @@ +<?php + +/** + * @file + * Creates a Views block with an `items_per_page` setting of `none`. + */ + +declare(strict_types=1); + +use Drupal\Core\Database\Database; +use Drupal\Core\Serialization\Yaml; + +$block_data = Yaml::decode(<<<END +uuid: ecdad54d-8165-4ed3-a678-8ad20b388282 +langcode: en +status: true +dependencies: + config: + - views.view.who_s_online + module: + - views + theme: + - olivero +id: olivero_who_s_online +theme: olivero +region: header +weight: 0 +provider: null +plugin: 'views_block:who_s_online-who_s_online_block' +settings: + id: 'views_block:who_s_online-who_s_online_block' + label: '' + label_display: visible + provider: views + views_label: '' + items_per_page: none +visibility: { } +END +); + +Database::getConnection() + ->insert('config') + ->fields([ + 'collection' => '', + 'name' => 'block.block.olivero_who_s_online', + 'data' => serialize($block_data), + ]) + ->execute(); diff --git a/core/modules/views/tests/modules/action_bulk_test/config/install/views.view.test_bulk_form.yml b/core/modules/views/tests/modules/action_bulk_test/config/install/views.view.test_bulk_form.yml index cba11476ae8b..573705f502da 100644 --- a/core/modules/views/tests/modules/action_bulk_test/config/install/views.view.test_bulk_form.yml +++ b/core/modules/views/tests/modules/action_bulk_test/config/install/views.view.test_bulk_form.yml @@ -126,6 +126,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true columns: diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort.yml index 3ab5ecb66894..077b4bb4ce16 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort.yml @@ -42,6 +42,7 @@ display: style: type: table options: + class: '' info: id: sortable: true diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort_ajax.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort_ajax.yml index bf65e0eaf379..1157a4e60bca 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort_ajax.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_click_sort_ajax.yml @@ -43,6 +43,15 @@ display: style: type: table options: + grouping: { } + class: '' + row_class: '' + default_row_class: true + override: true + sticky: true + caption: '' + summary: '' + description: '' info: id: sortable: true diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_content_ajax.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_content_ajax.yml index db3fd0693bf7..2036ee77c5fb 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_content_ajax.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_content_ajax.yml @@ -57,6 +57,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_distinct_click_sorting.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_distinct_click_sorting.yml index 55813bc9c49a..fe6c14f744bf 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_distinct_click_sorting.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_distinct_click_sorting.yml @@ -65,6 +65,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_entity_operations.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_entity_operations.yml index a19cea01b9f3..5ed73c2a1178 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_entity_operations.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_entity_operations.yml @@ -133,6 +133,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_field_header.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_field_header.yml index 091c4375635e..a8c84a7a6497 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_field_header.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_field_header.yml @@ -20,6 +20,8 @@ display: element_label_type: h2 style: type: table + options: + class: '' display_extenders: { } display_plugin: default display_title: Default diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_filter_placeholder_text.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_filter_placeholder_text.yml index 1f33e75b61fb..1217b6c86eba 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_filter_placeholder_text.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_filter_placeholder_text.yml @@ -52,6 +52,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_form_multiple.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_form_multiple.yml index ddb305fb162e..dffcbe3c0b72 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_form_multiple.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_form_multiple.yml @@ -40,6 +40,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_glossary.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_glossary.yml index 21ac6945808e..aa3e387ee905 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_glossary.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_glossary.yml @@ -286,6 +286,7 @@ display: override: true sticky: false grouping: { } + class: '' row_class: '' default_row_class: true uses_fields: false diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_row_render_cache.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_row_render_cache.yml index b52621ee9908..a4bda56b5437 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_row_render_cache.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_row_render_cache.yml @@ -68,6 +68,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_row_render_cache_none.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_row_render_cache_none.yml index aea7b9040134..91382cd03900 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_row_render_cache_none.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_row_render_cache_none.yml @@ -68,6 +68,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_table.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_table.yml index 10d215d29b51..77cca5f518f3 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_table.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_table.yml @@ -63,6 +63,7 @@ display: type: table options: grouping: { } + class: '' row_class: '' default_row_class: true override: true 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 000000000000..eb59548f17f5 --- /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/test_views/views.view.test_view_render.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_view_render.yml index cec43f7c7dc7..98fa2836be96 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_view_render.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_view_render.yml @@ -48,6 +48,8 @@ display: plugin_id: standard style: type: table + options: + class: '' display_plugin: default display_title: Default id: default 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 000000000000..8680489c2b6e --- /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 000000000000..0bdeeed705ab --- /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 000000000000..be9752795657 --- /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 292f91767715..25c08d5f1590 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 df420cf879fa..92874dd8b417 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/ContextualFiltersBlockContextTest.php b/core/modules/views/tests/src/Functional/Plugin/ContextualFiltersBlockContextTest.php index 955226497f95..0384c917083e 100644 --- a/core/modules/views/tests/src/Functional/Plugin/ContextualFiltersBlockContextTest.php +++ b/core/modules/views/tests/src/Functional/Plugin/ContextualFiltersBlockContextTest.php @@ -122,7 +122,7 @@ class ContextualFiltersBlockContextTest extends ViewTestBase { 'provider' => 'views', 'label_display' => 'visible', 'views_label' => '', - 'items_per_page' => 'none', + 'items_per_page' => NULL, 'context_mapping' => ['nid' => '@node.node_route_context:node'], ]; $this->assertEquals($expected_settings, $block->getPlugin()->getConfiguration(), 'Block settings are correct.'); diff --git a/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php b/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php index 8af887d1ef1b..5aecbea3e366 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/Update/BlockItemsPerPageUpdateTest.php b/core/modules/views/tests/src/Functional/Update/BlockItemsPerPageUpdateTest.php new file mode 100644 index 000000000000..bd250cc2736e --- /dev/null +++ b/core/modules/views/tests/src/Functional/Update/BlockItemsPerPageUpdateTest.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\views\Functional\Update; + +use Drupal\block\Entity\Block; +use Drupal\FunctionalTests\Update\UpdatePathTestBase; + +/** + * @group Update + * @covers views_post_update_block_items_per_page + */ +final class BlockItemsPerPageUpdateTest extends UpdatePathTestBase { + + /** + * {@inheritdoc} + */ + protected function setDatabaseDumpFiles(): void { + $this->databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz', + __DIR__ . '/../../../fixtures/update/views-block-items-per-page.php', + ]; + } + + /** + * Tests changing an `items_per_page` setting of `none` to NULL. + */ + public function testUpdateItemsPerPage(): void { + $settings = Block::load('olivero_who_s_online')?->get('settings'); + $this->assertIsArray($settings); + $this->assertSame('none', $settings['items_per_page']); + + $this->runUpdates(); + + $settings = Block::load('olivero_who_s_online')?->get('settings'); + $this->assertIsArray($settings); + $this->assertNull($settings['items_per_page']); + } + +} diff --git a/core/modules/views/tests/src/Functional/Wizard/ItemsPerPageTest.php b/core/modules/views/tests/src/Functional/Wizard/ItemsPerPageTest.php index 5f9cd364dac8..d290a27cd191 100644 --- a/core/modules/views/tests/src/Functional/Wizard/ItemsPerPageTest.php +++ b/core/modules/views/tests/src/Functional/Wizard/ItemsPerPageTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Drupal\Tests\views\Functional\Wizard; +use Drupal\views\Entity\View; + /** * Tests that the views wizard can specify the number of items per page. * @@ -19,6 +21,16 @@ class ItemsPerPageTest extends WizardTestBase { /** * {@inheritdoc} */ + protected static $configSchemaCheckerExclusions = [ + // To be able to test with the now invalid: + // - `items_per_page: 'none'` + // - `items_per_page: '5'` + 'block.block.views_block_items_per_page_test_with_historical_override', + ]; + + /** + * {@inheritdoc} + */ protected function setUp($import_test_views = TRUE, $modules = []): void { parent::setUp($import_test_views, $modules); @@ -27,6 +39,12 @@ class ItemsPerPageTest extends WizardTestBase { /** * Tests the number of items per page. + * + * This should be removed from the `legacy` group in + * https://drupal.org/i/3521221; see + * \Drupal\views\Hook\ViewsHooks::blockPresave(). + * + * @group legacy */ public function testItemsPerPage(): void { $this->drupalCreateContentType(['type' => 'article']); @@ -83,7 +101,7 @@ class ItemsPerPageTest extends WizardTestBase { $this->drupalGet($view['page[path]']); $this->assertSession()->statusCodeEquals(200); - // Make sure the page display shows the nodes we expect, and that they + // Make sure the page display shows the 4 nodes we expect, and that they // appear in the expected order. $this->assertSession()->addressEquals($view['page[path]']); $this->assertSession()->pageTextContains($view['page[title]']); @@ -109,21 +127,94 @@ class ItemsPerPageTest extends WizardTestBase { // Place the block, visit a page that displays the block, and check that the // nodes we expect appear in the correct order. - $this->drupalPlaceBlock("views_block:{$view['id']}-block_1"); - + $block = $this->drupalPlaceBlock("views_block:{$view['id']}-block_1"); + + // Asserts that the 3 newest articles are listed, which is the configuration + // for the `block` display in the view. In other words: the `items_per_page` + // setting in the `View` config entity is respected. + $assert_3_newest_nodes = function () use ($node5, $node4, $node3, $node2, $node1, $page_node) { + $this->drupalGet('user'); + $content = $this->getSession()->getPage()->getContent(); + $this->assertSession()->pageTextContains($node5->label()); + $this->assertSession()->pageTextContains($node4->label()); + $this->assertSession()->pageTextContains($node3->label()); + $this->assertSession()->pageTextNotContains($node2->label()); + $this->assertSession()->pageTextNotContains($node1->label()); + $this->assertSession()->pageTextNotContains($page_node->label()); + $pos5 = strpos($content, $node5->label()); + $pos4 = strpos($content, $node4->label()); + $pos3 = strpos($content, $node3->label()); + $this->assertGreaterThan($pos5, $pos4); + $this->assertGreaterThan($pos4, $pos3); + }; + self::assertSame(4, View::load($view['id'])->toArray()['display']['default']['display_options']['pager']['options']['items_per_page']); + self::assertSame(3, View::load($view['id'])->toArray()['display']['block_1']['display_options']['pager']['options']['items_per_page']); + self::assertArrayNotHasKey('items_per_page', $block->get('settings')); + $assert_3_newest_nodes(); + $block->delete(); + + // Because the `allow[items_per_page]` checkbox is checked, it is allowed to + // override the `items_per_page` setting for the Views's `block` display, + // and is actually respected. Valid values are `null` ("do not override") + // and a positive integer. + $block = $this->drupalPlaceBlock("views_block:{$view['id']}-block_1", [ + 'items_per_page' => NULL, + ]); + self::assertSame(4, View::load($view['id'])->toArray()['display']['default']['display_options']['pager']['options']['items_per_page']); + self::assertSame(3, View::load($view['id'])->toArray()['display']['block_1']['display_options']['pager']['options']['items_per_page']); + self::assertNull($block->get('settings')['items_per_page']); + $assert_3_newest_nodes(); + $block->delete(); + + $block = $this->drupalPlaceBlock("views_block:{$view['id']}-block_1", [ + 'items_per_page' => 5, + ]); + self::assertSame(4, View::load($view['id'])->toArray()['display']['default']['display_options']['pager']['options']['items_per_page']); + self::assertSame(3, View::load($view['id'])->toArray()['display']['block_1']['display_options']['pager']['options']['items_per_page']); + self::assertSame(5, $block->get('settings')['items_per_page']); $this->drupalGet('user'); - $content = $this->getSession()->getPage()->getContent(); - $this->assertSession()->pageTextContains($node5->label()); - $this->assertSession()->pageTextContains($node4->label()); - $this->assertSession()->pageTextContains($node3->label()); - $this->assertSession()->pageTextNotContains($node2->label()); - $this->assertSession()->pageTextNotContains($node1->label()); - $this->assertSession()->pageTextNotContains($page_node->label()); - $pos5 = strpos($content, $node5->label()); - $pos4 = strpos($content, $node4->label()); - $pos3 = strpos($content, $node3->label()); - $this->assertGreaterThan($pos5, $pos4); - $this->assertGreaterThan($pos4, $pos3); + foreach ([$node5, $node4, $node3, $node2, $node1] as $node) { + $this->assertSession()->pageTextContains($node->label()); + } + $block->delete(); + + // Finally: set `items_per_page: 'none'`, which is the predecessor of + // `items_per_page: null`. This must continue to work as before even if the + // configuration is no longer considered valid, because otherwise we risk + // breaking e.g. blocks placed using Layout Builder. + // @todo Delete in https://www.drupal.org/project/drupal/issues/3521221. + $block = $this->drupalPlaceBlock("views_block:{$view['id']}-block_1", [ + 'id' => 'views_block_items_per_page_test_with_historical_override', + ]); + // Explicitly set the `items_per_page` setting to a string without casting. + // It should be changed to NULL by the pre-save hook. + // @see \Drupal\views\Hook\ViewsHooks::blockPresave() + $block->set('settings', [ + 'items_per_page' => 'none', + ])->trustData()->save(); + $this->expectDeprecation('Saving a views block with "none" items per page is deprecated in drupal:11.2.0 and removed in drupal:12.0.0. To use the items per page defined by the view, use NULL. See https://www.drupal.org/node/3522240'); + self::assertNull($block->get('settings')['items_per_page']); + self::assertSame(4, View::load($view['id'])->toArray()['display']['default']['display_options']['pager']['options']['items_per_page']); + self::assertSame(3, View::load($view['id'])->toArray()['display']['block_1']['display_options']['pager']['options']['items_per_page']); + $assert_3_newest_nodes(); + $block->delete(); + + // Truly finally: set `items_per_page: '5'`, because for the same reason as + // above, blocks placed using Layout Builder may still have stale settings. + $block = $this->drupalPlaceBlock("views_block:{$view['id']}-block_1", [ + 'id' => 'views_block_items_per_page_test_with_historical_override', + ]); + // Explicitly set the `items_per_page` setting to a string without casting. + $block->set('settings', [ + 'items_per_page' => '5', + ])->trustData()->save(); + self::assertSame('5', $block->get('settings')['items_per_page']); + self::assertSame(4, View::load($view['id'])->toArray()['display']['default']['display_options']['pager']['options']['items_per_page']); + self::assertSame(3, View::load($view['id'])->toArray()['display']['block_1']['display_options']['pager']['options']['items_per_page']); + $this->drupalGet('user'); + foreach ([$node5, $node4, $node3, $node2, $node1] as $node) { + $this->assertSession()->pageTextContains($node->label()); + } } } diff --git a/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php b/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php index 03488125064a..e19f1414615f 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 97d670634b3f..14f90fd0c33a 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/Plugin/ViewsBlockTest.php b/core/modules/views/tests/src/Kernel/Plugin/ViewsBlockTest.php index b336584c74d6..ebd9df73e01d 100644 --- a/core/modules/views/tests/src/Kernel/Plugin/ViewsBlockTest.php +++ b/core/modules/views/tests/src/Kernel/Plugin/ViewsBlockTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Drupal\Tests\views\Kernel\Plugin; +use Drupal\Core\Extension\ThemeInstallerInterface; +use Drupal\Tests\block\Traits\BlockCreationTrait; use Drupal\views\Plugin\Block\ViewsBlock; use Drupal\views\Tests\ViewTestData; use Drupal\Tests\views\Kernel\ViewsKernelTestBase; @@ -16,6 +18,8 @@ use Drupal\views\Views; */ class ViewsBlockTest extends ViewsKernelTestBase { + use BlockCreationTrait; + /** * {@inheritdoc} */ @@ -142,4 +146,22 @@ class ViewsBlockTest extends ViewsKernelTestBase { $this->assertEquals('"test_view_block::block_1" views block', $views_block->getPreviewFallbackString()); } + /** + * Tests that saving a Views block with items_per_page = `none` is deprecated. + * + * @covers \Drupal\views\Hook\ViewsHooks::blockPresave + * + * @group legacy + */ + public function testSaveBlockWithDeprecatedItemsPerPageSetting(): void { + $this->container->get(ThemeInstallerInterface::class)->install(['stark']); + + $this->expectDeprecation('Saving a views block with "none" items per page is deprecated in drupal:11.2.0 and removed in drupal:12.0.0. To use the items per page defined by the view, use NULL. See https://www.drupal.org/node/3522240'); + $block = $this->placeBlock('views_block:test_view_block-block_1', [ + 'items_per_page' => 'none', + ]); + $settings = $block->get('settings'); + $this->assertNull($settings['items_per_page']); + } + } 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 000000000000..0f3d3eb5291a --- /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/tests/src/Unit/Plugin/views/display/BlockTest.php b/core/modules/views/tests/src/Unit/Plugin/views/display/BlockTest.php index 0d72bf27b4ac..6988a04d8b48 100644 --- a/core/modules/views/tests/src/Unit/Plugin/views/display/BlockTest.php +++ b/core/modules/views/tests/src/Unit/Plugin/views/display/BlockTest.php @@ -62,29 +62,37 @@ class BlockTest extends UnitTestCase { /** * Tests the build method with no overriding. + * + * @testWith [null] + * ["none"] + * [0] + * @todo Delete the last two cases in https://www.drupal.org/project/drupal/issues/3521221. The last one is `intval('none')`. */ - public function testBuildNoOverride(): void { + public function testBuildNoOverride($items_per_page_setting): void { $this->executable->expects($this->never()) ->method('setItemsPerPage'); $this->blockPlugin->expects($this->once()) ->method('getConfiguration') - ->willReturn(['items_per_page' => 'none']); + ->willReturn(['items_per_page' => $items_per_page_setting]); $this->blockDisplay->preBlockBuild($this->blockPlugin); } /** * Tests the build method with overriding items per page. + * + * @testWith [5, 5] + * ["5", 5] */ - public function testBuildOverride(): void { + public function testBuildOverride(mixed $input, int $expected): void { $this->executable->expects($this->once()) ->method('setItemsPerPage') - ->with(5); + ->with($expected); $this->blockPlugin->expects($this->once()) ->method('getConfiguration') - ->willReturn(['items_per_page' => 5]); + ->willReturn(['items_per_page' => $input]); $this->blockDisplay->preBlockBuild($this->blockPlugin); } diff --git a/core/modules/views/views.api.php b/core/modules/views/views.api.php index 4fbcad6730ea..6579f93199bb 100644 --- a/core/modules/views/views.api.php +++ b/core/modules/views/views.api.php @@ -532,8 +532,9 @@ function hook_views_data_alter(array &$data) { * When collecting the views data, views_views_data() invokes this hook for each * field storage definition, on the module that provides the field storage * definition. If the return value is empty, the result of - * FieldViewsDataProvider::defaultFieldImplementation() is used instead. Then the result is altered - * by invoking hook_field_views_data_alter() on all modules. + * FieldViewsDataProvider::defaultFieldImplementation() is used instead. Then + * the result is altered by invoking hook_field_views_data_alter() on all + * modules. * * If no hook implementation exists, hook_views_data() falls back to * FieldViewsDataProvider::defaultFieldImplementation(). diff --git a/core/modules/views/views.post_update.php b/core/modules/views/views.post_update.php index c3f352e99a42..48623fc593e0 100644 --- a/core/modules/views/views.post_update.php +++ b/core/modules/views/views.post_update.php @@ -5,6 +5,7 @@ * Post update functions for Views. */ +use Drupal\block\BlockInterface; use Drupal\Core\Config\Entity\ConfigEntityUpdater; use Drupal\views\ViewEntityInterface; use Drupal\views\ViewsConfigUpdater; @@ -83,7 +84,29 @@ 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->setDeprecationsEnabled(FALSE); \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', function (ViewEntityInterface $view) use ($view_config_updater): bool { return $view_config_updater->needsTableCssClassUpdate($view); }); } + +/** + * Defaults `items_per_page` to NULL in Views blocks. + */ +function views_post_update_block_items_per_page(?array &$sandbox = NULL): void { + if (!\Drupal::moduleHandler()->moduleExists('block')) { + return; + } + \Drupal::classResolver(ConfigEntityUpdater::class) + ->update($sandbox, 'block', function (BlockInterface $block): bool { + if (str_starts_with($block->getPluginId(), 'views_block:')) { + $settings = $block->get('settings'); + if ($settings['items_per_page'] === 'none') { + $settings['items_per_page'] = NULL; + $block->set('settings', $settings); + return TRUE; + } + } + return FALSE; + }); +} diff --git a/core/modules/views/views.theme.inc b/core/modules/views/views.theme.inc index 10c29c5dbf30..04c5de5a535f 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/admin.inc b/core/modules/views_ui/admin.inc index b866e9ed62cc..3da4ebaed854 100644 --- a/core/modules/views_ui/admin.inc +++ b/core/modules/views_ui/admin.inc @@ -116,7 +116,7 @@ function views_ui_add_ajax_trigger(&$wrapping_element, $trigger_key, $refresh_pa } /** - * Processes a non-JavaScript fallback submit button to limit its validation errors. + * Limits validation errors for a non-JavaScript fallback submit button. */ function views_ui_add_limited_validation($element, FormStateInterface $form_state) { // Retrieve the AJAX triggering element so we can determine its parents. (We @@ -148,7 +148,7 @@ function views_ui_add_limited_validation($element, FormStateInterface $form_stat } /** - * After-build function that adds a wrapper to a form region (for AJAX refreshes). + * Adds a wrapper to a form region (for AJAX refreshes) after the build. * * This function inserts a wrapper around the region of the form that needs to * be refreshed by AJAX, based on information stored in the corresponding 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 529e8fa77c63..554d5044cb79 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 2f5c9590bc2c..1934593779e3 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/workflows/src/Plugin/WorkflowTypeBase.php b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php index 47488b38801b..e86b77a22857 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 e46fbcf417bf..180edc868f60 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 84b3fa8f986f..a603d53949cd 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 6f9d6d5b6563..aa5aa49f9d20 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/EntityQuery/Tables.php b/core/modules/workspaces/src/EntityQuery/Tables.php index 199d5cc15597..7b397c9580c4 100644 --- a/core/modules/workspaces/src/EntityQuery/Tables.php +++ b/core/modules/workspaces/src/EntityQuery/Tables.php @@ -126,7 +126,7 @@ class Tables extends BaseTables { } /** - * Adds a new join to the 'workspace_association' table for an entity base table. + * Adds a join to the 'workspace_association' table for an entity base table. * * This method assumes that the active workspace has already been determined * to be a non-default workspace. diff --git a/core/modules/workspaces/src/Hook/EntityOperations.php b/core/modules/workspaces/src/Hook/EntityOperations.php index 377ea62b2e88..f193795cc410 100644 --- a/core/modules/workspaces/src/Hook/EntityOperations.php +++ b/core/modules/workspaces/src/Hook/EntityOperations.php @@ -209,18 +209,19 @@ class EntityOperations { // that translation to the default revision as well, otherwise the new // translation wouldn't show up in entity queries or views which use the // field data table as the base table. - $this->workspaceManager->executeOutsideWorkspace(function () use ($translation) { - $storage = $this->entityTypeManager->getStorage($translation->getEntityTypeId()); - $default_revision = $storage->load($translation->id()); - - $langcode = $translation->language()->getId(); - if (!$default_revision->hasTranslation($langcode)) { - $default_revision_translation = $default_revision->addTranslation($langcode, $translation->toArray()); - $default_revision_translation->setUnpublished(); - $default_revision_translation->setSyncing(TRUE); - $default_revision_translation->save(); - } + $default_revision = $this->workspaceManager->executeOutsideWorkspace(function () use ($translation) { + return $this->entityTypeManager + ->getStorage($translation->getEntityTypeId()) + ->load($translation->id()); }); + $langcode = $translation->language()->getId(); + if (!$default_revision->hasTranslation($langcode)) { + $default_revision_translation = $default_revision->addTranslation($langcode, $translation->toArray()); + assert($default_revision_translation instanceof EntityPublishedInterface); + $default_revision_translation->setUnpublished(); + $default_revision_translation->setSyncing(TRUE); + $default_revision_translation->save(); + } } /** @@ -296,7 +297,7 @@ class EntityOperations { * * Alters entity forms to disallow concurrent editing in multiple workspaces. */ - #[Hook('form_alter')] + #[Hook('form_alter', order: Order::First)] public function entityFormAlter(array &$form, FormStateInterface $form_state, string $form_id): void { if (!$form_state->getFormObject() instanceof EntityFormInterface) { return; diff --git a/core/modules/workspaces/src/Hook/FormOperations.php b/core/modules/workspaces/src/Hook/FormOperations.php index 85f374582398..6f91618e71a8 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,25 +29,53 @@ 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; } - // Add a validation step for every form if we are in a workspace. - $this->addWorkspaceValidation($form); + // If a form hasn't already been marked as safe or not to submit in a + // workspace, check the generic interfaces. + if (!$form_state->has('workspace_safe')) { + $form_object = $form_state->getFormObject(); + $workspace_safe = $form_object instanceof WorkspaceSafeFormInterface + || ($form_object instanceof WorkspaceDynamicSafeFormInterface && $form_object->isWorkspaceSafeForm($form, $form_state)); - // If a form has already been marked as safe or not to submit in a - // workspace, we don't have anything else to do. - if ($form_state->has('workspace_safe')) { - return; + $form_state->set('workspace_safe', $workspace_safe); } - $form_object = $form_state->getFormObject(); - $workspace_safe = $form_object instanceof WorkspaceSafeFormInterface - || ($form_object instanceof WorkspaceDynamicSafeFormInterface && $form_object->isWorkspaceSafeForm($form, $form_state)); + // Add a validation step for every other form. + if ($form_state->get('workspace_safe') !== TRUE) { + $form['workspace_safe'] = [ + '#type' => 'value', + '#value' => FALSE, + ]; + $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(), + ]; - $form_state->set('workspace_safe', $workspace_safe); + $url_query_options = $this->queryParameterNegotiator->getQueryOptions($active_workspace->id()); + $this->setAjaxWorkspace($form, $url_query_options + ['persist' => FALSE]); + } } /** @@ -59,8 +92,14 @@ class FormOperations { } } - if (isset($element['#validate'])) { + if (isset($element['#submit'])) { $element['#validate'][] = [static::class, 'validateDefaultWorkspace']; + + // Ensure that the workspace validation is always shown, even when the + // form element is limiting validation errors. + if (isset($element['#limit_validation_errors']) && $element['#limit_validation_errors'] !== FALSE) { + $element['#limit_validation_errors'][] = ['workspace_safe']; + } } } @@ -68,8 +107,33 @@ class FormOperations { * Validation handler which sets a validation error for all unsupported forms. */ public static function validateDefaultWorkspace(array &$form, FormStateInterface $form_state): void { - if ($form_state->get('workspace_safe') !== TRUE) { - $form_state->setError($form, new TranslatableMarkup('This form can only be submitted in the default workspace.')); + if ($form_state->get('workspace_safe') !== TRUE && isset($form_state->getCompleteForm()['workspace_safe'])) { + $form_state->setErrorByName('workspace_safe', new TranslatableMarkup('This form can only be submitted in the default workspace.')); + } + } + + /** + * 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 new file mode 100644 index 000000000000..d865ea82c175 --- /dev/null +++ b/core/modules/workspaces/src/Install/Requirements/WorkspacesRequirements.php @@ -0,0 +1,32 @@ +<?php + +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. + */ +class WorkspacesRequirements implements InstallRequirementsInterface { + + /** + * {@inheritdoc} + */ + public static function getRequirements(): array { + $requirements = []; + if (\Drupal::moduleHandler()->moduleExists('workspace')) { + $requirements['workspace_incompatibility'] = [ + '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', + ]), + ]; + } + + return $requirements; + } + +} diff --git a/core/modules/workspaces/src/Negotiator/QueryParameterWorkspaceNegotiator.php b/core/modules/workspaces/src/Negotiator/QueryParameterWorkspaceNegotiator.php index 1f0b688541ff..04efac2c3b12 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/Plugin/Validation/Constraint/EntityReferenceSupportedNewEntitiesConstraintValidator.php b/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityReferenceSupportedNewEntitiesConstraintValidator.php index bf2fc243f6f4..180cfa9a8a1b 100644 --- a/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityReferenceSupportedNewEntitiesConstraintValidator.php +++ b/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityReferenceSupportedNewEntitiesConstraintValidator.php @@ -36,9 +36,6 @@ class EntityReferenceSupportedNewEntitiesConstraintValidator extends ConstraintV */ protected $workspaceInfo; - /** - * Creates a new EntityReferenceSupportedNewEntitiesConstraintValidator instance. - */ public function __construct(WorkspaceManagerInterface $workspaceManager, EntityTypeManagerInterface $entityTypeManager, WorkspaceInformationInterface $workspace_information) { $this->workspaceManager = $workspaceManager; $this->entityTypeManager = $entityTypeManager; diff --git a/core/modules/workspaces/src/WorkspaceManager.php b/core/modules/workspaces/src/WorkspaceManager.php index 5bb2dc454c3f..b0e9b8ac959e 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 a61d29f50d5f..8d8024c66207 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 000000000000..955cf4720385 --- /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 09ab54054ff6..d5d64ca55495 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(). */ @@ -24,4 +32,60 @@ class WorkspacesTestHooks { } } + /** + * Implements hook_ENTITY_TYPE_translation_create() for 'entity_test_mulrevpub'. + */ + #[Hook('entity_test_mulrevpub_translation_create')] + public function entityTranslationCreate(): void { + /** @var \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspaces.manager'); + $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 000000000000..bdf7648db9cc --- /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 c3fe0e722a2e..19007def89d9 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 b998af426b25..3cbddf127d9a 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 2109552e7619..9f9dad806f66 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 165eb6c1d959..25ce72c1af41 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 new file mode 100644 index 000000000000..f01c554cea7a --- /dev/null +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceFormValidationTest.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\workspaces\Functional; + +use Drupal\Tests\BrowserTestBase; + +/** + * Tests Workspaces form validation. + * + * @group workspaces + */ +class WorkspaceFormValidationTest extends BrowserTestBase { + + use WorkspaceTestUtilities; + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'form_test', 'workspaces']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->drupalLogin($this->drupalCreateUser(['administer workspaces'])); + $this->setupWorkspaceSwitcherBlock(); + } + + /** + * Tests partial form validation through #limit_validation_errors. + */ + public function testValidateLimitErrors(): void { + $this->createAndActivateWorkspaceThroughUi(); + + $edit = [ + 'test' => 'test1', + 'test_numeric_index[0]' => 'test2', + 'test_substring[foo]' => 'test3', + ]; + $path = 'form-test/limit-validation-errors'; + + // Submit the form by pressing all the 'Partial validate' buttons. + $this->drupalGet($path); + $this->submitForm($edit, 'Partial validate'); + $this->assertSession()->pageTextContains('This form can only be submitted in the default workspace.'); + + $this->drupalGet($path); + $this->submitForm($edit, 'Partial validate (numeric index)'); + $this->assertSession()->pageTextContains('This form can only be submitted in the default workspace.'); + + $this->drupalGet($path); + $this->submitForm($edit, 'Partial validate (substring)'); + $this->assertSession()->pageTextContains('This form can only be submitted in the default workspace.'); + + // Now test full form validation. + $this->drupalGet($path); + $this->submitForm($edit, 'Full validate'); + $this->assertSession()->pageTextContains('This form can only be submitted in the default workspace.'); + } + +} diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceMenuLinkContentIntegrationTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceMenuLinkContentIntegrationTest.php index 864ac8c1a365..75f6db02b153 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 d7ac36a5e889..eef28c5ff65f 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 0684f46467e0..b632e60efd50 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 7f4230b07733..56e91d8a26ba 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 de8795b644ab..92b2673341e6 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 c14fe48effbc..487e0dc5f55c 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 d058cfcc8382..f59aba78bb5f 100644 --- a/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesLayoutBuilderIntegrationTest.php +++ b/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesLayoutBuilderIntegrationTest.php @@ -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'); diff --git a/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesMediaLibraryIntegrationTest.php b/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesMediaLibraryIntegrationTest.php index 83ecb8e434f9..49cac2860ae7 100644 --- a/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesMediaLibraryIntegrationTest.php +++ b/core/modules/workspaces/tests/src/FunctionalJavascript/WorkspacesMediaLibraryIntegrationTest.php @@ -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/WorkspaceContentTranslationTest.php b/core/modules/workspaces/tests/src/Kernel/WorkspaceContentTranslationTest.php index 70351c963187..e3792cfaa325 100644 --- a/core/modules/workspaces/tests/src/Kernel/WorkspaceContentTranslationTest.php +++ b/core/modules/workspaces/tests/src/Kernel/WorkspaceContentTranslationTest.php @@ -35,6 +35,7 @@ class WorkspaceContentTranslationTest extends KernelTestBase { 'language', 'user', 'workspaces', + 'workspaces_test', ]; /** @@ -84,6 +85,9 @@ class WorkspaceContentTranslationTest extends KernelTestBase { $entity_published->addTranslation('ro', ['name' => 'live - 1 - published - RO']); $entity_published->save(); + // Test that the default revision translation is created in a WS. + $this->assertTrue(\Drupal::keyValue('ws_test')->get('workspace_was_active')); + $entity_unpublished->addTranslation('ro', ['name' => 'live - 2 - unpublished - RO']); $entity_unpublished->save(); 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 000000000000..ac3e7b3a51ce --- /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 a798b4a2c22b..be1f19a3a30f 100644 --- a/core/modules/workspaces/workspaces.install +++ b/core/modules/workspaces/workspaces.install @@ -6,57 +6,6 @@ */ use Drupal\Core\Entity\EntityTypeInterface; -use Drupal\workspaces\Entity\Workspace; - -/** - * Implements hook_requirements(). - */ -function workspaces_requirements($phase): array { - $requirements = []; - if ($phase === 'install') { - if (\Drupal::moduleHandler()->moduleExists('workspace')) { - $requirements['workspace_incompatibility'] = [ - 'severity' => REQUIREMENT_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', - ]), - ]; - } - } - - return $requirements; -} - -/** - * 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 1fd07d36d772..4abb1d1cae42 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 488a6dd34c3c..b83953de37e4 100644 --- a/core/modules/workspaces_ui/tests/src/FunctionalJavascript/WorkspaceToolbarIntegrationTest.php +++ b/core/modules/workspaces_ui/tests/src/FunctionalJavascript/WorkspaceToolbarIntegrationTest.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace Drupal\Tests\workspaces_ui\FunctionalJavascript; use Drupal\Tests\system\FunctionalJavascript\OffCanvasTestBase; +use Drupal\workspaces\Entity\Workspace; /** * Tests workspace settings stray integration. * + * @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(); } /** |