summaryrefslogtreecommitdiffstatshomepage
path: root/core
diff options
context:
space:
mode:
Diffstat (limited to 'core')
-rw-r--r--core/.deprecation-ignore.txt50
-rw-r--r--core/.phpstan-baseline.php186
-rw-r--r--core/MAINTAINERS.txt4
-rw-r--r--core/composer.json3
-rw-r--r--core/config/schema/core.data_types.schema.yml12
-rw-r--r--core/core.services.yml2
-rw-r--r--core/includes/common.inc8
-rw-r--r--core/lib/Drupal.php2
-rw-r--r--core/lib/Drupal/Component/Plugin/LazyPluginCollection.php6
-rw-r--r--core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php13
-rw-r--r--core/lib/Drupal/Core/Cache/CacheTagsPurgeInterface.php24
-rw-r--r--core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php18
-rw-r--r--core/lib/Drupal/Core/Database/Connection.php4
-rw-r--r--core/lib/Drupal/Core/Database/Statement/PdoTrait.php10
-rw-r--r--core/lib/Drupal/Core/Database/Statement/ResultBase.php8
-rw-r--r--core/lib/Drupal/Core/Database/Statement/StatementBase.php8
-rw-r--r--core/lib/Drupal/Core/Database/StatementInterface.php8
-rw-r--r--core/lib/Drupal/Core/Database/StatementPrefetchIterator.php2
-rw-r--r--core/lib/Drupal/Core/Database/StatementWrapperIterator.php2
-rw-r--r--core/lib/Drupal/Core/Database/Transaction.php30
-rw-r--r--core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php134
-rw-r--r--core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php4
-rw-r--r--core/lib/Drupal/Core/Extension/ThemeInstaller.php120
-rw-r--r--core/lib/Drupal/Core/Hook/Attribute/FormAlter.php54
-rw-r--r--core/lib/Drupal/Core/Hook/Attribute/Hook.php26
-rw-r--r--core/lib/Drupal/Core/Hook/Attribute/Preprocess.php23
-rw-r--r--core/lib/Drupal/Core/Mailer/Transport/SendmailCommandValidationTransportFactory.php52
-rw-r--r--core/lib/Drupal/Core/Mailer/TransportServiceFactory.php44
-rw-r--r--core/lib/Drupal/Core/Mailer/TransportServiceFactoryInterface.php28
-rw-r--r--core/lib/Drupal/Core/Mailer/TransportServiceFactoryTrait.php42
-rw-r--r--core/lib/Drupal/Core/Recipe/ConsoleInputCollector.php13
-rw-r--r--core/lib/Drupal/Core/Render/Renderer.php15
-rw-r--r--core/lib/Drupal/Core/Session/SessionManager.php11
-rw-r--r--core/lib/Drupal/Core/Template/Loader/ComponentLoader.php7
-rw-r--r--core/lib/Drupal/Core/Test/SimpletestTestRunResultsStorage.php6
-rw-r--r--core/lib/Drupal/Core/Theme/Component/ComponentValidator.php4
-rw-r--r--core/lib/Drupal/Core/Theme/ComponentPluginManager.php4
-rw-r--r--core/misc/cspell/dictionary.txt2
-rw-r--r--core/misc/drupal.js5
-rw-r--r--core/modules/block/block.module4
-rw-r--r--core/modules/block/migrations/d6_block.yml2
-rw-r--r--core/modules/block/migrations/d7_block.yml2
-rw-r--r--core/modules/block/src/Hook/BlockHooks.php7
-rw-r--r--core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php4
-rw-r--r--core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php86
-rw-r--r--core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php2
-rw-r--r--core/modules/block_content/src/Controller/BlockContentController.php5
-rw-r--r--core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php6
-rw-r--r--core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php6
-rw-r--r--core/modules/block_content/tests/src/Functional/LocalActionTest.php53
-rw-r--r--core/modules/comment/src/Hook/CommentThemeHooks.php4
-rw-r--r--core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php4
-rw-r--r--core/modules/comment/tests/src/Functional/CommentAdminTest.php5
-rw-r--r--core/modules/comment/tests/src/Functional/CommentPagerTest.php3
-rw-r--r--core/modules/config/tests/src/Functional/ConfigEntityTest.php8
-rw-r--r--core/modules/config_translation/migrations/d6_block_translation.yml2
-rw-r--r--core/modules/config_translation/migrations/d7_block_translation.yml2
-rw-r--r--core/modules/contact/src/Hook/ContactFormHooks.php6
-rw-r--r--core/modules/contact/tests/src/Functional/ContactPersonalTest.php8
-rw-r--r--core/modules/contextual/contextual.libraries.yml20
-rw-r--r--core/modules/contextual/css/contextual.theme.css4
-rw-r--r--core/modules/contextual/js/contextual.js99
-rw-r--r--core/modules/contextual/js/contextual.toolbar.js35
-rw-r--r--core/modules/contextual/js/contextualModelView.js254
-rw-r--r--core/modules/contextual/js/models/StateModel.js130
-rw-r--r--core/modules/contextual/js/toolbar/contextualToolbarModelView.js175
-rw-r--r--core/modules/contextual/js/toolbar/models/StateModel.js126
-rw-r--r--core/modules/contextual/js/toolbar/views/AuralView.js122
-rw-r--r--core/modules/contextual/js/toolbar/views/VisualView.js85
-rw-r--r--core/modules/contextual/js/views/AuralView.js59
-rw-r--r--core/modules/contextual/js/views/KeyboardView.js62
-rw-r--r--core/modules/contextual/js/views/RegionView.js75
-rw-r--r--core/modules/contextual/js/views/VisualView.js109
-rw-r--r--core/modules/contextual/src/Hook/ContextualThemeHooks.php4
-rw-r--r--core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php75
-rw-r--r--core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php53
-rw-r--r--core/modules/dblog/tests/src/Functional/DbLogTest.php11
-rw-r--r--core/modules/editor/tests/src/Functional/EditorAdminTest.php4
-rw-r--r--core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php3
-rw-r--r--core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php8
-rw-r--r--core/modules/file/tests/src/Functional/SaveUploadTest.php6
-rw-r--r--core/modules/help/src/HelpTopicTwigLoader.php2
-rw-r--r--core/modules/help/src/HelpTwigExtension.php2
-rw-r--r--core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php2
-rw-r--r--core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php2
-rw-r--r--core/modules/help/tests/src/Unit/HelpTopicTwigTest.php6
-rw-r--r--core/modules/image/config/install/image.style.large.yml2
-rw-r--r--core/modules/image/config/install/image.style.medium.yml2
-rw-r--r--core/modules/image/config/install/image.style.thumbnail.yml2
-rw-r--r--core/modules/image/config/install/image.style.wide.yml2
-rw-r--r--core/modules/image/config/schema/image.schema.yml4
-rw-r--r--core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php82
-rw-r--r--core/modules/image/tests/src/Kernel/ImageEffectsTest.php25
-rw-r--r--core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php3
-rw-r--r--core/modules/locale/src/Hook/LocaleThemeHooks.php4
-rw-r--r--core/modules/mailer/mailer.info.yml6
-rw-r--r--core/modules/mailer/mailer.services.yml48
-rw-r--r--core/modules/mailer/src/Hook/MailerHooks.php35
-rw-r--r--core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml5
-rw-r--r--core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml6
-rw-r--r--core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php54
-rw-r--r--core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml5
-rw-r--r--core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml7
-rw-r--r--core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php26
-rw-r--r--core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php33
-rw-r--r--core/modules/mailer/tests/src/Functional/GenericTest.php14
-rw-r--r--core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php57
-rw-r--r--core/modules/mailer/tests/src/Kernel/TransportTest.php160
-rw-r--r--core/modules/media_library/config/install/image.style.media_library.yml2
-rw-r--r--core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php6
-rw-r--r--core/modules/migrate/tests/src/Unit/MigrateSourceTest.php27
-rw-r--r--core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php1
-rw-r--r--core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php1
-rw-r--r--core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php2
-rw-r--r--core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php2
-rw-r--r--core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php8
-rw-r--r--core/modules/node/js/node.preview.js7
-rw-r--r--core/modules/node/src/Controller/NodeController.php3
-rw-r--r--core/modules/node/src/Hook/NodeHooks.php9
-rw-r--r--core/modules/node/src/Hook/NodeThemeHooks.php4
-rw-r--r--core/modules/node/src/NodeAccessControlHandler.php9
-rw-r--r--core/modules/node/src/NodePermissions.php24
-rw-r--r--core/modules/node/src/Plugin/Block/SyndicateBlock.php6
-rw-r--r--core/modules/node/src/Plugin/views/UidRevisionTrait.php38
-rw-r--r--core/modules/node/src/Plugin/views/argument/UidRevision.php7
-rw-r--r--core/modules/node/src/Plugin/views/filter/UidRevision.php13
-rw-r--r--core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php89
-rw-r--r--core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php102
-rw-r--r--core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php16
-rw-r--r--core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php2
-rw-r--r--core/modules/node/tests/src/Functional/NodeTranslationUITest.php24
-rw-r--r--core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php1
-rw-r--r--core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php8
-rw-r--r--core/modules/package_manager/package_manager.api.php7
-rw-r--r--core/modules/package_manager/package_manager.services.yml7
-rw-r--r--core/modules/package_manager/src/Attribute/AllowDirectWrite.php21
-rw-r--r--core/modules/package_manager/src/ComposerInspector.php2
-rw-r--r--core/modules/package_manager/src/DirectWritePreconditionBypass.php98
-rw-r--r--core/modules/package_manager/src/EventSubscriber/ChangeLogger.php10
-rw-r--r--core/modules/package_manager/src/EventSubscriber/DirectWriteSubscriber.php92
-rw-r--r--core/modules/package_manager/src/SandboxManagerBase.php80
-rw-r--r--core/modules/package_manager/src/Validator/LockFileValidator.php6
-rw-r--r--core/modules/package_manager/src/Validator/RsyncValidator.php6
-rw-r--r--core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php2
-rw-r--r--core/modules/package_manager/tests/src/Build/PackageInstallTest.php7
-rw-r--r--core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php4
-rw-r--r--core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php2
-rw-r--r--core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php234
-rw-r--r--core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php22
-rw-r--r--core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php9
-rw-r--r--core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php2
-rw-r--r--core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php4
-rw-r--r--core/modules/sqlite/src/Driver/Database/sqlite/Connection.php2
-rw-r--r--core/modules/sqlite/src/Driver/Database/sqlite/Statement.php2
-rw-r--r--core/modules/system/css/components/position-container.module.css8
-rw-r--r--core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php12
-rw-r--r--core/modules/system/system.libraries.yml1
-rw-r--r--core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gzbin162581 -> 162168 bytes
-rw-r--r--core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gzbin596855 -> 596593 bytes
-rw-r--r--core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php38
-rw-r--r--core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php6
-rw-r--r--core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php6
-rw-r--r--core/modules/system/tests/modules/session_test/session_test.routing.yml22
-rw-r--r--core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php35
-rw-r--r--core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php127
-rw-r--r--core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php4
-rw-r--r--core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php2
-rw-r--r--core/modules/system/tests/src/Functional/Form/FormTest.php32
-rw-r--r--core/modules/system/tests/src/Functional/Session/LegacySessionTest.php44
-rw-r--r--core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php7
-rw-r--r--core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php1
-rw-r--r--core/modules/toolbar/js/escapeAdmin.js2
-rw-r--r--core/modules/toolbar/js/views/ToolbarVisualView.js4
-rw-r--r--core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php52
-rw-r--r--core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js27
-rw-r--r--core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php38
-rw-r--r--core/modules/views/js/ajax_view.js2
-rw-r--r--core/modules/views/src/Entity/View.php2
-rw-r--r--core/modules/views/src/Form/ViewsExposedForm.php1
-rw-r--r--core/modules/views/src/Plugin/views/display/DisplayPluginBase.php7
-rw-r--r--core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml37
-rw-r--r--core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml7
-rw-r--r--core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml8
-rw-r--r--core/modules/views/tests/src/Functional/GlossaryTest.php1
-rw-r--r--core/modules/views/tests/src/Functional/Plugin/DisplayTest.php13
-rw-r--r--core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php34
-rw-r--r--core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php5
-rw-r--r--core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php55
-rw-r--r--core/modules/views/views.theme.inc14
-rw-r--r--core/profiles/demo_umami/config/install/image.style.large_21_9.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.large_21_9_2x.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.large_3_2_2x.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.large_3_2_768x512.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.medium_21_9.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.medium_3_2_2x.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.medium_3_2_600x400.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.medium_8_7.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_large.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_medium.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_small.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_tiny.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_wide.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.small_21_9.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.square_large.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.square_medium.yml2
-rw-r--r--core/profiles/demo_umami/config/install/image.style.square_small.yml2
-rw-r--r--core/profiles/demo_umami/config/optional/image.style.max_1300x1300.yml2
-rw-r--r--core/profiles/demo_umami/config/optional/image.style.max_2600x2600.yml2
-rw-r--r--core/profiles/demo_umami/config/optional/image.style.max_325x325.yml2
-rw-r--r--core/profiles/demo_umami/config/optional/image.style.max_650x650.yml2
-rw-r--r--core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php4
-rw-r--r--core/profiles/standard/config/optional/image.style.max_1300x1300.yml2
-rw-r--r--core/profiles/standard/config/optional/image.style.max_2600x2600.yml2
-rw-r--r--core/profiles/standard/config/optional/image.style.max_325x325.yml2
-rw-r--r--core/profiles/standard/config/optional/image.style.max_650x650.yml2
-rw-r--r--core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php26
-rw-r--r--core/profiles/standard/tests/src/Traits/StandardTestTrait.php2
-rw-r--r--core/recipes/standard/recipe.yml1
-rw-r--r--core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml2
-rw-r--r--core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml2
-rw-r--r--core/recipes/standard_responsive_images/config/image.style.max_325x325.yml2
-rw-r--r--core/recipes/standard_responsive_images/config/image.style.max_650x650.yml2
-rw-r--r--core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php36
-rw-r--r--core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php37
-rw-r--r--core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php51
-rw-r--r--core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php38
-rw-r--r--core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php1
-rw-r--r--core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php46
-rw-r--r--core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php31
-rw-r--r--core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php6
-rw-r--r--core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php22
-rw-r--r--core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php1278
-rw-r--r--core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php1036
-rw-r--r--core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php980
-rw-r--r--core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php30
-rw-r--r--core/tests/Drupal/Tests/Core/Database/ConnectionTest.php6
-rw-r--r--core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php43
-rw-r--r--core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php92
-rw-r--r--core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php9
-rw-r--r--core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php3
-rw-r--r--core/themes/claro/css/classy/components/tablesort.css3
-rw-r--r--core/themes/claro/css/components/breadcrumb.pcss.css2
-rw-r--r--core/themes/claro/css/components/card.pcss.css2
-rw-r--r--core/themes/claro/css/components/details.css1
-rw-r--r--core/themes/claro/css/components/details.pcss.css8
-rw-r--r--core/themes/claro/css/components/dialog.css3
-rw-r--r--core/themes/claro/css/components/dialog.pcss.css3
-rw-r--r--core/themes/claro/css/components/form--checkbox-radio.pcss.css2
-rw-r--r--core/themes/claro/css/components/form--managed-file.pcss.css10
-rw-r--r--core/themes/claro/css/components/form--password-confirm.pcss.css6
-rw-r--r--core/themes/claro/css/components/form--select.pcss.css7
-rw-r--r--core/themes/claro/css/components/messages.pcss.css4
-rw-r--r--core/themes/claro/css/components/page-title.pcss.css2
-rw-r--r--core/themes/claro/css/components/shortcut.pcss.css10
-rw-r--r--core/themes/claro/css/components/system-admin--admin-list.pcss.css2
-rw-r--r--core/themes/claro/css/components/system-status-counter.css7
-rw-r--r--core/themes/claro/css/components/system-status-counter.pcss.css9
-rw-r--r--core/themes/claro/css/components/tabledrag.pcss.css12
-rw-r--r--core/themes/claro/css/components/tabs.css4
-rw-r--r--core/themes/claro/css/components/tabs.pcss.css4
-rw-r--r--core/themes/claro/css/components/vertical-tabs.pcss.css4
-rw-r--r--core/themes/claro/css/components/views_ui.admin.pcss.css2
-rw-r--r--core/themes/claro/images/icons/buttonText/ex.svg1
-rw-r--r--core/themes/olivero/config/optional/block.block.olivero_syndicate.yml20
-rw-r--r--core/themes/olivero/olivero.libraries.yml16
-rw-r--r--core/themes/olivero/olivero.theme9
-rw-r--r--core/themes/stable9/stable9.info.yml1
267 files changed, 6131 insertions, 2871 deletions
diff --git a/core/.deprecation-ignore.txt b/core/.deprecation-ignore.txt
index 3eb0b15f07a..12772fbb55b 100644
--- a/core/.deprecation-ignore.txt
+++ b/core/.deprecation-ignore.txt
@@ -2,37 +2,33 @@
# deprecated code.
# See https://www.drupal.org/node/3285162 for more details.
-%The "Symfony\\Component\\Validator\\Context\\ExecutionContextInterface::.*\(\)" method is considered internal Used by the validator engine\. (Should not be called by user\W+code\. )?It may change without further notice\. You should not extend it from "[^"]+"\.%
+# @todo Remove when we no longer support PHPUnit 10.
+%The "PHPUnit\\Framework\\TestCase::__construct\(\)" method is considered internal.* You should not extend it from "Drupal\\[^"]+"%
-# Skip some dependencies' DebugClassLoader forward compatibility warnings.
-%Method "Behat\\[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "Doctrine\\Common\\Annotations\\Reader::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "Twig\\Extension\\ExtensionInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "Twig\\Loader\\FilesystemLoader::findTemplate\(\)" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "Twig\\Loader\\LoaderInterface::exists\(\)" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "Twig\\Node\\Node::compile\(\)" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "Twig\\NodeVisitor\\AbstractNodeVisitor::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "Twig\\NodeVisitor\\NodeVisitorInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "Twig\\TokenParser\\TokenParserInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "WebDriver\\Service\\CurlServiceInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-
-# Indirect deprecations. These are not in Drupal's remit to fix, but it is
-# worth keeping track of dependencies' issues.
-%Method "[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "org\\bovigo\\vfs\\[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-%Method "[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in (child class|implementation) "OpenTelemetry\\[^"]+" now to avoid errors or add an explicit @return annotation to suppress this message%
-
-# The following deprecation is listed for Twig 2 compatibility when unit
-# testing using \Symfony\Component\ErrorHandler\DebugClassLoader.
-%The "Twig\\Template" class is considered internal\. It may change without further notice\. You should not use it from "Drupal\\Tests\\Core\\Template\\StubTwigTemplate"\.%
+# Internal code that we cannot avoid extending.
+%The "PHPUnit\\Framework\\TestCase::__construct\(\)" method is considered final.* You should not extend it from "Drupal\\[^"]+"%
%The "Twig\\Environment::getTemplateClass\(\)" method is considered internal\. It may change without further notice\. You should not extend it from "Drupal\\Core\\Template\\TwigEnvironment"\.%
-# PHPUnit 10.
-%The "PHPUnit\\Framework\\TestCase::__construct\(\)" method is considered internal.*You should not extend it from "Drupal\\[^"]+"%
+# Skip some dependencies' DebugClassLoader forward compatibility warnings, in
+# order to let contrib modules make their necessary fixes first.
+%Method "Behat\\Mink\\Driver\\CoreDriver::[^"]+" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\FunctionalJavascriptTests\\DrupalSelenium2Driver" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Behat\\Mink\\WebAssert::[^"]+" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\FunctionalJavascriptTests\\WebDriverWebAssert" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Behat\\Mink\\WebAssert::[^"]+" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\Tests\\WebAssert" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Doctrine\\Common\\Annotations\\Reader::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "Drupal\\Component\\Annotation\\Doctrine\\SimpleAnnotationReader" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Twig\\Extension\\ExtensionInterface::[^"]+" might add "array" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\TwigExtension" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Twig\\Loader\\FilesystemLoader::findTemplate\(\)" might add "\?string" as a native return type declaration in the future. Do the same in child class "Drupal\\Core\\Template\\Loader\\FilesystemLoader" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Twig\\Loader\\LoaderInterface::exists\(\)" might add "bool" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\Loader\\StringLoader" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Twig\\Node\\Node::compile\(\)" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\Core\\Template\\TwigNodeCheckDeprecations" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Twig\\Node\\Node::compile\(\)" might add "void" as a native return type declaration in the future. Do the same in child class "Drupal\\Core\\Template\\TwigNodeTrans" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Twig\\NodeVisitor\\NodeVisitorInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\RemoveCheckToStringNodeVisitor" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Twig\\NodeVisitor\\NodeVisitorInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\TwigNodeVisitor" now to avoid errors or add an explicit @return annotation to suppress this message%
+%Method "Twig\\TokenParser\\TokenParserInterface::[^"]+" might add "[^"]+" as a native return type declaration in the future. Do the same in implementation "Drupal\\Core\\Template\\TwigTransTokenParser" now to avoid errors or add an explicit @return annotation to suppress this message%
-# PHPUnit 11.
-%The "PHPUnit\\Framework\\TestCase::__construct\(\)" method is considered final\. It may change without further notice as of its next major version\. You should not extend it from "Drupal\\[^"]+"%
+# Indirect deprecations. These are not in Drupal's remit to fix, but it is
+# worth keeping track of dependencies' issues.
+%Method "Iterator::[^"]+" might add "void" as a native return type declaration in the future. Do the same in implementation "org\\bovigo\\vfs\\vfsStreamContainerIterator" now to avoid errors or add an explicit @return annotation to suppress this message%
-# Symfony 7.2
+# Symfony 7.2.
%Since symfony/http-foundation 7.2: NativeSessionStorage's "sid_length" option is deprecated and will be ignored in Symfony 8.0.%
%Since symfony/http-foundation 7.2: NativeSessionStorage's "sid_bits_per_character" option is deprecated and will be ignored in Symfony 8.0.%
@@ -44,5 +40,5 @@
%The "Drupal\\Core\\Entity\\Query\\QueryBase::hasAllTags\(\)" method will require a new "string \.\.\. \$tags" argument in the next major version of its interface%
%The "Drupal\\Core\\Entity\\Query\\QueryBase::hasAnyTag\(\)" method will require a new "string \.\.\. \$tags" argument in the next major version of its interface%
-# Symfony 7.3
+# Symfony 7.3.
%Since symfony/validator 7.3: Passing an array of options to configure the "[^"]+" constraint is deprecated, use named arguments instead.%
diff --git a/core/.phpstan-baseline.php b/core/.phpstan-baseline.php
index 551b1b4c9f1..b7f87b4ee01 100644
--- a/core/.phpstan-baseline.php
+++ b/core/.phpstan-baseline.php
@@ -1052,12 +1052,6 @@ $ignoreErrors[] = [
'path' => __DIR__ . '/lib/Drupal/Component/Plugin/LazyPluginCollection.php',
];
$ignoreErrors[] = [
- 'message' => '#^Method Drupal\\\\Component\\\\Plugin\\\\LazyPluginCollection\\:\\:getIterator\\(\\) return type with generic class ArrayIterator does not specify its types\\: TKey, TValue$#',
- 'identifier' => 'missingType.generics',
- 'count' => 1,
- 'path' => __DIR__ . '/lib/Drupal/Component/Plugin/LazyPluginCollection.php',
-];
-$ignoreErrors[] = [
'message' => '#^Method Drupal\\\\Component\\\\Plugin\\\\LazyPluginCollection\\:\\:initializePlugin\\(\\) has no return type specified\\.$#',
'identifier' => 'missingType.return',
'count' => 1,
@@ -15836,12 +15830,6 @@ $ignoreErrors[] = [
'path' => __DIR__ . '/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php',
];
$ignoreErrors[] = [
- 'message' => '#^Variable \\$unrestricted_tab_count might not be defined\\.$#',
- 'identifier' => 'variable.undefined',
- 'count' => 1,
- 'path' => __DIR__ . '/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php',
-];
-$ignoreErrors[] = [
'message' => '#^Method Drupal\\\\datetime\\\\DateTimeComputed\\:\\:setValue\\(\\) has no return type specified\\.$#',
'identifier' => 'missingType.return',
'count' => 1,
@@ -34384,12 +34372,6 @@ $ignoreErrors[] = [
'path' => __DIR__ . '/modules/system/tests/modules/services_defaults_test/src/TestService.php',
];
$ignoreErrors[] = [
- 'message' => '#^Method Drupal\\\\session_test\\\\Controller\\\\SessionTestController\\:\\:triggerWriteException\\(\\) has no return type specified\\.$#',
- 'identifier' => 'missingType.return',
- 'count' => 1,
- 'path' => __DIR__ . '/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php',
-];
-$ignoreErrors[] = [
'message' => '#^Method Drupal\\\\session_test\\\\EventSubscriber\\\\SessionTestSubscriber\\:\\:onKernelRequestSessionTest\\(\\) has no return type specified\\.$#',
'identifier' => 'missingType.return',
'count' => 1,
@@ -51092,6 +51074,174 @@ $ignoreErrors[] = [
'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php',
];
$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addBaseField\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addBaseFieldIndex\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addBundleField\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addEntityIndex\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addLongNameBaseField\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:addRevisionableBaseField\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:applyEntityUpdates\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:deleteEntityType\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:doEntityUpdate\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:doFieldUpdate\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:enableNewEntityType\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:makeBaseFieldEntityKey\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:modifyBaseField\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:modifyBundleField\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:removeBaseField\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:removeBaseFieldIndex\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:removeBundleField\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:removeEntityIndex\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:renameBaseTable\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:renameDataTable\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:renameRevisionBaseTable\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:renameRevisionDataTable\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:resetEntityType\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToNotRevisionable\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToNotTranslatable\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToRevisionable\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToRevisionableAndTranslatable\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
+ 'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateMultipleTypesTest\\:\\:updateEntityTypeToTranslatable\\(\\) has no return type specified\\.$#',
+ 'identifier' => 'missingType.return',
+ 'count' => 1,
+ 'path' => __DIR__ . '/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php',
+];
+$ignoreErrors[] = [
'message' => '#^Method Drupal\\\\KernelTests\\\\Core\\\\Entity\\\\EntityDefinitionUpdateProviderTest\\:\\:addBaseField\\(\\) has no return type specified\\.$#',
'identifier' => 'missingType.return',
'count' => 1,
diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt
index e44df814938..08e531165d8 100644
--- a/core/MAINTAINERS.txt
+++ b/core/MAINTAINERS.txt
@@ -210,7 +210,7 @@ Entity API
- Sascha Grossenbacher 'Berdir' https://www.drupal.org/u/berdir
Extension API
-- ?
+- Nic Laflin 'nicxvan' https://www.drupal.org/u/nicxvan
Field API
- Andrei Mateescu 'amateescu' https://www.drupal.org/u/amateescu
@@ -228,6 +228,7 @@ Filter
Form API
- Alex Bronstein 'effulgentsia' https://www.drupal.org/u/effulgentsia
- Tim Plunkett 'tim.plunkett' https://www.drupal.org/u/tim.plunkett
+- Lee Rowlands 'larowlan' https://www.drupal.org/u/larowlan
History
- Andrey Postnikov 'andypost' https://www.drupal.org/u/andypost
@@ -354,6 +355,7 @@ Recipes
Render API
- Alex Bronstein 'effulgentsia' https://www.drupal.org/u/effulgentsia
- Moshe Weitzman 'moshe weitzman' https://www.drupal.org/u/moshe-weitzman
+- Lee Rowlands 'larowlan' https://www.drupal.org/u/larowlan
Request Processing
- ?
diff --git a/core/composer.json b/core/composer.json
index 59bca714e5b..af9d9ca3162 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -32,7 +32,8 @@
"symfony/serializer": "^7.3@beta",
"symfony/validator": "^7.3@beta",
"symfony/process": "^7.3@beta",
- "symfony/polyfill-iconv": "^1.26",
+ "symfony/polyfill-iconv": "^1.32",
+ "symfony/polyfill-php84": "^1.32",
"symfony/yaml": "^7.3@beta",
"revolt/event-loop": "^1.0",
"twig/twig": "^3.21.0",
diff --git a/core/config/schema/core.data_types.schema.yml b/core/config/schema/core.data_types.schema.yml
index b7798d0373d..3e88997a1cf 100644
--- a/core/config/schema/core.data_types.schema.yml
+++ b/core/config/schema/core.data_types.schema.yml
@@ -940,6 +940,18 @@ field.value.timestamp:
type: timestamp
label: 'Value'
+field.value.language:
+ type: mapping
+ label: 'Language value'
+ mapping:
+ value:
+ # Not this doesn't make use of type: langcode because that doesn't allow null values.
+ type: string
+ label: 'Language value'
+ constraints:
+ Choice:
+ callback: 'Drupal\Core\TypedData\Plugin\DataType\LanguageReference::getAllValidLangcodes'
+
# Text with a text format.
text_format:
type: mapping
diff --git a/core/core.services.yml b/core/core.services.yml
index dc37393615e..789eee02214 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -709,7 +709,7 @@ services:
Drupal\Core\Extension\ThemeHandlerInterface: '@theme_handler'
theme_installer:
class: Drupal\Core\Extension\ThemeInstaller
- arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state', '@extension.list.module', '@theme.registry', '@extension.list.theme']
+ arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state', '@extension.list.module', '@theme.registry', '@extension.list.theme', '@plugin.manager.sdc']
Drupal\Core\Extension\ThemeInstallerInterface: '@theme_installer'
entity.memory_cache:
class: Drupal\Core\Cache\MemoryCache\LruMemoryCache
diff --git a/core/includes/common.inc b/core/includes/common.inc
index 47f360caffe..9c60a811ce3 100644
--- a/core/includes/common.inc
+++ b/core/includes/common.inc
@@ -10,6 +10,7 @@
use Drupal\Component\Utility\SortArray;
use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheTagsPurgeInterface;
use Drupal\Core\DrupalKernel;
/**
@@ -418,6 +419,13 @@ function drupal_flush_all_caches($kernel = NULL): void {
$module_handler = \Drupal::moduleHandler();
// Flush all persistent caches.
$module_handler->invokeAll('cache_flush');
+ // Purge cache tags immediately before flushing cache bins. If a cache tag is
+ // invalidated between the tags being purged and cache bins are flushed, then
+ // it will be included in the checksum of any new cache items, but still valid
+ // because the tag was written before the creation of cache item.
+ if (($invalidator = \Drupal::service('cache_tags.invalidator')) && ($invalidator instanceof CacheTagsPurgeInterface)) {
+ $invalidator->purge();
+ }
foreach (Cache::getBins() as $cache_backend) {
$cache_backend->deleteAll();
}
diff --git a/core/lib/Drupal.php b/core/lib/Drupal.php
index 63514b60eff..9b6dc876501 100644
--- a/core/lib/Drupal.php
+++ b/core/lib/Drupal.php
@@ -76,7 +76,7 @@ class Drupal {
/**
* The current system version.
*/
- const VERSION = '11.2-dev';
+ const VERSION = '11.3-dev';
/**
* Core API compatibility.
diff --git a/core/lib/Drupal/Component/Plugin/LazyPluginCollection.php b/core/lib/Drupal/Component/Plugin/LazyPluginCollection.php
index e11003efcb2..86408c23390 100644
--- a/core/lib/Drupal/Component/Plugin/LazyPluginCollection.php
+++ b/core/lib/Drupal/Component/Plugin/LazyPluginCollection.php
@@ -142,7 +142,11 @@ abstract class LazyPluginCollection implements \IteratorAggregate, \Countable {
$this->remove($instance_id);
}
- public function getIterator(): \ArrayIterator {
+ /**
+ * @return \Traversable<string, mixed>
+ * A traversable generator.
+ */
+ public function getIterator(): \Traversable {
$instances = [];
foreach ($this->getInstanceIds() as $instance_id) {
$instances[$instance_id] = $this->get($instance_id);
diff --git a/core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php b/core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php
index 6c02649d270..dad8bc10a21 100644
--- a/core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php
+++ b/core/lib/Drupal/Core/Cache/CacheTagsInvalidator.php
@@ -7,7 +7,7 @@ use Drupal\Component\Assertion\Inspector;
/**
* Passes cache tag events to classes that wish to respond to them.
*/
-class CacheTagsInvalidator implements CacheTagsInvalidatorInterface {
+class CacheTagsInvalidator implements CacheTagsInvalidatorInterface, CacheTagsPurgeInterface {
/**
* Holds an array of cache tags invalidators.
@@ -54,6 +54,17 @@ class CacheTagsInvalidator implements CacheTagsInvalidatorInterface {
}
/**
+ * {@inheritdoc}
+ */
+ public function purge(): void {
+ foreach ($this->invalidators as $invalidator) {
+ if ($invalidator instanceof CacheTagsPurgeInterface) {
+ $invalidator->purge();
+ }
+ }
+ }
+
+ /**
* Adds a cache tags invalidator.
*
* @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $invalidator
diff --git a/core/lib/Drupal/Core/Cache/CacheTagsPurgeInterface.php b/core/lib/Drupal/Core/Cache/CacheTagsPurgeInterface.php
new file mode 100644
index 00000000000..24c110372d1
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/CacheTagsPurgeInterface.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Cache;
+
+/**
+ * Provides purging of cache tag invalidations.
+ *
+ * Backends that persistently store cache tag invalidations can use this
+ * interface to implement purging of cache tag invalidations. By default, cache
+ * tag purging will only be called during drupal_flush_all_caches(), after all
+ * other caches have been cleared.
+ *
+ * @ingroup cache
+ */
+interface CacheTagsPurgeInterface {
+
+ /**
+ * Purge cache tag invalidations.
+ */
+ public function purge(): void;
+
+}
diff --git a/core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php b/core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php
index cb88c69495a..9602bc8ba5d 100644
--- a/core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php
+++ b/core/lib/Drupal/Core/Cache/DatabaseCacheTagsChecksum.php
@@ -8,7 +8,7 @@ use Drupal\Core\Database\DatabaseException;
/**
* Cache tags invalidations checksum implementation that uses the database.
*/
-class DatabaseCacheTagsChecksum implements CacheTagsChecksumInterface, CacheTagsInvalidatorInterface, CacheTagsChecksumPreloadInterface {
+class DatabaseCacheTagsChecksum implements CacheTagsChecksumInterface, CacheTagsInvalidatorInterface, CacheTagsChecksumPreloadInterface, CacheTagsPurgeInterface {
use CacheTagsChecksumTrait;
@@ -70,6 +70,22 @@ class DatabaseCacheTagsChecksum implements CacheTagsChecksumInterface, CacheTags
}
/**
+ * {@inheritdoc}
+ */
+ public function purge(): void {
+ try {
+ $this->connection->truncate('cachetags')->execute();
+ }
+ catch (\Throwable $e) {
+ // If the table does not exist yet, there is nothing to purge.
+ if (!$this->ensureTableExists()) {
+ throw $e;
+ }
+ }
+ $this->reset();
+ }
+
+ /**
* Check if the cache tags table exists and create it if not.
*/
protected function ensureTableExists() {
diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php
index 489b2f5f94d..133dc99f182 100644
--- a/core/lib/Drupal/Core/Database/Connection.php
+++ b/core/lib/Drupal/Core/Database/Connection.php
@@ -433,7 +433,7 @@ abstract class Connection {
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 {
@@ -654,7 +654,7 @@ abstract class Connection {
assert(!isset($options['return']), 'Passing "return" option to query() has no effect. See https://www.drupal.org/node/3185520');
assert(!isset($options['target']), 'Passing "target" option to query() has no effect. See https://www.drupal.org/node/2993033');
if (isset($options['fetch']) && is_int($options['fetch'])) {
- @trigger_error("Passing the 'fetch' key as an integer to \$options in query() 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 query() 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);
}
// Use default values if not already set.
diff --git a/core/lib/Drupal/Core/Database/Statement/PdoTrait.php b/core/lib/Drupal/Core/Database/Statement/PdoTrait.php
index 3e1f104c9f4..f477b466a63 100644
--- a/core/lib/Drupal/Core/Database/Statement/PdoTrait.php
+++ b/core/lib/Drupal/Core/Database/Statement/PdoTrait.php
@@ -12,7 +12,7 @@ trait PdoTrait {
/**
* Converts a FetchAs mode to a \PDO::FETCH_* constant value.
*
- * @param \Drupal\Core\Database\FetchAs $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs $mode
* The FetchAs mode.
*
* @return int
@@ -34,7 +34,7 @@ trait PdoTrait {
* @param int $mode
* The \PDO::FETCH_* constant value.
*
- * @return \Drupal\Core\Database\FetchAs
+ * @return \Drupal\Core\Database\Statement\FetchAs
* A FetchAs mode.
*/
protected function pdoToFetchAs(int $mode): FetchAs {
@@ -70,7 +70,7 @@ trait PdoTrait {
/**
* Sets the default fetch mode for the PDO statement.
*
- * @param \Drupal\Core\Database\FetchAs $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs $mode
* One of the cases of the FetchAs enum.
* @param int|class-string|null $columnOrClass
* If $mode is FetchAs::Column, the index of the column to fetch.
@@ -118,7 +118,7 @@ trait PdoTrait {
/**
* Fetches the next row from the PDO statement.
*
- * @param \Drupal\Core\Database\FetchAs|null $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs|null $mode
* (Optional) one of the cases of the FetchAs enum. If not specified,
* defaults to what is specified by setFetchMode().
* @param int|null $cursorOrientation
@@ -175,7 +175,7 @@ trait PdoTrait {
/**
* Returns an array containing all of the result set rows.
*
- * @param \Drupal\Core\Database\FetchAs|null $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs|null $mode
* (Optional) one of the cases of the FetchAs enum. If not specified,
* defaults to what is specified by setFetchMode().
* @param int|class-string|null $columnOrClass
diff --git a/core/lib/Drupal/Core/Database/Statement/ResultBase.php b/core/lib/Drupal/Core/Database/Statement/ResultBase.php
index 6232581f906..af1b12a5653 100644
--- a/core/lib/Drupal/Core/Database/Statement/ResultBase.php
+++ b/core/lib/Drupal/Core/Database/Statement/ResultBase.php
@@ -42,7 +42,7 @@ abstract class ResultBase {
/**
* Sets the default fetch mode for this result set.
*
- * @param \Drupal\Core\Database\FetchAs $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs $mode
* One of the cases of the FetchAs enum.
* @param array{class: class-string, constructor_args: list<mixed>, column: int, cursor_orientation?: int, cursor_offset?: int} $fetchOptions
* An array of fetch options.
@@ -55,7 +55,7 @@ abstract class ResultBase {
/**
* Fetches the next row.
*
- * @param \Drupal\Core\Database\FetchAs $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs $mode
* One of the cases of the FetchAs enum.
* @param array{class?: class-string, constructor_args?: list<mixed>, column?: int, cursor_orientation?: int, cursor_offset?: int} $fetchOptions
* An array of fetch options.
@@ -68,7 +68,7 @@ abstract class ResultBase {
/**
* Returns an array containing all of the result set rows.
*
- * @param \Drupal\Core\Database\FetchAs $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs $mode
* One of the cases of the FetchAs enum.
* @param array{class?: class-string, constructor_args?: list<mixed>, column?: int, cursor_orientation?: int, cursor_offset?: int} $fetchOptions
* An array of fetch options.
@@ -120,7 +120,7 @@ abstract class ResultBase {
*
* @param string $column
* The name of the field on which to index the array.
- * @param \Drupal\Core\Database\FetchAs $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs $mode
* One of the cases of the FetchAs enum. If set to FetchAs::Associative
* or FetchAs::List the returned value with be an array of arrays. For any
* other value it will be an array of objects. If not specified, defaults to
diff --git a/core/lib/Drupal/Core/Database/Statement/StatementBase.php b/core/lib/Drupal/Core/Database/Statement/StatementBase.php
index c193c5d3502..98fa378d58f 100644
--- a/core/lib/Drupal/Core/Database/Statement/StatementBase.php
+++ b/core/lib/Drupal/Core/Database/Statement/StatementBase.php
@@ -180,7 +180,7 @@ abstract class StatementBase implements \Iterator, StatementInterface {
*/
public function setFetchMode($mode, $a1 = NULL, $a2 = []) {
if (is_int($mode)) {
- @trigger_error("Passing the \$mode argument as an integer to setFetchMode() 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 \$mode argument as an integer to setFetchMode() 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);
$mode = $this->pdoToFetchAs($mode);
}
assert($mode instanceof FetchAs);
@@ -217,7 +217,7 @@ abstract class StatementBase implements \Iterator, StatementInterface {
*/
public function fetch($mode = NULL, $cursorOrientation = NULL, $cursorOffset = NULL) {
if (is_int($mode)) {
- @trigger_error("Passing the \$mode argument as an integer to fetch() 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 \$mode argument as an integer to fetch() 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);
$mode = $this->pdoToFetchAs($mode);
}
assert($mode === NULL || $mode instanceof FetchAs);
@@ -292,7 +292,7 @@ abstract class StatementBase implements \Iterator, StatementInterface {
*/
public function fetchAll($mode = NULL, $columnIndex = NULL, $constructorArguments = NULL) {
if (is_int($mode)) {
- @trigger_error("Passing the \$mode argument as an integer to fetchAll() 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 \$mode argument as an integer to fetchAll() 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);
$mode = $this->pdoToFetchAs($mode);
}
@@ -325,7 +325,7 @@ abstract class StatementBase implements \Iterator, StatementInterface {
*/
public function fetchAllAssoc($key, $fetch = NULL) {
if (is_int($fetch)) {
- @trigger_error("Passing the \$fetch argument as an integer to fetchAllAssoc() 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 argument as an integer to fetchAllAssoc() 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);
$fetch = $this->pdoToFetchAs($fetch);
}
assert($fetch === NULL || $fetch instanceof FetchAs);
diff --git a/core/lib/Drupal/Core/Database/StatementInterface.php b/core/lib/Drupal/Core/Database/StatementInterface.php
index c4cafb9d289..7f906620674 100644
--- a/core/lib/Drupal/Core/Database/StatementInterface.php
+++ b/core/lib/Drupal/Core/Database/StatementInterface.php
@@ -65,7 +65,7 @@ interface StatementInterface extends \Traversable {
/**
* Sets the default fetch mode for this statement.
*
- * @param \Drupal\Core\Database\FetchAs|int $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs|int $mode
* One of the cases of the FetchAs enum, or (deprecated) a \PDO::FETCH_*
* constant.
* @param string|int|null $a1
@@ -87,7 +87,7 @@ interface StatementInterface extends \Traversable {
/**
* Fetches the next row from a result set.
*
- * @param \Drupal\Core\Database\FetchAs|int|null $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs|int|null $mode
* (Optional) one of the cases of the FetchAs enum, or (deprecated) a
* \PDO::FETCH_* constant. If not specified, defaults to what is specified
* by setFetchMode().
@@ -147,7 +147,7 @@ interface StatementInterface extends \Traversable {
/**
* Returns an array containing all of the result set rows.
*
- * @param \Drupal\Core\Database\FetchAs|int|null $mode
+ * @param \Drupal\Core\Database\Statement\FetchAs|int|null $mode
* (Optional) one of the cases of the FetchAs enum, or (deprecated) a
* \PDO::FETCH_* constant. If not specified, defaults to what is specified
* by setFetchMode().
@@ -206,7 +206,7 @@ interface StatementInterface extends \Traversable {
*
* @param string $key
* The name of the field on which to index the array.
- * @param \Drupal\Core\Database\FetchAs|int|string|null $fetch
+ * @param \Drupal\Core\Database\Statement\FetchAs|int|string|null $fetch
* (Optional) the fetch mode to use. One of the cases of the FetchAs enum,
* or (deprecated) a \PDO::FETCH_* constant. If set to FetchAs::Associative
* or FetchAs::List the returned value with be an array of arrays. For any
diff --git a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php
index 96bc07e7f89..8a2a73f1bf7 100644
--- a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php
+++ b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php
@@ -101,7 +101,7 @@ class StatementPrefetchIterator extends StatementBase {
*/
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);
}
$startEvent = $this->dispatchStatementExecutionStartEvent($args ?? []);
diff --git a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php
index 88dc007f540..f580d645cad 100644
--- a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php
+++ b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php
@@ -52,7 +52,7 @@ class StatementWrapperIterator extends StatementBase {
*/
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);
}
if (isset($options['fetch'])) {
diff --git a/core/lib/Drupal/Core/Database/Transaction.php b/core/lib/Drupal/Core/Database/Transaction.php
index dcecc44e17c..b8693e4bb76 100644
--- a/core/lib/Drupal/Core/Database/Transaction.php
+++ b/core/lib/Drupal/Core/Database/Transaction.php
@@ -30,12 +30,12 @@ class Transaction {
/**
* Destructs the object.
*
- * Depending on the nesting level of the object, this leads to a COMMIT (for
- * a root item) or to a RELEASE SAVEPOINT (for a savepoint item) executed on
- * the database.
+ * If the transaction is still active at this stage, and depending on the
+ * state of the transaction stack, this leads to a COMMIT (for a root item)
+ * or to a RELEASE SAVEPOINT (for a savepoint item) executed on the database.
*/
public function __destruct() {
- $this->connection->transactionManager()->unpile($this->name, $this->id);
+ $this->connection->transactionManager()->purge($this->name, $this->id);
}
/**
@@ -46,16 +46,22 @@ class Transaction {
}
/**
- * Rolls back the current transaction.
+ * Returns the transaction to the parent nesting level.
*
- * This is just a wrapper method to rollback whatever transaction stack we are
- * currently in, which is managed by the TransactionManager. Note that logging
- * needs to happen after a transaction has been rolled back or the log
- * messages will be rolled back too.
+ * Depending on the state of the transaction stack, this leads to a COMMIT
+ * operation (for a root item), or to a RELEASE SAVEPOINT operation (for a
+ * savepoint item) executed on the database.
+ */
+ public function commitOrRelease(): void {
+ $this->connection->transactionManager()->unpile($this->name, $this->id);
+ }
+
+ /**
+ * Rolls back the transaction.
*
- * Depending on the nesting level of the object, this leads to a ROLLBACK (for
- * a root item) or to a ROLLBACK TO SAVEPOINT (for a savepoint item) executed
- * on the database.
+ * Depending on the state of the transaction stack, this leads to a ROLLBACK
+ * operation (for a root item), or to a ROLLBACK TO SAVEPOINT + a RELEASE
+ * SAVEPOINT operations (for a savepoint item) executed on the database.
*/
public function rollBack() {
$this->connection->transactionManager()->rollback($this->name, $this->id);
diff --git a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php
index aa663d94226..fa1a309a767 100644
--- a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php
+++ b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerBase.php
@@ -102,6 +102,16 @@ abstract class TransactionManagerBase implements TransactionManagerInterface {
private ClientConnectionTransactionState $connectionTransactionState;
/**
+ * Whether to trigger warnings when unpiling a void transaction.
+ *
+ * Normally FALSE, is set to TRUE by specific tests checking the internal
+ * state of the transaction stack.
+ *
+ * @internal
+ */
+ public bool $triggerWarningWhenUnpilingOnVoidTransaction = FALSE;
+
+ /**
* Constructor.
*
* @param \Drupal\Core\Database\Connection $connection
@@ -202,7 +212,9 @@ abstract class TransactionManagerBase implements TransactionManagerInterface {
protected function voidStackItem(string $id): void {
// The item should be removed from $stack and added to $voidedItems for
// later processing.
- $this->voidedItems[$id] = $this->stack[$id];
+ if (isset($this->stack[$id])) {
+ $this->voidedItems[$id] = $this->stack[$id];
+ }
$this->removeStackItem($id);
}
@@ -285,14 +297,29 @@ abstract class TransactionManagerBase implements TransactionManagerInterface {
}
/**
- * {@inheritdoc}
+ * Purges a Drupal transaction from the manager.
+ *
+ * This is only called by a Transaction object's ::__destruct() method and
+ * should only be called internally by a database driver.
+ *
+ * @param string $name
+ * The name of the transaction.
+ * @param string $id
+ * The id of the transaction.
+ *
+ * @throws \Drupal\Core\Database\TransactionOutOfOrderException
+ * If a Drupal Transaction with the specified name does not exist.
+ * @throws \Drupal\Core\Database\TransactionCommitFailedException
+ * If the commit of the root transaction failed.
+ *
+ * @internal
*/
- public function unpile(string $name, string $id): void {
+ public function purge(string $name, string $id): void {
// If this is a 'root' transaction, and it is voided (that is, no longer in
// the stack), then the transaction on the database is no longer active. An
- // action such as a rollback, or a DDL statement, was executed that
- // terminated the database transaction. So, we can process the post
- // transaction callbacks.
+ // action such as a commit, a release savepoint, a rollback, or a DDL
+ // statement, was executed that terminated the database transaction. So, we
+ // can process the post transaction callbacks.
if (!isset($this->stack()[$id]) && isset($this->voidedItems[$id]) && $this->rootId === $id) {
$this->processPostTransactionCallbacks();
$this->rootId = NULL;
@@ -309,6 +336,62 @@ abstract class TransactionManagerBase implements TransactionManagerInterface {
return;
}
+ // When we get here, the transaction (or savepoint) is still active on the
+ // database. We can unpile it, and if we are left with no more items in the
+ // stack, we can also process the post transaction callbacks.
+ $this->commit($name, $id);
+ $this->removeStackItem($id);
+ if ($this->rootId === $id) {
+ $this->processPostTransactionCallbacks();
+ $this->rootId = NULL;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function unpile(string $name, string $id): void {
+ // If the transaction was voided, we cannot unpile. Skip but trigger a user
+ // warning if requested.
+ if ($this->getConnectionTransactionState() === ClientConnectionTransactionState::Voided) {
+ if ($this->triggerWarningWhenUnpilingOnVoidTransaction) {
+ trigger_error('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', E_USER_WARNING);
+ }
+ return;
+ }
+
+ // If there is no $id to commit, or if $id does not correspond to the one
+ // in the stack for that $name, the commit is out of order.
+ if (!isset($this->stack()[$id]) || $this->stack()[$id]->name !== $name) {
+ throw new TransactionOutOfOrderException("Error attempting commit of {$id}\\{$name}. Active stack: " . $this->dumpStackItemsAsString());
+ }
+
+ // Commit the transaction.
+ $this->commit($name, $id);
+
+ // Void the transaction stack item.
+ $this->voidStackItem($id);
+ }
+
+ /**
+ * Commits a Drupal transaction.
+ *
+ * @param string $name
+ * The name of the transaction.
+ * @param string $id
+ * The id of the transaction.
+ *
+ * @throws \Drupal\Core\Database\TransactionOutOfOrderException
+ * If a Drupal Transaction with the specified name does not exist.
+ * @throws \Drupal\Core\Database\TransactionCommitFailedException
+ * If the commit of the root transaction failed.
+ */
+ protected function commit(string $name, string $id): void {
+ if ($this->getConnectionTransactionState() !== ClientConnectionTransactionState::Active) {
+ // The stack got corrupted.
+ throw new TransactionOutOfOrderException("Transaction {$id}\\{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString());
+ }
+
// If we are not releasing the last savepoint but an earlier one, or
// committing a root transaction while savepoints are active, all
// subsequent savepoints will be released as well. The stack must be
@@ -317,33 +400,20 @@ abstract class TransactionManagerBase implements TransactionManagerInterface {
$this->voidStackItem((string) $i);
}
- if ($this->getConnectionTransactionState() === ClientConnectionTransactionState::Active) {
- if ($this->stackDepth() > 1 && $this->stack()[$id]->type === StackItemType::Savepoint) {
- // Release the client transaction savepoint in case the Drupal
- // transaction is not a root one.
- $this->releaseClientSavepoint($name);
- }
- elseif ($this->stackDepth() === 1 && $this->stack()[$id]->type === StackItemType::Root) {
- // If this was the root Drupal transaction, we can commit the client
- // transaction.
- $this->processRootCommit();
- if ($this->rootId === $id) {
- $this->processPostTransactionCallbacks();
- $this->rootId = NULL;
- }
- }
- else {
- // The stack got corrupted.
- throw new TransactionOutOfOrderException("Transaction {$id}/{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString());
- }
-
- // Remove the transaction from the stack.
- $this->removeStackItem($id);
- return;
+ if ($this->stackDepth() > 1 && $this->stack()[$id]->type === StackItemType::Savepoint) {
+ // Release the client transaction savepoint in case the Drupal
+ // transaction is not a root one.
+ $this->releaseClientSavepoint($name);
+ }
+ elseif ($this->stackDepth() === 1 && $this->stack()[$id]->type === StackItemType::Root) {
+ // If this was the root Drupal transaction, we can commit the client
+ // transaction.
+ $this->processRootCommit();
+ }
+ else {
+ // The stack got corrupted.
+ throw new TransactionOutOfOrderException("Transaction {$id}/{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString());
}
-
- // The stack got corrupted.
- throw new TransactionOutOfOrderException("Transaction {$id}/{$name} is out of order. Active stack: " . $this->dumpStackItemsAsString());
}
/**
diff --git a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php
index 11af511f14b..a9aa2c77052 100644
--- a/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php
+++ b/core/lib/Drupal/Core/Database/Transaction/TransactionManagerInterface.php
@@ -53,8 +53,8 @@ interface TransactionManagerInterface {
* Removes a Drupal transaction from the stack.
*
* The unpiled item does not necessarily need to be the last on the stack.
- * This method should only be called by a Transaction object going out of
- * scope.
+ * This method should only be called by a Transaction object's
+ * ::commitOrRelease() method.
*
* This method should only be called internally by a database driver.
*
diff --git a/core/lib/Drupal/Core/Extension/ThemeInstaller.php b/core/lib/Drupal/Core/Extension/ThemeInstaller.php
index 364d672c12f..172193ca855 100644
--- a/core/lib/Drupal/Core/Extension/ThemeInstaller.php
+++ b/core/lib/Drupal/Core/Extension/ThemeInstaller.php
@@ -2,6 +2,7 @@
namespace Drupal\Core\Extension;
+use Drupal\Component\Plugin\Discovery\CachedDiscoveryInterface;
use Drupal\Component\Utility\Html;
use Drupal\Core\Asset\AssetCollectionOptimizerInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
@@ -22,100 +23,25 @@ class ThemeInstaller implements ThemeInstallerInterface {
use ModuleDependencyMessageTrait;
use StringTranslationTrait;
- /**
- * @var \Drupal\Core\Extension\ThemeHandlerInterface
- */
- protected $themeHandler;
-
- /**
- * @var \Drupal\Core\Config\ConfigFactoryInterface
- */
- protected $configFactory;
-
- /**
- * @var \Drupal\Core\Config\ConfigInstallerInterface
- */
- protected $configInstaller;
-
- /**
- * @var \Drupal\Core\Extension\ModuleHandlerInterface
- */
- protected $moduleHandler;
-
- /**
- * @var \Drupal\Core\State\StateInterface
- */
- protected $state;
-
- /**
- * @var \Drupal\Core\Config\ConfigManagerInterface
- */
- protected $configManager;
-
- /**
- * @var \Drupal\Core\Asset\AssetCollectionOptimizerInterface
- */
- protected $cssCollectionOptimizer;
-
- /**
- * @var \Drupal\Core\Routing\RouteBuilderInterface
- */
- protected $routeBuilder;
-
- /**
- * @var \Psr\Log\LoggerInterface
- */
- protected $logger;
-
- /**
- * The module extension list.
- *
- * @var \Drupal\Core\Extension\ModuleExtensionList
- */
- protected $moduleExtensionList;
-
- /**
- * Constructs a new ThemeInstaller.
- *
- * @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler
- * The theme handler.
- * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
- * The config factory to get the installed themes.
- * @param \Drupal\Core\Config\ConfigInstallerInterface $config_installer
- * (optional) The config installer to install configuration. This optional
- * to allow the theme handler to work before Drupal is installed and has a
- * database.
- * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
- * The module handler to fire themes_installed/themes_uninstalled hooks.
- * @param \Drupal\Core\Config\ConfigManagerInterface $config_manager
- * The config manager used to uninstall a theme.
- * @param \Drupal\Core\Asset\AssetCollectionOptimizerInterface $css_collection_optimizer
- * The CSS asset collection optimizer service.
- * @param \Drupal\Core\Routing\RouteBuilderInterface $route_builder
- * (optional) The route builder service to rebuild the routes if a theme is
- * installed.
- * @param \Psr\Log\LoggerInterface $logger
- * A logger instance.
- * @param \Drupal\Core\State\StateInterface $state
- * The state store.
- * @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list
- * The module extension list.
- * @param \Drupal\Core\Theme\Registry|null $themeRegistry
- * The theme registry.
- * @param \Drupal\Core\Extension\ThemeExtensionList|null $themeExtensionList
- * The theme extension list.
- */
- public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory, ConfigInstallerInterface $config_installer, ModuleHandlerInterface $module_handler, ConfigManagerInterface $config_manager, AssetCollectionOptimizerInterface $css_collection_optimizer, RouteBuilderInterface $route_builder, LoggerInterface $logger, StateInterface $state, ModuleExtensionList $module_extension_list, protected Registry $themeRegistry, protected ThemeExtensionList $themeExtensionList) {
- $this->themeHandler = $theme_handler;
- $this->configFactory = $config_factory;
- $this->configInstaller = $config_installer;
- $this->moduleHandler = $module_handler;
- $this->configManager = $config_manager;
- $this->cssCollectionOptimizer = $css_collection_optimizer;
- $this->routeBuilder = $route_builder;
- $this->logger = $logger;
- $this->state = $state;
- $this->moduleExtensionList = $module_extension_list;
+ public function __construct(
+ protected readonly ThemeHandlerInterface $themeHandler,
+ protected readonly ConfigFactoryInterface $configFactory,
+ protected readonly ConfigInstallerInterface $configInstaller,
+ protected readonly ModuleHandlerInterface $moduleHandler,
+ protected readonly ConfigManagerInterface $configManager,
+ protected readonly AssetCollectionOptimizerInterface $cssCollectionOptimizer,
+ protected readonly RouteBuilderInterface $routeBuilder,
+ protected readonly LoggerInterface $logger,
+ protected readonly StateInterface $state,
+ protected readonly ModuleExtensionList $moduleExtensionList,
+ protected readonly Registry $themeRegistry,
+ protected readonly ThemeExtensionList $themeExtensionList,
+ protected ?CachedDiscoveryInterface $componentPluginManager = NULL,
+ ) {
+ if ($this->componentPluginManager === NULL) {
+ @trigger_error('Calling ' . __METHOD__ . ' without the $componentPluginManager argument is deprecated in drupal:11.2.0 and it will be required in drupal:12.0.0. See https://www.drupal.org/node/3525649', E_USER_DEPRECATED);
+ $this->componentPluginManager = \Drupal::service('plugin.manager.sdc');
+ }
}
/**
@@ -311,11 +237,9 @@ class ThemeInstaller implements ThemeInstallerInterface {
* Resets some other systems like rebuilding the route information or caches.
*/
protected function resetSystem() {
- if ($this->routeBuilder) {
- $this->routeBuilder->setRebuildNeeded();
- }
-
+ $this->routeBuilder->setRebuildNeeded();
$this->themeRegistry->reset();
+ $this->componentPluginManager->clearCachedDefinitions();
}
}
diff --git a/core/lib/Drupal/Core/Hook/Attribute/FormAlter.php b/core/lib/Drupal/Core/Hook/Attribute/FormAlter.php
deleted file mode 100644
index 158010463d2..00000000000
--- a/core/lib/Drupal/Core/Hook/Attribute/FormAlter.php
+++ /dev/null
@@ -1,54 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\Core\Hook\Attribute;
-
-use Drupal\Core\Hook\Order\OrderInterface;
-
-/**
- * Hook attribute for FormAlter.
- *
- * @see hook_form_alter().
- */
-#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
-class FormAlter extends Hook {
-
- /**
- * {@inheritdoc}
- */
- public const string PREFIX = 'form';
-
- /**
- * {@inheritdoc}
- */
- public const string SUFFIX = 'alter';
-
- /**
- * Constructs a FormAlter attribute object.
- *
- * @param string $form_id
- * (optional) The ID of the form that this implementation alters.
- * If this is left blank then `form_alter` is the hook that is registered.
- * @param string $method
- * (optional) The method name. If this attribute is on a method, this
- * parameter is not required. If this attribute is on a class and this
- * parameter is omitted, the class must have an __invoke() method, which is
- * taken as the hook implementation.
- * @param string|null $module
- * (optional) The module this implementation is for. This allows one module
- * to implement a hook on behalf of another module. Defaults to the module
- * the implementation is in.
- * @param \Drupal\Core\Hook\Order\OrderInterface|null $order
- * (optional) Set the order of the implementation.
- */
- public function __construct(
- string $form_id = '',
- public string $method = '',
- public ?string $module = NULL,
- public ?OrderInterface $order = NULL,
- ) {
- parent::__construct($form_id, $method, $module, $order);
- }
-
-}
diff --git a/core/lib/Drupal/Core/Hook/Attribute/Hook.php b/core/lib/Drupal/Core/Hook/Attribute/Hook.php
index 34dbc8ebf91..0084e651180 100644
--- a/core/lib/Drupal/Core/Hook/Attribute/Hook.php
+++ b/core/lib/Drupal/Core/Hook/Attribute/Hook.php
@@ -98,27 +98,10 @@ use Drupal\Core\Hook\Order\OrderInterface;
class Hook implements HookAttributeInterface {
/**
- * The hook prefix such as `form`.
- *
- * @var string
- */
- public const string PREFIX = '';
-
- /**
- * The hook suffix such as `alter`.
- *
- * @var string
- */
- public const string SUFFIX = '';
-
- /**
* Constructs a Hook attribute object.
*
* @param string $hook
* The short hook name, without the 'hook_' prefix.
- * $hook is only optional when Hook is extended and a PREFIX or SUFFIX is
- * defined. When using the [#Hook] attribute directly $hook is required.
- * See Drupal\Core\Hook\Attribute\Preprocess.
* @param string $method
* (optional) The method name. If this attribute is on a method, this
* parameter is not required. If this attribute is on a class and this
@@ -132,15 +115,10 @@ class Hook implements HookAttributeInterface {
* (optional) Set the order of the implementation.
*/
public function __construct(
- public string $hook = '',
+ public string $hook,
public string $method = '',
public ?string $module = NULL,
public ?OrderInterface $order = NULL,
- ) {
- $this->hook = implode('_', array_filter([static::PREFIX, $hook, static::SUFFIX]));
- if ($this->hook === '') {
- throw new \LogicException('The Hook attribute or an attribute extending the Hook attribute must provide the $hook parameter, a PREFIX or a SUFFIX.');
- }
- }
+ ) {}
}
diff --git a/core/lib/Drupal/Core/Hook/Attribute/Preprocess.php b/core/lib/Drupal/Core/Hook/Attribute/Preprocess.php
deleted file mode 100644
index 47642859a20..00000000000
--- a/core/lib/Drupal/Core/Hook/Attribute/Preprocess.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\Core\Hook\Attribute;
-
-/**
- * Attribute for defining a class method as a preprocess function.
- *
- * Pass no arguments for hook_preprocess `#[Preprocess]`.
- * For `hook_preprocess_HOOK` pass the `HOOK` without the `hook_preprocess`
- * portion `#[Preprocess('HOOK')]`.
- *
- * See \Drupal\Core\Hook\Attribute\Hook for additional information.
- */
-#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
-class Preprocess extends Hook {
- /**
- * {@inheritdoc}
- */
- public const string PREFIX = 'preprocess';
-
-}
diff --git a/core/lib/Drupal/Core/Mailer/Transport/SendmailCommandValidationTransportFactory.php b/core/lib/Drupal/Core/Mailer/Transport/SendmailCommandValidationTransportFactory.php
new file mode 100644
index 00000000000..84dfb64c7b2
--- /dev/null
+++ b/core/lib/Drupal/Core/Mailer/Transport/SendmailCommandValidationTransportFactory.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Mailer\Transport;
+
+use Drupal\Core\Site\Settings;
+use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
+use Symfony\Component\Mailer\Transport\Dsn;
+use Symfony\Component\Mailer\Transport\TransportFactoryInterface;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+
+/**
+ * Command validation decorator for sendmail transport factory.
+ */
+class SendmailCommandValidationTransportFactory implements TransportFactoryInterface {
+
+ /**
+ * Construct command validation decorator for sendmail transport factory.
+ *
+ * @param \Symfony\Component\Mailer\Transport\TransportFactoryInterface $inner
+ * The decorated sendmail transport factory.
+ */
+ public function __construct(
+ #[AutowireDecorated]
+ protected TransportFactoryInterface $inner,
+ ) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function create(Dsn $dsn): TransportInterface {
+ $command = $dsn->getOption('command');
+ if (!empty($command)) {
+ $commands = Settings::get('mailer_sendmail_commands', []);
+ if (!in_array($command, $commands, TRUE)) {
+ throw new \RuntimeException("Unsafe sendmail command {$command}");
+ }
+ }
+
+ return $this->inner->create($dsn);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function supports(Dsn $dsn): bool {
+ return $this->inner->supports($dsn);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Mailer/TransportServiceFactory.php b/core/lib/Drupal/Core/Mailer/TransportServiceFactory.php
new file mode 100644
index 00000000000..8950d44e364
--- /dev/null
+++ b/core/lib/Drupal/Core/Mailer/TransportServiceFactory.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Mailer;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
+use Symfony\Component\Mailer\Transport\Dsn;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+
+/**
+ * The default mailer transport service factory.
+ */
+class TransportServiceFactory implements TransportServiceFactoryInterface {
+
+ use TransportServiceFactoryTrait;
+
+ /**
+ * Constructs a new transport service factory.
+ *
+ * @param Iterable<TransportFactoryInterface> $factories
+ * A list of transport factories.
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
+ * The config factory service.
+ */
+ public function __construct(
+ #[AutowireIterator(tag: 'mailer.transport_factory')]
+ iterable $factories,
+ protected ConfigFactoryInterface $configFactory,
+ ) {
+ $this->factories = $factories;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createTransport(): TransportInterface {
+ $dsn = $this->configFactory->get('system.mail')->get('mailer_dsn');
+ $dsnObject = new Dsn(...$dsn);
+ return $this->fromDsnObject($dsnObject);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Mailer/TransportServiceFactoryInterface.php b/core/lib/Drupal/Core/Mailer/TransportServiceFactoryInterface.php
new file mode 100644
index 00000000000..8a2b5368db0
--- /dev/null
+++ b/core/lib/Drupal/Core/Mailer/TransportServiceFactoryInterface.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Mailer;
+
+use Symfony\Component\Mailer\Transport\TransportInterface;
+
+/**
+ * An interface defining mailer transport service factory implementations.
+ *
+ * The transport service factory is responsible to create a transport instance
+ * according to the site configuration. The default service factory looks up the
+ * `mailer_dsn` key from the `system.mail` config and returns an appropriate
+ * transport implementation.
+ *
+ * Contrib and custom code may choose to replace or decorate the transport
+ * service factory in order to provide a mailer transport instance which
+ * requires more complex setup.
+ */
+interface TransportServiceFactoryInterface {
+
+ /**
+ * Creates and returns a configured mailer transport class.
+ */
+ public function createTransport(): TransportInterface;
+
+}
diff --git a/core/lib/Drupal/Core/Mailer/TransportServiceFactoryTrait.php b/core/lib/Drupal/Core/Mailer/TransportServiceFactoryTrait.php
new file mode 100644
index 00000000000..c4aa2c736a4
--- /dev/null
+++ b/core/lib/Drupal/Core/Mailer/TransportServiceFactoryTrait.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Mailer;
+
+use Symfony\Component\Mailer\Exception\UnsupportedSchemeException;
+use Symfony\Component\Mailer\Transport\Dsn;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+
+/**
+ * A trait containing helper methods for transport service construction.
+ */
+trait TransportServiceFactoryTrait {
+
+ /**
+ * A list of transport factories.
+ *
+ * @var Iterable<TransportFactoryInterface>
+ */
+ protected iterable $factories;
+
+ /**
+ * Constructs a transport instance given a DSN object.
+ *
+ * @param \Symfony\Component\Mailer\Transport\Dsn $dsn
+ * The mailer DSN object.
+ *
+ * @throws \Symfony\Component\Mailer\Exception\IncompleteDsnException
+ * @throws \Symfony\Component\Mailer\Exception\UnsupportedSchemeException
+ */
+ protected function fromDsnObject(Dsn $dsn): TransportInterface {
+ foreach ($this->factories as $factory) {
+ if ($factory->supports($dsn)) {
+ return $factory->create($dsn);
+ }
+ }
+
+ throw new UnsupportedSchemeException($dsn);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Recipe/ConsoleInputCollector.php b/core/lib/Drupal/Core/Recipe/ConsoleInputCollector.php
index f1db3a342af..9feb9bed8da 100644
--- a/core/lib/Drupal/Core/Recipe/ConsoleInputCollector.php
+++ b/core/lib/Drupal/Core/Recipe/ConsoleInputCollector.php
@@ -93,11 +93,14 @@ final class ConsoleInputCollector implements InputCollectorInterface {
$method = $settings['method'];
$arguments = $settings['arguments'] ?? [];
- // Most of the input-collecting methods of StyleInterface have a `default`
- // parameter.
- $arguments += [
- 'default' => $default_value,
- ];
+ if ($method !== 'askHidden') {
+ // Most of the input-collecting methods of StyleInterface have a `default`
+ // parameter.
+ $arguments += [
+ 'default' => $default_value,
+ ];
+ }
+
// We don't support using Symfony Console's inline validation; instead,
// input definitions should define constraints.
unset($arguments['validator']);
diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index d0150fe0127..fe3f29ea696 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -250,9 +250,10 @@ class Renderer implements RendererInterface {
return $return;
}
- // Only when we're in a root (non-recursive) Renderer::render() call,
- // placeholders must be processed, to prevent breaking the render cache in
- // case of nested elements with #cache set.
+ // Only when rendering the root do placeholders have to be processed. If we
+ // were to replace them while rendering cacheable nested elements, their
+ // cacheable metadata would still bubble all the way up the render tree,
+ // effectively making the use of placeholders pointless.
$this->replacePlaceholders($elements);
return $elements['#markup'];
@@ -304,11 +305,9 @@ class Renderer implements RendererInterface {
}
$context->push(new BubbleableMetadata());
- // Set the bubbleable rendering metadata that has configurable defaults, if:
- // - this is the root call, to ensure that the final render array definitely
- // has these configurable defaults, even when no subtree is render cached.
- // - this is a render cacheable subtree, to ensure that the cached data has
- // the configurable defaults (which may affect the ID and invalidation).
+ // Set the bubbleable rendering metadata that has configurable defaults if
+ // this is a render cacheable subtree, to ensure that the cached data has
+ // the configurable defaults (which may affect the ID and invalidation).
if (isset($elements['#cache']['keys'])) {
$required_cache_contexts = $this->rendererConfig['required_cache_contexts'];
if (isset($elements['#cache']['contexts'])) {
diff --git a/core/lib/Drupal/Core/Session/SessionManager.php b/core/lib/Drupal/Core/Session/SessionManager.php
index 0170626181c..6927ba2ebbe 100644
--- a/core/lib/Drupal/Core/Session/SessionManager.php
+++ b/core/lib/Drupal/Core/Session/SessionManager.php
@@ -6,6 +6,7 @@ use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpFoundation\Session\SessionBagInterface;
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
/**
@@ -162,6 +163,16 @@ class SessionManager extends NativeSessionStorage implements SessionManagerInter
parent::save();
}
+ $allowedKeys = array_map(
+ fn (SessionBagInterface $bag) => $bag->getStorageKey(),
+ $this->bags
+ );
+ $allowedKeys[] = $this->getMetadataBag()->getStorageKey();
+ $deprecatedKeys = array_diff(array_keys($_SESSION), $allowedKeys);
+ if (count($deprecatedKeys) > 0) {
+ @trigger_error(sprintf('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: %s. See https://www.drupal.org/node/3518527', implode(", ", $deprecatedKeys)), E_USER_DEPRECATED);
+ }
+
$this->startedLazy = FALSE;
}
diff --git a/core/lib/Drupal/Core/Template/Loader/ComponentLoader.php b/core/lib/Drupal/Core/Template/Loader/ComponentLoader.php
index d141d202ecb..e3669f8f145 100644
--- a/core/lib/Drupal/Core/Template/Loader/ComponentLoader.php
+++ b/core/lib/Drupal/Core/Template/Loader/ComponentLoader.php
@@ -122,13 +122,8 @@ class ComponentLoader implements LoaderInterface {
catch (ComponentNotFoundException) {
throw new LoaderError('Unable to find component');
}
- // If any of the templates, or the component definition, are fresh. Then the
- // component is fresh.
$metadata_path = $component->getPluginDefinition()[YamlDirectoryDiscovery::FILE_KEY];
- if ($file_is_fresh($metadata_path)) {
- return TRUE;
- }
- return $file_is_fresh($component->getTemplatePath());
+ return $file_is_fresh($component->getTemplatePath()) && $file_is_fresh($metadata_path);
}
}
diff --git a/core/lib/Drupal/Core/Test/SimpletestTestRunResultsStorage.php b/core/lib/Drupal/Core/Test/SimpletestTestRunResultsStorage.php
index cb754e1afaa..0c000d675c3 100644
--- a/core/lib/Drupal/Core/Test/SimpletestTestRunResultsStorage.php
+++ b/core/lib/Drupal/Core/Test/SimpletestTestRunResultsStorage.php
@@ -95,13 +95,14 @@ class SimpletestTestRunResultsStorage implements TestRunResultsStorageInterface
* {@inheritdoc}
*/
public function removeResults(TestRun $test_run): int {
- $this->connection->startTransaction('delete_test_run');
+ $transaction = $this->connection->startTransaction('delete_test_run');
$this->connection->delete('simpletest')
->condition('test_id', $test_run->id())
->execute();
$count = $this->connection->delete('simpletest_test_id')
->condition('test_id', $test_run->id())
->execute();
+ $transaction->commitOrRelease();
return $count;
}
@@ -169,9 +170,10 @@ class SimpletestTestRunResultsStorage implements TestRunResultsStorageInterface
*/
public function cleanUp(): int {
// Clear test results.
- $this->connection->startTransaction('delete_simpletest');
+ $transaction = $this->connection->startTransaction('delete_simpletest');
$this->connection->delete('simpletest')->execute();
$count = $this->connection->delete('simpletest_test_id')->execute();
+ $transaction->commitOrRelease();
return $count;
}
diff --git a/core/lib/Drupal/Core/Theme/Component/ComponentValidator.php b/core/lib/Drupal/Core/Theme/Component/ComponentValidator.php
index 246f143d4e2..ff102b5170a 100644
--- a/core/lib/Drupal/Core/Theme/Component/ComponentValidator.php
+++ b/core/lib/Drupal/Core/Theme/Component/ComponentValidator.php
@@ -199,7 +199,9 @@ class ComponentValidator {
$errors = array_filter(
$this->validator->getErrors(),
function (array $error) use ($context): bool {
- if (($error['constraint'] ?? '') !== 'type') {
+ // Support 5.0 ($error['constraint']) and 6.0
+ // ($error['constraint']['name']) at the same time.
+ if (($error['constraint']['name'] ?? $error['constraint'] ?? '') !== 'type') {
return TRUE;
}
return !Element::isRenderArray($context[$error['property']] ?? NULL);
diff --git a/core/lib/Drupal/Core/Theme/ComponentPluginManager.php b/core/lib/Drupal/Core/Theme/ComponentPluginManager.php
index a0c93317699..5a3b62773ea 100644
--- a/core/lib/Drupal/Core/Theme/ComponentPluginManager.php
+++ b/core/lib/Drupal/Core/Theme/ComponentPluginManager.php
@@ -164,6 +164,10 @@ class ComponentPluginManager extends DefaultPluginManager implements Categorizin
public function clearCachedDefinitions(): void {
parent::clearCachedDefinitions();
$this->componentNegotiator->clearCache();
+ // When clearing cached definitions from theme install or uninstall, the
+ // container is not rebuilt. Unset discovery so it will be re-instantiated
+ // in getDiscovery() with the updated list of theme directories.
+ $this->discovery = NULL;
}
/**
diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt
index 24999a781ac..69b32dfa718 100644
--- a/core/misc/cspell/dictionary.txt
+++ b/core/misc/cspell/dictionary.txt
@@ -427,6 +427,8 @@ rowspans
rtsp
ruleset
sameorigin
+sandboxed
+sandboxing
savepoints
sayre
schemaapi
diff --git a/core/misc/drupal.js b/core/misc/drupal.js
index 416c4f415a5..641c461a802 100644
--- a/core/misc/drupal.js
+++ b/core/misc/drupal.js
@@ -404,7 +404,6 @@ window.Drupal = { behaviors: {}, locale: {} };
*
* @see https://github.com/angular/angular.js/blob/v1.4.4/src/ng/urlUtils.js
* @see https://grack.com/blog/2009/11/17/absolutizing-url-in-javascript
- * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L53
*/
Drupal.url.toAbsolute = function (url) {
const urlParsingNode = document.createElement('a');
@@ -419,9 +418,7 @@ window.Drupal = { behaviors: {}, locale: {} };
urlParsingNode.setAttribute('href', url);
- // IE <= 7 normalizes the URL when assigned to the anchor node similar to
- // the other browsers.
- return urlParsingNode.cloneNode(false).href;
+ return urlParsingNode.href;
};
/**
diff --git a/core/modules/block/block.module b/core/modules/block/block.module
index 94a2cb9fc7a..24e28589491 100644
--- a/core/modules/block/block.module
+++ b/core/modules/block/block.module
@@ -16,6 +16,10 @@ use Drupal\Core\Installer\InstallerKernel;
* @see block_modules_installed()
*/
function block_themes_installed($theme_list): void {
+ // Do not create blocks during config sync.
+ if (\Drupal::service('config.installer')->isSyncing()) {
+ return;
+ }
// Disable this functionality prior to install profile installation because
// block configuration is often optional or provided by the install profile
// itself. block_theme_initialize() will be called when the install profile is
diff --git a/core/modules/block/migrations/d6_block.yml b/core/modules/block/migrations/d6_block.yml
index 74922444e8d..853ce28a47b 100644
--- a/core/modules/block/migrations/d6_block.yml
+++ b/core/modules/block/migrations/d6_block.yml
@@ -56,8 +56,6 @@ process:
1: forum_new_block
locale:
0: language_block
- node:
- 0: node_syndicate_block
search:
0: search_form_block
statistics:
diff --git a/core/modules/block/migrations/d7_block.yml b/core/modules/block/migrations/d7_block.yml
index 9b031b7daa7..35c6f23d86f 100644
--- a/core/modules/block/migrations/d7_block.yml
+++ b/core/modules/block/migrations/d7_block.yml
@@ -59,8 +59,6 @@ process:
new: forum_new_block
# locale:
# 0: language_block
- node:
- syndicate: node_syndicate_block
search:
form: search_form_block
statistics:
diff --git a/core/modules/block/src/Hook/BlockHooks.php b/core/modules/block/src/Hook/BlockHooks.php
index 657109309a3..802a60bccb1 100644
--- a/core/modules/block/src/Hook/BlockHooks.php
+++ b/core/modules/block/src/Hook/BlockHooks.php
@@ -151,7 +151,12 @@ class BlockHooks {
* @see block_themes_installed()
*/
#[Hook('modules_installed')]
- public function modulesInstalled($modules): void {
+ public function modulesInstalled($modules, bool $is_syncing): void {
+ // Do not create blocks during config sync.
+ if ($is_syncing) {
+ return;
+ }
+
// block_themes_installed() does not call block_theme_initialize() during
// site installation because block configuration can be optional or provided
// by the profile. Now, when the profile is installed, this configuration
diff --git a/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php b/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php
index 8b2ead48eda..6305ab7f841 100644
--- a/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php
+++ b/core/modules/block/tests/src/Kernel/BlockConfigSchemaTest.php
@@ -65,6 +65,10 @@ class BlockConfigSchemaTest extends KernelTestBase {
*/
public function testBlockConfigSchema(): void {
foreach ($this->blockManager->getDefinitions() as $block_id => $definition) {
+ // Skip the syndicate block as it is deprecated.
+ if ($block_id === 'node_syndicate_block') {
+ continue;
+ }
$id = $this->randomMachineName();
$block = Block::create([
'id' => $id,
diff --git a/core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php b/core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php
new file mode 100644
index 00000000000..80e3f798342
--- /dev/null
+++ b/core/modules/block/tests/src/Kernel/BlockConfigSyncTest.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\block\Kernel;
+
+use Drupal\Core\Config\ConfigInstallerInterface;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Extension\ThemeInstallerInterface;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\block\Entity\Block;
+
+/**
+ * Tests that blocks are not created during config sync.
+ *
+ * @group block
+ */
+class BlockConfigSyncTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['block', 'system'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ \Drupal::service(ThemeInstallerInterface::class)
+ ->install(['stark', 'claro']);
+
+ // Delete all existing blocks.
+ foreach (Block::loadMultiple() as $block) {
+ $block->delete();
+ }
+
+ // Set the default theme.
+ $this->config('system.theme')
+ ->set('default', 'stark')
+ ->save();
+
+ // Create a block for the default theme to be copied later.
+ Block::create([
+ 'id' => 'test_block',
+ 'plugin' => 'system_powered_by_block',
+ 'region' => 'content',
+ 'theme' => 'stark',
+ ])->save();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function register(ContainerBuilder $container): void {
+ parent::register($container);
+ $container->setParameter('install_profile', 'testing');
+ }
+
+ /**
+ * Tests blocks are not created during config sync.
+ *
+ * @param bool $syncing
+ * Whether or not config is syncing when the hook is invoked.
+ * @param string|null $expected_block_id
+ * The expected ID of the block that should be created, or NULL if no block
+ * should be created.
+ *
+ * @testWith [true, null]
+ * [false, "claro_test_block"]
+ */
+ public function testNoBlocksCreatedDuringConfigSync(bool $syncing, ?string $expected_block_id): void {
+ \Drupal::service(ConfigInstallerInterface::class)
+ ->setSyncing($syncing);
+
+ // Invoke the hook that should skip block creation due to config sync.
+ \Drupal::moduleHandler()->invoke('block', 'themes_installed', [['claro']]);
+ // This should hold true if the "current" install profile triggers an
+ // invocation of hook_modules_installed().
+ \Drupal::moduleHandler()->invoke('block', 'modules_installed', [['testing'], $syncing]);
+
+ $this->assertSame($expected_block_id, Block::load('claro_test_block')?->id());
+ }
+
+}
diff --git a/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php b/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php
index 3f20b2148b8..dc96d95e699 100644
--- a/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php
+++ b/core/modules/block/tests/src/Kernel/Migrate/d6/MigrateBlockTest.php
@@ -100,7 +100,7 @@ class MigrateBlockTest extends MigrateDrupal6TestBase {
*/
public function testBlockMigration(): void {
$blocks = Block::loadMultiple();
- $this->assertCount(25, $blocks);
+ $this->assertCount(24, $blocks);
// Check user blocks.
$visibility = [
diff --git a/core/modules/block_content/src/Controller/BlockContentController.php b/core/modules/block_content/src/Controller/BlockContentController.php
index b2776f51d7d..77f8eee7939 100644
--- a/core/modules/block_content/src/Controller/BlockContentController.php
+++ b/core/modules/block_content/src/Controller/BlockContentController.php
@@ -2,9 +2,9 @@
namespace Drupal\block_content\Controller;
+use Drupal\block_content\BlockContentTypeInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityStorageInterface;
-use Drupal\block_content\BlockContentTypeInterface;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -88,7 +88,8 @@ class BlockContentController extends ControllerBase {
uasort($types, [$this->blockContentTypeStorage->getEntityType()->getClass(), 'sort']);
if ($types && count($types) == 1) {
$type = reset($types);
- return $this->addForm($type, $request);
+ $query = $request->query->all();
+ return $this->redirect('block_content.add_form', ['block_content_type' => $type->id()], ['query' => $query]);
}
if (count($types) === 0) {
return [
diff --git a/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php b/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php
index 844e06895cc..4e6c3b141e7 100644
--- a/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php
+++ b/core/modules/block_content/src/Plugin/Menu/LocalAction/BlockContentAddLocalAction.php
@@ -5,7 +5,6 @@ namespace Drupal\block_content\Plugin\Menu\LocalAction;
use Drupal\Core\Menu\LocalActionDefault;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Routing\RouteProviderInterface;
-use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -54,11 +53,6 @@ class BlockContentAddLocalAction extends LocalActionDefault {
if ($region = $this->requestStack->getCurrentRequest()->query->getString('region')) {
$options['query']['region'] = $region;
}
-
- // Adds a destination on content block listing.
- if ($route_match->getRouteName() == 'entity.block_content.collection') {
- $options['query']['destination'] = Url::fromRoute('<current>')->toString();
- }
return $options;
}
diff --git a/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php b/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php
index bca42cd3e32..364b5f4524d 100644
--- a/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php
+++ b/core/modules/block_content/tests/src/Functional/BlockContentCreationTest.php
@@ -155,11 +155,7 @@ class BlockContentCreationTest extends BlockContentTestBase {
// Create a block and place in block layout.
$this->drupalGet('/admin/content/block');
$this->clickLink('Add content block');
- // Verify destination URL, when clicking "Save and configure" this
- // destination will be ignored.
- $base = base_path();
- $url = 'block/add?destination=' . $base . 'admin/content/block';
- $this->assertSession()->addressEquals($url);
+ $this->assertSession()->addressEquals('/block/add/basic');
$edit = [];
$edit['info[0][value]'] = 'Test Block';
$edit['body[0][value]'] = $this->randomMachineName(16);
diff --git a/core/modules/block_content/tests/src/Functional/LocalActionTest.php b/core/modules/block_content/tests/src/Functional/LocalActionTest.php
new file mode 100644
index 00000000000..bb1a20df880
--- /dev/null
+++ b/core/modules/block_content/tests/src/Functional/LocalActionTest.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\block_content\Functional;
+
+/**
+ * Tests block_content local action links.
+ *
+ * @group block_content
+ */
+class LocalActionTest extends BlockContentTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $defaultTheme = 'stark';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->drupalLogin($this->adminUser);
+ }
+
+ /**
+ * Tests the block_content_add_action link.
+ */
+ public function testAddContentBlockLink(): void {
+ // Verify that the link takes you straight to the block form if there's only
+ // one type.
+ $this->drupalGet('/admin/content/block');
+ $this->clickLink('Add content block');
+ $this->assertSession()->statusCodeEquals(200);
+ $this->assertSession()->addressEquals('/block/add/basic');
+
+ $type = $this->randomMachineName();
+ $this->createBlockContentType([
+ 'id' => $type,
+ 'label' => $type,
+ ]);
+
+ // Verify that the link takes you to the block add page if there's more than
+ // one type.
+ $this->drupalGet('/admin/content/block');
+ $this->clickLink('Add content block');
+ $this->assertSession()->statusCodeEquals(200);
+ $this->assertSession()->addressEquals('/block/add');
+ }
+
+}
diff --git a/core/modules/comment/src/Hook/CommentThemeHooks.php b/core/modules/comment/src/Hook/CommentThemeHooks.php
index e789af6dab1..c137d586d41 100644
--- a/core/modules/comment/src/Hook/CommentThemeHooks.php
+++ b/core/modules/comment/src/Hook/CommentThemeHooks.php
@@ -2,7 +2,7 @@
namespace Drupal\comment\Hook;
-use Drupal\Core\Hook\Attribute\Preprocess;
+use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for comment.
@@ -12,7 +12,7 @@ class CommentThemeHooks {
/**
* Implements hook_preprocess_HOOK() for block templates.
*/
- #[Preprocess('block')]
+ #[Hook('preprocess_block')]
public function preprocessBlock(&$variables): void {
if ($variables['configuration']['provider'] == 'comment') {
$variables['attributes']['role'] = 'navigation';
diff --git a/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php b/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php
index db1ffae5a6d..01a40394b40 100644
--- a/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php
+++ b/core/modules/comment/tests/modules/comment_empty_title_test/src/Hook/CommentEmptyTitleTestThemeHooks.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Drupal\comment_empty_title_test\Hook;
-use Drupal\Core\Hook\Attribute\Preprocess;
+use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for comment_empty_title_test.
@@ -14,7 +14,7 @@ class CommentEmptyTitleTestThemeHooks {
/**
* Implements hook_preprocess_comment().
*/
- #[Preprocess('comment')]
+ #[Hook('preprocess_comment')]
public function preprocessComment(&$variables): void {
$variables['title'] = '';
}
diff --git a/core/modules/comment/tests/src/Functional/CommentAdminTest.php b/core/modules/comment/tests/src/Functional/CommentAdminTest.php
index f8dfc8a9b38..69c634ba0f9 100644
--- a/core/modules/comment/tests/src/Functional/CommentAdminTest.php
+++ b/core/modules/comment/tests/src/Functional/CommentAdminTest.php
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Drupal\Tests\comment\Functional;
use Drupal\comment\CommentInterface;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Utility\Html;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\user\RoleInterface;
@@ -281,8 +280,8 @@ class CommentAdminTest extends CommentTestBase {
];
$this->drupalGet('admin/content/comment');
$this->submitForm($edit, 'Update');
- $this->assertSession()->responseContains(new FormattableMarkup('@label (Original translation) - <em>The following comment translations will be deleted:</em>', ['@label' => $comment1->label()]));
- $this->assertSession()->responseContains(new FormattableMarkup('@label (Original translation) - <em>The following comment translations will be deleted:</em>', ['@label' => $comment2->label()]));
+ $this->assertSession()->responseContains($comment1->label() . " (Original translation) - <em>The following comment translations will be deleted:</em>");
+ $this->assertSession()->responseContains($comment2->label() . " (Original translation) - <em>The following comment translations will be deleted:</em>");
$this->assertSession()->pageTextContains('English');
$this->assertSession()->pageTextContains('Urdu');
$this->submitForm([], 'Delete');
diff --git a/core/modules/comment/tests/src/Functional/CommentPagerTest.php b/core/modules/comment/tests/src/Functional/CommentPagerTest.php
index 819403386b1..4927803208b 100644
--- a/core/modules/comment/tests/src/Functional/CommentPagerTest.php
+++ b/core/modules/comment/tests/src/Functional/CommentPagerTest.php
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Drupal\Tests\comment\Functional;
use Drupal\comment\CommentManagerInterface;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\node\Entity\Node;
/**
@@ -446,7 +445,7 @@ class CommentPagerTest extends CommentTestBase {
$url_target = $this->getAbsoluteUrl($urls[$index]->getAttribute('href'));
return $this->drupalGet($url_target);
}
- $this->fail(new FormattableMarkup('Link %label does not exist on @url_before', ['%label' => $xpath, '@url_before' => $url_before]));
+ $this->fail("Link $xpath does not exist on $url_before");
return FALSE;
}
diff --git a/core/modules/config/tests/src/Functional/ConfigEntityTest.php b/core/modules/config/tests/src/Functional/ConfigEntityTest.php
index 1fe966fb127..d9f9b15724d 100644
--- a/core/modules/config/tests/src/Functional/ConfigEntityTest.php
+++ b/core/modules/config/tests/src/Functional/ConfigEntityTest.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Drupal\Tests\config\Functional;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Uuid\Uuid;
use Drupal\Core\Entity\EntityMalformedException;
use Drupal\Core\Entity\EntityStorageException;
@@ -172,10 +171,9 @@ class ConfigEntityTest extends BrowserTestBase {
]);
try {
$status = $id_length_config_test->save();
- $this->fail(new FormattableMarkup("config_test entity with ID length @length exceeding the maximum allowed length of @max saved successfully", [
- '@length' => strlen($id_length_config_test->id()),
- '@max' => static::MAX_ID_LENGTH,
- ]));
+ $length = strlen($id_length_config_test->id());
+ $max = static::MAX_ID_LENGTH;
+ $this->fail("config_test entity with ID length $length exceeding the maximum allowed length of $max saved successfully");
}
catch (ConfigEntityIdLengthException) {
// Expected exception; just continue testing.
diff --git a/core/modules/config_translation/migrations/d6_block_translation.yml b/core/modules/config_translation/migrations/d6_block_translation.yml
index 6d57fdae1be..7925c49626f 100644
--- a/core/modules/config_translation/migrations/d6_block_translation.yml
+++ b/core/modules/config_translation/migrations/d6_block_translation.yml
@@ -39,8 +39,6 @@ process:
1: forum_new_block
locale:
0: language_block
- node:
- 0: node_syndicate_block
search:
0: search_form_block
statistics:
diff --git a/core/modules/config_translation/migrations/d7_block_translation.yml b/core/modules/config_translation/migrations/d7_block_translation.yml
index 9c82ee6b678..d2530e3b50a 100644
--- a/core/modules/config_translation/migrations/d7_block_translation.yml
+++ b/core/modules/config_translation/migrations/d7_block_translation.yml
@@ -44,8 +44,6 @@ process:
new: forum_new_block
# locale:
# 0: language_block
- node:
- syndicate: node_syndicate_block
search:
form: search_form_block
statistics:
diff --git a/core/modules/contact/src/Hook/ContactFormHooks.php b/core/modules/contact/src/Hook/ContactFormHooks.php
index ad8223c3ec6..b31b929bddf 100644
--- a/core/modules/contact/src/Hook/ContactFormHooks.php
+++ b/core/modules/contact/src/Hook/ContactFormHooks.php
@@ -4,7 +4,7 @@ namespace Drupal\contact\Hook;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Form\FormStateInterface;
-use Drupal\Core\Hook\Attribute\FormAlter;
+use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\user\UserDataInterface;
@@ -29,7 +29,7 @@ class ContactFormHooks {
*
* @see \Drupal\user\ProfileForm::form()
*/
- #[FormAlter('user_form')]
+ #[Hook('form_user_form_alter')]
public function formUserFormAlter(&$form, FormStateInterface $form_state) : void {
$form['contact'] = [
'#type' => 'details',
@@ -55,7 +55,7 @@ class ContactFormHooks {
*
* Adds the default personal contact setting on the user settings page.
*/
- #[FormAlter('user_admin_settings')]
+ #[Hook('form_user_admin_settings_alter')]
public function formUserAdminSettingsAlter(&$form, FormStateInterface $form_state) : void {
$form['contact'] = [
'#type' => 'details',
diff --git a/core/modules/contact/tests/src/Functional/ContactPersonalTest.php b/core/modules/contact/tests/src/Functional/ContactPersonalTest.php
index bac903bdd29..df4f0834788 100644
--- a/core/modules/contact/tests/src/Functional/ContactPersonalTest.php
+++ b/core/modules/contact/tests/src/Functional/ContactPersonalTest.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Drupal\Tests\contact\Functional;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Utility\Html;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Test\AssertMailTrait;
@@ -106,12 +105,7 @@ class ContactPersonalTest extends BrowserTestBase {
$this->drupalLogin($this->adminUser);
// Verify that the correct watchdog message has been logged.
$this->drupalGet('/admin/reports/dblog');
- $placeholders = [
- '@sender_name' => $this->webUser->getAccountName(),
- '@sender_email' => $this->webUser->getEmail(),
- '@recipient_name' => $this->contactUser->getAccountName(),
- ];
- $this->assertSession()->responseContains(new FormattableMarkup('@sender_name (@sender_email) sent @recipient_name an email.', $placeholders));
+ $this->assertSession()->responseContains($this->webUser->getAccountName() . " (" . HTML::escape($this->webUser->getEmail()) . ") sent " . $this->contactUser->getAccountName() . " an email.");
// Ensure an unescaped version of the email does not exist anywhere.
$this->assertSession()->responseNotContains($this->webUser->getEmail());
diff --git a/core/modules/contextual/contextual.libraries.yml b/core/modules/contextual/contextual.libraries.yml
index bfc1c996c98..6798d02d907 100644
--- a/core/modules/contextual/contextual.libraries.yml
+++ b/core/modules/contextual/contextual.libraries.yml
@@ -1,16 +1,9 @@
drupal.contextual-links:
version: VERSION
js:
+ js/contextualModelView.js: {}
# Ensure to run before contextual/drupal.context-toolbar.
- # Core.
js/contextual.js: { weight: -2 }
- # Models.
- js/models/StateModel.js: { weight: -2 }
- # Views.
- js/views/AuralView.js: { weight: -2 }
- js/views/KeyboardView.js: { weight: -2 }
- js/views/RegionView.js: { weight: -2 }
- js/views/VisualView.js: { weight: -2 }
css:
component:
css/contextual.module.css: {}
@@ -22,28 +15,21 @@ drupal.contextual-links:
- core/drupal
- core/drupal.ajax
- core/drupalSettings
- # @todo Remove this in https://www.drupal.org/project/drupal/issues/3203920
- - core/internal.backbone
- core/once
- core/drupal.touchevents-test
drupal.contextual-toolbar:
version: VERSION
js:
+ js/toolbar/contextualToolbarModelView.js: {}
js/contextual.toolbar.js: {}
- # Models.
- js/toolbar/models/StateModel.js: {}
- # Views.
- js/toolbar/views/AuralView.js: {}
- js/toolbar/views/VisualView.js: {}
css:
component:
css/contextual.toolbar.css: {}
dependencies:
- core/jquery
+ - contextual/drupal.contextual-links
- core/drupal
- # @todo Remove this in https://www.drupal.org/project/drupal/issues/3203920
- - core/internal.backbone
- core/once
- core/drupal.tabbingmanager
- core/drupal.announce
diff --git a/core/modules/contextual/css/contextual.theme.css b/core/modules/contextual/css/contextual.theme.css
index 06a6728be39..55a83d5ca12 100644
--- a/core/modules/contextual/css/contextual.theme.css
+++ b/core/modules/contextual/css/contextual.theme.css
@@ -17,6 +17,10 @@
left: 0;
}
+.contextual.open {
+ z-index: 501;
+}
+
/**
* Contextual region.
*/
diff --git a/core/modules/contextual/js/contextual.js b/core/modules/contextual/js/contextual.js
index 87ccaa52dff..f1008eabe07 100644
--- a/core/modules/contextual/js/contextual.js
+++ b/core/modules/contextual/js/contextual.js
@@ -3,7 +3,7 @@
* Attaches behaviors for the Contextual module.
*/
-(function ($, Drupal, drupalSettings, _, Backbone, JSON, storage) {
+(function ($, Drupal, drupalSettings, JSON, storage) {
const options = $.extend(
drupalSettings.contextual,
// Merge strings on top of drupalSettings so that they are not mutable.
@@ -14,22 +14,19 @@
},
},
);
-
// Clear the cached contextual links whenever the current user's set of
// permissions changes.
const cachedPermissionsHash = storage.getItem(
'Drupal.contextual.permissionsHash',
);
- const permissionsHash = drupalSettings.user.permissionsHash;
+ const { permissionsHash } = drupalSettings.user;
if (cachedPermissionsHash !== permissionsHash) {
if (typeof permissionsHash === 'string') {
- _.chain(storage)
- .keys()
- .each((key) => {
- if (key.startsWith('Drupal.contextual.')) {
- storage.removeItem(key);
- }
- });
+ Object.keys(storage).forEach((key) => {
+ if (key.startsWith('Drupal.contextual.')) {
+ storage.removeItem(key);
+ }
+ });
}
storage.setItem('Drupal.contextual.permissionsHash', permissionsHash);
}
@@ -87,7 +84,7 @@
*/
function initContextual($contextual, html) {
const $region = $contextual.closest('.contextual-region');
- const contextual = Drupal.contextual;
+ const { contextual } = Drupal;
$contextual
// Update the placeholder to contain its rendered contextual links.
@@ -107,46 +104,18 @@
const glue = url.includes('?') ? '&' : '?';
this.setAttribute('href', url + glue + destination);
});
-
let title = '';
const $regionHeading = $region.find('h2');
if ($regionHeading.length) {
title = $regionHeading[0].textContent.trim();
}
- // Create a model and the appropriate views.
- const model = new contextual.StateModel({
- title,
- });
- const viewOptions = $.extend({ el: $contextual, model }, options);
- contextual.views.push({
- visual: new contextual.VisualView(viewOptions),
- aural: new contextual.AuralView(viewOptions),
- keyboard: new contextual.KeyboardView(viewOptions),
- });
- contextual.regionViews.push(
- new contextual.RegionView($.extend({ el: $region, model }, options)),
- );
-
- // Add the model to the collection. This must happen after the views have
- // been associated with it, otherwise collection change event handlers can't
- // trigger the model change event handler in its views.
- contextual.collection.add(model);
-
- // Let other JavaScript react to the adding of a new contextual link.
- $(document).trigger(
- 'drupalContextualLinkAdded',
- Drupal.deprecatedProperty({
- target: {
- $el: $contextual,
- $region,
- model,
- },
- deprecatedProperty: 'model',
- message:
- 'The model property is deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no replacement.',
- }),
+ options.title = title;
+ const contextualModelView = new Drupal.contextual.ContextualModelView(
+ $contextual,
+ $region,
+ options,
);
-
+ contextual.instances.push(contextualModelView);
// Fix visual collisions between contextual link triggers.
adjustIfNestedAndOverlapping($contextual);
}
@@ -192,7 +161,7 @@
// Initialize after the current execution cycle, to make the AJAX
// request for retrieving the uncached contextual links as soon as
// possible, but also to ensure that other Drupal behaviors have had
- // the chance to set up an event listener on the Backbone collection
+ // the chance to set up an event listener on the collection
// Drupal.contextual.collection.
window.setTimeout(() => {
initContextual(
@@ -217,7 +186,7 @@
data: { 'ids[]': uncachedIDs, 'tokens[]': uncachedTokens },
dataType: 'json',
success(results) {
- _.each(results, (html, contextualID) => {
+ Object.entries(results).forEach(([contextualID, html]) => {
// Store the metadata.
storage.setItem(`Drupal.contextual.${contextualID}`, html);
// If the rendered contextual links are empty, then the current
@@ -274,21 +243,23 @@
* replacement.
*/
regionViews: [],
+ instances: new Proxy([], {
+ set: function set(obj, prop, value) {
+ obj[prop] = value;
+ window.dispatchEvent(new Event('contextual-instances-added'));
+ return true;
+ },
+ deleteProperty(target, prop) {
+ if (prop in target) {
+ delete target[prop];
+ window.dispatchEvent(new Event('contextual-instances-removed'));
+ }
+ },
+ }),
+ ContextualModelView: {},
};
/**
- * A Backbone.Collection of {@link Drupal.contextual.StateModel} instances.
- *
- * @type {Backbone.Collection}
- *
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
- * replacement.
- */
- Drupal.contextual.collection = new Backbone.Collection([], {
- model: Drupal.contextual.StateModel,
- });
-
- /**
* A trigger is an interactive element often bound to a click handler.
*
* @return {string}
@@ -311,12 +282,4 @@
$(document).on('drupalContextualLinkAdded', (event, data) => {
Drupal.ajax.bindAjaxLinks(data.$el[0]);
});
-})(
- jQuery,
- Drupal,
- drupalSettings,
- _,
- Backbone,
- window.JSON,
- window.sessionStorage,
-);
+})(jQuery, Drupal, drupalSettings, window.JSON, window.sessionStorage);
diff --git a/core/modules/contextual/js/contextual.toolbar.js b/core/modules/contextual/js/contextual.toolbar.js
index 8fc206cc2c3..c94d0df414c 100644
--- a/core/modules/contextual/js/contextual.toolbar.js
+++ b/core/modules/contextual/js/contextual.toolbar.js
@@ -3,7 +3,7 @@
* Attaches behaviors for the Contextual module's edit toolbar tab.
*/
-(function ($, Drupal, Backbone) {
+(function ($, Drupal) {
const strings = {
tabbingReleased: Drupal.t(
'Tabbing is no longer constrained by the Contextual module.',
@@ -21,33 +21,19 @@
* A contextual links DOM element as rendered by the server.
*/
function initContextualToolbar(context) {
- if (!Drupal.contextual || !Drupal.contextual.collection) {
+ if (!Drupal.contextual || !Drupal.contextual.instances) {
return;
}
- const contextualToolbar = Drupal.contextualToolbar;
- contextualToolbar.model = new contextualToolbar.StateModel(
- {
- // Checks whether localStorage indicates we should start in edit mode
- // rather than view mode.
- // @see Drupal.contextualToolbar.VisualView.persist
- isViewing:
- document.querySelector('body .contextual-region') === null ||
- localStorage.getItem('Drupal.contextualToolbar.isViewing') !==
- 'false',
- },
- {
- contextualCollection: Drupal.contextual.collection,
- },
- );
+ const { contextualToolbar } = Drupal;
const viewOptions = {
el: $('.toolbar .toolbar-bar .contextual-toolbar-tab'),
- model: contextualToolbar.model,
strings,
};
- new contextualToolbar.VisualView(viewOptions);
- new contextualToolbar.AuralView(viewOptions);
+ contextualToolbar.model = new Drupal.contextual.ContextualToolbarModelView(
+ viewOptions,
+ );
}
/**
@@ -75,13 +61,10 @@
*/
Drupal.contextualToolbar = {
/**
- * The {@link Drupal.contextualToolbar.StateModel} instance.
- *
- * @type {?Drupal.contextualToolbar.StateModel}
+ * The {@link Drupal.contextual.ContextualToolbarModelView} instance.
*
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is
- * no replacement.
+ * @type {?Drupal.contextual.ContextualToolbarModelView}
*/
model: null,
};
-})(jQuery, Drupal, Backbone);
+})(jQuery, Drupal);
diff --git a/core/modules/contextual/js/contextualModelView.js b/core/modules/contextual/js/contextualModelView.js
new file mode 100644
index 00000000000..4488045e223
--- /dev/null
+++ b/core/modules/contextual/js/contextualModelView.js
@@ -0,0 +1,254 @@
+(($, Drupal) => {
+ /**
+ * Models the state of a contextual link's trigger, list & region.
+ */
+ Drupal.contextual.ContextualModelView = class {
+ constructor($contextual, $region, options) {
+ this.title = options.title || '';
+ this.regionIsHovered = false;
+ this._hasFocus = false;
+ this._isOpen = false;
+ this._isLocked = false;
+ this.strings = options.strings;
+ this.timer = NaN;
+ this.modelId = btoa(Math.random()).substring(0, 12);
+ this.$region = $region;
+ this.$contextual = $contextual;
+
+ if (!document.body.classList.contains('touchevents')) {
+ this.$region.on({
+ mouseenter: () => {
+ this.regionIsHovered = true;
+ },
+ mouseleave: () => {
+ this.close().blur();
+ this.regionIsHovered = false;
+ },
+ 'mouseleave mouseenter': () => this.render(),
+ });
+ this.$contextual.on('mouseenter', () => {
+ this.focus();
+ this.render();
+ });
+ }
+
+ this.$contextual.on(
+ {
+ click: () => {
+ this.toggleOpen();
+ },
+ touchend: () => {
+ Drupal.contextual.ContextualModelView.touchEndToClick();
+ },
+ focus: () => {
+ this.focus();
+ },
+ blur: () => {
+ this.blur();
+ },
+ 'click blur touchend focus': () => this.render(),
+ },
+ '.trigger',
+ );
+
+ this.$contextual.on(
+ {
+ click: () => {
+ this.close().blur();
+ },
+ touchend: (event) => {
+ Drupal.contextual.ContextualModelView.touchEndToClick(event);
+ },
+ focus: () => {
+ this.focus();
+ },
+ blur: () => {
+ this.waitCloseThenBlur();
+ },
+ 'click blur touchend focus': () => this.render(),
+ },
+ '.contextual-links a',
+ );
+
+ this.render();
+
+ // Let other JavaScript react to the adding of a new contextual link.
+ $(document).trigger('drupalContextualLinkAdded', {
+ $el: $contextual,
+ $region,
+ model: this,
+ });
+ }
+
+ /**
+ * Updates the rendered representation of the current contextual links.
+ */
+ render() {
+ const { isOpen } = this;
+ const isVisible = this.isLocked || this.regionIsHovered || isOpen;
+ this.$region.toggleClass('focus', this.hasFocus);
+ this.$contextual
+ .toggleClass('open', isOpen)
+ // Update the visibility of the trigger.
+ .find('.trigger')
+ .toggleClass('visually-hidden', !isVisible);
+
+ this.$contextual.find('.contextual-links').prop('hidden', !isOpen);
+ const trigger = this.$contextual.find('.trigger').get(0);
+ trigger.textContent = Drupal.t('@action @title configuration options', {
+ '@action': !isOpen ? this.strings.open : this.strings.close,
+ '@title': this.title,
+ });
+ trigger.setAttribute('aria-pressed', isOpen);
+ }
+
+ /**
+ * Prevents delay and simulated mouse events.
+ *
+ * @param {jQuery.Event} event the touch end event.
+ */
+ static touchEndToClick(event) {
+ event.preventDefault();
+ event.target.click();
+ }
+
+ /**
+ * Set up a timeout to allow a user to tab between the trigger and the
+ * contextual links without the menu dismissing.
+ */
+ waitCloseThenBlur() {
+ this.timer = window.setTimeout(() => {
+ this.isOpen = false;
+ this.hasFocus = false;
+ this.render();
+ }, 150);
+ }
+
+ /**
+ * Opens or closes the contextual link.
+ *
+ * If it is opened, then also give focus.
+ *
+ * @return {Drupal.contextual.ContextualModelView}
+ * The current contextual model view.
+ */
+ toggleOpen() {
+ const newIsOpen = !this.isOpen;
+ this.isOpen = newIsOpen;
+ if (newIsOpen) {
+ this.focus();
+ }
+ return this;
+ }
+
+ /**
+ * Gives focus to this contextual link.
+ *
+ * Also closes + removes focus from every other contextual link.
+ *
+ * @return {Drupal.contextual.ContextualModelView}
+ * The current contextual model view.
+ */
+ focus() {
+ const { modelId } = this;
+ Drupal.contextual.instances.forEach((model) => {
+ if (model.modelId !== modelId) {
+ model.close().blur();
+ }
+ });
+ window.clearTimeout(this.timer);
+ this.hasFocus = true;
+ return this;
+ }
+
+ /**
+ * Removes focus from this contextual link, unless it is open.
+ *
+ * @return {Drupal.contextual.ContextualModelView}
+ * The current contextual model view.
+ */
+ blur() {
+ if (!this.isOpen) {
+ this.hasFocus = false;
+ }
+ return this;
+ }
+
+ /**
+ * Closes this contextual link.
+ *
+ * Does not call blur() because we want to allow a contextual link to have
+ * focus, yet be closed for example when hovering.
+ *
+ * @return {Drupal.contextual.ContextualModelView}
+ * The current contextual model view.
+ */
+ close() {
+ this.isOpen = false;
+ return this;
+ }
+
+ /**
+ * Gets the current focus state.
+ *
+ * @return {boolean} the focus state.
+ */
+ get hasFocus() {
+ return this._hasFocus;
+ }
+
+ /**
+ * Sets the current focus state.
+ *
+ * @param {boolean} value - new focus state
+ */
+ set hasFocus(value) {
+ this._hasFocus = value;
+ this.$region.toggleClass('focus', this._hasFocus);
+ }
+
+ /**
+ * Gets the current open state.
+ *
+ * @return {boolean} the open state.
+ */
+ get isOpen() {
+ return this._isOpen;
+ }
+
+ /**
+ * Sets the current open state.
+ *
+ * @param {boolean} value - new open state
+ */
+ set isOpen(value) {
+ this._isOpen = value;
+ // Nested contextual region handling: hide any nested contextual triggers.
+ this.$region
+ .closest('.contextual-region')
+ .find('.contextual .trigger:not(:first)')
+ .toggle(!this.isOpen);
+ }
+
+ /**
+ * Gets the current locked state.
+ *
+ * @return {boolean} the locked state.
+ */
+ get isLocked() {
+ return this._isLocked;
+ }
+
+ /**
+ * Sets the current locked state.
+ *
+ * @param {boolean} value - new locked state
+ */
+ set isLocked(value) {
+ if (value !== this._isLocked) {
+ this._isLocked = value;
+ this.render();
+ }
+ }
+ };
+})(jQuery, Drupal);
diff --git a/core/modules/contextual/js/models/StateModel.js b/core/modules/contextual/js/models/StateModel.js
deleted file mode 100644
index 622f897917f..00000000000
--- a/core/modules/contextual/js/models/StateModel.js
+++ /dev/null
@@ -1,130 +0,0 @@
-/**
- * @file
- * A Backbone Model for the state of a contextual link's trigger, list & region.
- */
-
-(function (Drupal, Backbone) {
- /**
- * Models the state of a contextual link's trigger, list & region.
- *
- * @constructor
- *
- * @augments Backbone.Model
- *
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
- * replacement.
- */
- Drupal.contextual.StateModel = Backbone.Model.extend(
- /** @lends Drupal.contextual.StateModel# */ {
- /**
- * @type {object}
- *
- * @prop {string} title
- * @prop {boolean} regionIsHovered
- * @prop {boolean} hasFocus
- * @prop {boolean} isOpen
- * @prop {boolean} isLocked
- */
- defaults: /** @lends Drupal.contextual.StateModel# */ {
- /**
- * The title of the entity to which these contextual links apply.
- *
- * @type {string}
- */
- title: '',
-
- /**
- * Represents if the contextual region is being hovered.
- *
- * @type {boolean}
- */
- regionIsHovered: false,
-
- /**
- * Represents if the contextual trigger or options have focus.
- *
- * @type {boolean}
- */
- hasFocus: false,
-
- /**
- * Represents if the contextual options for an entity are available to
- * be selected (i.e. whether the list of options is visible).
- *
- * @type {boolean}
- */
- isOpen: false,
-
- /**
- * When the model is locked, the trigger remains active.
- *
- * @type {boolean}
- */
- isLocked: false,
- },
-
- /**
- * Opens or closes the contextual link.
- *
- * If it is opened, then also give focus.
- *
- * @return {Drupal.contextual.StateModel}
- * The current contextual state model.
- */
- toggleOpen() {
- const newIsOpen = !this.get('isOpen');
- this.set('isOpen', newIsOpen);
- if (newIsOpen) {
- this.focus();
- }
- return this;
- },
-
- /**
- * Closes this contextual link.
- *
- * Does not call blur() because we want to allow a contextual link to have
- * focus, yet be closed for example when hovering.
- *
- * @return {Drupal.contextual.StateModel}
- * The current contextual state model.
- */
- close() {
- this.set('isOpen', false);
- return this;
- },
-
- /**
- * Gives focus to this contextual link.
- *
- * Also closes + removes focus from every other contextual link.
- *
- * @return {Drupal.contextual.StateModel}
- * The current contextual state model.
- */
- focus() {
- this.set('hasFocus', true);
- const cid = this.cid;
- this.collection.each((model) => {
- if (model.cid !== cid) {
- model.close().blur();
- }
- });
- return this;
- },
-
- /**
- * Removes focus from this contextual link, unless it is open.
- *
- * @return {Drupal.contextual.StateModel}
- * The current contextual state model.
- */
- blur() {
- if (!this.get('isOpen')) {
- this.set('hasFocus', false);
- }
- return this;
- },
- },
- );
-})(Drupal, Backbone);
diff --git a/core/modules/contextual/js/toolbar/contextualToolbarModelView.js b/core/modules/contextual/js/toolbar/contextualToolbarModelView.js
new file mode 100644
index 00000000000..6c6db5fe70c
--- /dev/null
+++ b/core/modules/contextual/js/toolbar/contextualToolbarModelView.js
@@ -0,0 +1,175 @@
+(($, Drupal) => {
+ Drupal.contextual.ContextualToolbarModelView = class {
+ constructor(options) {
+ this.strings = options.strings;
+ this.isVisible = false;
+ this._contextualCount = Drupal.contextual.instances.count;
+ this.tabbingContext = null;
+ this._isViewing =
+ localStorage.getItem('Drupal.contextualToolbar.isViewing') !== 'false';
+ this.$el = options.el;
+
+ window.addEventListener('contextual-instances-added', () =>
+ this.lockNewContextualLinks(),
+ );
+ window.addEventListener('contextual-instances-removed', () => {
+ this.contextualCount = Drupal.contextual.instances.count;
+ });
+
+ this.$el.on({
+ click: () => {
+ this.isViewing = !this.isViewing;
+ },
+ touchend: (event) => {
+ event.preventDefault();
+ event.target.click();
+ },
+ 'click touchend': () => this.render(),
+ });
+
+ $(document).on('keyup', (event) => this.onKeypress(event));
+ this.manageTabbing(true);
+ this.render();
+ }
+
+ /**
+ * Responds to esc and tab key press events.
+ *
+ * @param {jQuery.Event} event
+ * The keypress event.
+ */
+ onKeypress(event) {
+ // The first tab key press is tracked so that an announcement about
+ // tabbing constraints can be raised if edit mode is enabled when the page
+ // is loaded.
+ if (!this.announcedOnce && event.keyCode === 9 && !this.isViewing) {
+ this.announceTabbingConstraint();
+ // Set announce to true so that this conditional block won't run again.
+ this.announcedOnce = true;
+ }
+ // Respond to the ESC key. Exit out of edit mode.
+ if (event.keyCode === 27) {
+ this.isViewing = true;
+ }
+ }
+
+ /**
+ * Updates the rendered representation of the current toolbar model view.
+ */
+ render() {
+ this.$el[0].classList.toggle('hidden', this.isVisible);
+ const button = this.$el[0].querySelector('button');
+ button.classList.toggle('is-active', !this.isViewing);
+ button.setAttribute('aria-pressed', !this.isViewing);
+ this.contextualCount = Drupal.contextual.instances.count;
+ }
+
+ /**
+ * Automatically updates visibility of the view/edit mode toggle.
+ */
+ updateVisibility() {
+ this.isVisible = this.get('contextualCount') > 0;
+ }
+
+ /**
+ * Lock newly added contextual links if edit mode is enabled.
+ */
+ lockNewContextualLinks() {
+ Drupal.contextual.instances.forEach((model) => {
+ model.isLocked = !this.isViewing;
+ });
+ this.contextualCount = Drupal.contextual.instances.count;
+ }
+
+ /**
+ * Limits tabbing to the contextual links and edit mode toolbar tab.
+ *
+ * @param {boolean} init - true to initialize tabbing.
+ */
+ manageTabbing(init = false) {
+ let { tabbingContext } = this;
+ // Always release an existing tabbing context.
+ if (tabbingContext && !init) {
+ // Only announce release when the context was active.
+ if (tabbingContext.active) {
+ Drupal.announce(this.strings.tabbingReleased);
+ }
+ tabbingContext.release();
+ this.tabbingContext = null;
+ }
+ // Create a new tabbing context when edit mode is enabled.
+ if (!this.isViewing) {
+ tabbingContext = Drupal.tabbingManager.constrain(
+ $('.contextual-toolbar-tab, .contextual'),
+ );
+ this.tabbingContext = tabbingContext;
+ this.announceTabbingConstraint();
+ this.announcedOnce = true;
+ }
+ }
+
+ /**
+ * Announces the current tabbing constraint.
+ */
+ announceTabbingConstraint() {
+ const { strings } = this;
+ Drupal.announce(
+ Drupal.formatString(strings.tabbingConstrained, {
+ '@contextualsCount': Drupal.formatPlural(
+ Drupal.contextual.instances.length,
+ '@count contextual link',
+ '@count contextual links',
+ ),
+ }) + strings.pressEsc,
+ );
+ }
+
+ /**
+ * Gets the current viewing state.
+ *
+ * @return {boolean} the viewing state.
+ */
+ get isViewing() {
+ return this._isViewing;
+ }
+
+ /**
+ * Sets the current viewing state.
+ *
+ * @param {boolean} value - new viewing state
+ */
+ set isViewing(value) {
+ this._isViewing = value;
+ localStorage[!value ? 'setItem' : 'removeItem'](
+ 'Drupal.contextualToolbar.isViewing',
+ 'false',
+ );
+
+ Drupal.contextual.instances.forEach((model) => {
+ model.isLocked = !this.isViewing;
+ });
+ this.manageTabbing();
+ }
+
+ /**
+ * Gets the current contextual links count.
+ *
+ * @return {integer} the current contextual links count.
+ */
+ get contextualCount() {
+ return this._contextualCount;
+ }
+
+ /**
+ * Sets the current contextual links count.
+ *
+ * @param {integer} value - new contextual links count.
+ */
+ set contextualCount(value) {
+ if (value !== this._contextualCount) {
+ this._contextualCount = value;
+ this.updateVisibility();
+ }
+ }
+ };
+})(jQuery, Drupal);
diff --git a/core/modules/contextual/js/toolbar/models/StateModel.js b/core/modules/contextual/js/toolbar/models/StateModel.js
deleted file mode 100644
index 88f66193f9f..00000000000
--- a/core/modules/contextual/js/toolbar/models/StateModel.js
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @file
- * A Backbone Model for the state of Contextual module's edit toolbar tab.
- */
-
-(function (Drupal, Backbone) {
- /**
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
- * replacement.
- */
- Drupal.contextualToolbar.StateModel = Backbone.Model.extend(
- /** @lends Drupal.contextualToolbar.StateModel# */ {
- /**
- * @type {object}
- *
- * @prop {boolean} isViewing
- * @prop {boolean} isVisible
- * @prop {number} contextualCount
- * @prop {Drupal~TabbingContext} tabbingContext
- */
- defaults: /** @lends Drupal.contextualToolbar.StateModel# */ {
- /**
- * Indicates whether the toggle is currently in "view" or "edit" mode.
- *
- * @type {boolean}
- */
- isViewing: true,
-
- /**
- * Indicates whether the toggle should be visible or hidden. Automatically
- * calculated, depends on contextualCount.
- *
- * @type {boolean}
- */
- isVisible: false,
-
- /**
- * Tracks how many contextual links exist on the page.
- *
- * @type {number}
- */
- contextualCount: 0,
-
- /**
- * A TabbingContext object as returned by {@link Drupal~TabbingManager}:
- * the set of tabbable elements when edit mode is enabled.
- *
- * @type {?Drupal~TabbingContext}
- */
- tabbingContext: null,
- },
-
- /**
- * Models the state of the edit mode toggle.
- *
- * @constructs
- *
- * @augments Backbone.Model
- *
- * @param {object} attrs
- * Attributes for the backbone model.
- * @param {object} options
- * An object with the following option:
- * @param {Backbone.collection} options.contextualCollection
- * The collection of {@link Drupal.contextual.StateModel} models that
- * represent the contextual links on the page.
- */
- initialize(attrs, options) {
- // Respond to new/removed contextual links.
- this.listenTo(
- options.contextualCollection,
- 'reset remove add',
- this.countContextualLinks,
- );
- this.listenTo(
- options.contextualCollection,
- 'add',
- this.lockNewContextualLinks,
- );
-
- // Automatically determine visibility.
- this.listenTo(this, 'change:contextualCount', this.updateVisibility);
-
- // Whenever edit mode is toggled, lock all contextual links.
- this.listenTo(this, 'change:isViewing', (model, isViewing) => {
- options.contextualCollection.each((contextualModel) => {
- contextualModel.set('isLocked', !isViewing);
- });
- });
- },
-
- /**
- * Tracks the number of contextual link models in the collection.
- *
- * @param {Drupal.contextual.StateModel} contextualModel
- * The contextual links model that was added or removed.
- * @param {Backbone.Collection} contextualCollection
- * The collection of contextual link models.
- */
- countContextualLinks(contextualModel, contextualCollection) {
- this.set('contextualCount', contextualCollection.length);
- },
-
- /**
- * Lock newly added contextual links if edit mode is enabled.
- *
- * @param {Drupal.contextual.StateModel} contextualModel
- * The contextual links model that was added.
- * @param {Backbone.Collection} [contextualCollection]
- * The collection of contextual link models.
- */
- lockNewContextualLinks(contextualModel, contextualCollection) {
- if (!this.get('isViewing')) {
- contextualModel.set('isLocked', true);
- }
- },
-
- /**
- * Automatically updates visibility of the view/edit mode toggle.
- */
- updateVisibility() {
- this.set('isVisible', this.get('contextualCount') > 0);
- },
- },
- );
-})(Drupal, Backbone);
diff --git a/core/modules/contextual/js/toolbar/views/AuralView.js b/core/modules/contextual/js/toolbar/views/AuralView.js
deleted file mode 100644
index 2bcf9cdcca0..00000000000
--- a/core/modules/contextual/js/toolbar/views/AuralView.js
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * @file
- * A Backbone View that provides the aural view of the edit mode toggle.
- */
-
-(function ($, Drupal, Backbone, _) {
- /**
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
- * replacement.
- */
- Drupal.contextualToolbar.AuralView = Backbone.View.extend(
- /** @lends Drupal.contextualToolbar.AuralView# */ {
- /**
- * Tracks whether the tabbing constraint announcement has been read once.
- *
- * @type {boolean}
- */
- announcedOnce: false,
-
- /**
- * Renders the aural view of the edit mode toggle (screen reader support).
- *
- * @constructs
- *
- * @augments Backbone.View
- *
- * @param {object} options
- * Options for the view.
- */
- initialize(options) {
- this.options = options;
-
- this.listenTo(this.model, 'change', this.render);
- this.listenTo(this.model, 'change:isViewing', this.manageTabbing);
-
- $(document).on('keyup', _.bind(this.onKeypress, this));
- this.manageTabbing();
- },
-
- /**
- * {@inheritdoc}
- *
- * @return {Drupal.contextualToolbar.AuralView}
- * The current contextual toolbar aural view.
- */
- render() {
- // Render the state.
- this.$el
- .find('button')
- .attr('aria-pressed', !this.model.get('isViewing'));
-
- return this;
- },
-
- /**
- * Limits tabbing to the contextual links and edit mode toolbar tab.
- */
- manageTabbing() {
- let tabbingContext = this.model.get('tabbingContext');
- // Always release an existing tabbing context.
- if (tabbingContext) {
- // Only announce release when the context was active.
- if (tabbingContext.active) {
- Drupal.announce(this.options.strings.tabbingReleased);
- }
- tabbingContext.release();
- }
- // Create a new tabbing context when edit mode is enabled.
- if (!this.model.get('isViewing')) {
- tabbingContext = Drupal.tabbingManager.constrain(
- $('.contextual-toolbar-tab, .contextual'),
- );
- this.model.set('tabbingContext', tabbingContext);
- this.announceTabbingConstraint();
- this.announcedOnce = true;
- }
- },
-
- /**
- * Announces the current tabbing constraint.
- */
- announceTabbingConstraint() {
- const strings = this.options.strings;
- Drupal.announce(
- Drupal.formatString(strings.tabbingConstrained, {
- '@contextualsCount': Drupal.formatPlural(
- Drupal.contextual.collection.length,
- '@count contextual link',
- '@count contextual links',
- ),
- }),
- );
- Drupal.announce(strings.pressEsc);
- },
-
- /**
- * Responds to esc and tab key press events.
- *
- * @param {jQuery.Event} event
- * The keypress event.
- */
- onKeypress(event) {
- // The first tab key press is tracked so that an announcement about
- // tabbing constraints can be raised if edit mode is enabled when the page
- // is loaded.
- if (
- !this.announcedOnce &&
- event.keyCode === 9 &&
- !this.model.get('isViewing')
- ) {
- this.announceTabbingConstraint();
- // Set announce to true so that this conditional block won't run again.
- this.announcedOnce = true;
- }
- // Respond to the ESC key. Exit out of edit mode.
- if (event.keyCode === 27) {
- this.model.set('isViewing', true);
- }
- },
- },
- );
-})(jQuery, Drupal, Backbone, _);
diff --git a/core/modules/contextual/js/toolbar/views/VisualView.js b/core/modules/contextual/js/toolbar/views/VisualView.js
deleted file mode 100644
index 10d8dff2dea..00000000000
--- a/core/modules/contextual/js/toolbar/views/VisualView.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @file
- * A Backbone View that provides the visual view of the edit mode toggle.
- */
-
-(function (Drupal, Backbone) {
- /**
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
- * replacement.
- */
- Drupal.contextualToolbar.VisualView = Backbone.View.extend(
- /** @lends Drupal.contextualToolbar.VisualView# */ {
- /**
- * Events for the Backbone view.
- *
- * @return {object}
- * A mapping of events to be used in the view.
- */
- events() {
- // Prevents delay and simulated mouse events.
- const touchEndToClick = function (event) {
- event.preventDefault();
- event.target.click();
- };
-
- return {
- click() {
- this.model.set('isViewing', !this.model.get('isViewing'));
- },
- touchend: touchEndToClick,
- };
- },
-
- /**
- * Renders the visual view of the edit mode toggle.
- *
- * Listens to mouse & touch and handles edit mode toggle interactions.
- *
- * @constructs
- *
- * @augments Backbone.View
- */
- initialize() {
- this.listenTo(this.model, 'change', this.render);
- this.listenTo(this.model, 'change:isViewing', this.persist);
- },
-
- /**
- * {@inheritdoc}
- *
- * @return {Drupal.contextualToolbar.VisualView}
- * The current contextual toolbar visual view.
- */
- render() {
- // Render the visibility.
- this.$el.toggleClass('hidden', !this.model.get('isVisible'));
- // Render the state.
- this.$el
- .find('button')
- .toggleClass('is-active', !this.model.get('isViewing'));
-
- return this;
- },
-
- /**
- * Model change handler; persists the isViewing value to localStorage.
- *
- * `isViewing === true` is the default, so only stores in localStorage when
- * it's not the default value (i.e. false).
- *
- * @param {Drupal.contextualToolbar.StateModel} model
- * A {@link Drupal.contextualToolbar.StateModel} model.
- * @param {boolean} isViewing
- * The value of the isViewing attribute in the model.
- */
- persist(model, isViewing) {
- if (!isViewing) {
- localStorage.setItem('Drupal.contextualToolbar.isViewing', 'false');
- } else {
- localStorage.removeItem('Drupal.contextualToolbar.isViewing');
- }
- },
- },
- );
-})(Drupal, Backbone);
diff --git a/core/modules/contextual/js/views/AuralView.js b/core/modules/contextual/js/views/AuralView.js
deleted file mode 100644
index 62287c1bf11..00000000000
--- a/core/modules/contextual/js/views/AuralView.js
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * @file
- * A Backbone View that provides the aural view of a contextual link.
- */
-
-(function (Drupal, Backbone) {
- /**
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
- * replacement.
- */
- Drupal.contextual.AuralView = Backbone.View.extend(
- /** @lends Drupal.contextual.AuralView# */ {
- /**
- * Renders the aural view of a contextual link (i.e. screen reader support).
- *
- * @constructs
- *
- * @augments Backbone.View
- *
- * @param {object} options
- * Options for the view.
- */
- initialize(options) {
- this.options = options;
-
- this.listenTo(this.model, 'change', this.render);
-
- // Initial render.
- this.render();
- },
-
- /**
- * {@inheritdoc}
- */
- render() {
- const isOpen = this.model.get('isOpen');
-
- // Set the hidden property of the links.
- this.$el.find('.contextual-links').prop('hidden', !isOpen);
-
- // Update the view of the trigger.
- const $trigger = this.$el.find('.trigger');
- $trigger
- .each((index, element) => {
- element.textContent = Drupal.t(
- '@action @title configuration options',
- {
- '@action': !isOpen
- ? this.options.strings.open
- : this.options.strings.close,
- '@title': this.model.get('title'),
- },
- );
- })
- .attr('aria-pressed', isOpen);
- },
- },
- );
-})(Drupal, Backbone);
diff --git a/core/modules/contextual/js/views/KeyboardView.js b/core/modules/contextual/js/views/KeyboardView.js
deleted file mode 100644
index 2a3d144bea0..00000000000
--- a/core/modules/contextual/js/views/KeyboardView.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @file
- * A Backbone View that provides keyboard interaction for a contextual link.
- */
-
-(function (Drupal, Backbone) {
- /**
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
- * replacement.
- */
- Drupal.contextual.KeyboardView = Backbone.View.extend(
- /** @lends Drupal.contextual.KeyboardView# */ {
- /**
- * @type {object}
- */
- events: {
- 'focus .trigger': 'focus',
- 'focus .contextual-links a': 'focus',
- 'blur .trigger': function () {
- this.model.blur();
- },
- 'blur .contextual-links a': function () {
- // Set up a timeout to allow a user to tab between the trigger and the
- // contextual links without the menu dismissing.
- const that = this;
- this.timer = window.setTimeout(() => {
- that.model.close().blur();
- }, 150);
- },
- },
-
- /**
- * Provides keyboard interaction for a contextual link.
- *
- * @constructs
- *
- * @augments Backbone.View
- */
- initialize() {
- /**
- * The timer is used to create a delay before dismissing the contextual
- * links on blur. This is only necessary when keyboard users tab into
- * contextual links without edit mode (i.e. without TabbingManager).
- * That means that if we decide to disable tabbing of contextual links
- * without edit mode, all this timer logic can go away.
- *
- * @type {NaN|number}
- */
- this.timer = NaN;
- },
-
- /**
- * Sets focus on the model; Clears the timer that dismisses the links.
- */
- focus() {
- // Clear the timeout that might have been set by blurring a link.
- window.clearTimeout(this.timer);
- this.model.focus();
- },
- },
- );
-})(Drupal, Backbone);
diff --git a/core/modules/contextual/js/views/RegionView.js b/core/modules/contextual/js/views/RegionView.js
deleted file mode 100644
index 349428301d8..00000000000
--- a/core/modules/contextual/js/views/RegionView.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @file
- * A Backbone View that renders the visual view of a contextual region element.
- */
-
-(function (Drupal, Backbone) {
- /**
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
- * replacement.
- */
- Drupal.contextual.RegionView = Backbone.View.extend(
- /** @lends Drupal.contextual.RegionView# */ {
- /**
- * Events for the Backbone view.
- *
- * @return {object}
- * A mapping of events to be used in the view.
- */
- events() {
- // Used for tracking the presence of touch events. When true, the
- // mousemove and mouseenter event handlers are effectively disabled.
- // This is used instead of preventDefault() on touchstart as some
- // touchstart events are not cancelable.
- let touchStart = false;
- return {
- touchstart() {
- // Set to true so the mouseenter and mouseleave events that follow
- // know to not execute any hover related logic.
- touchStart = true;
- },
- mouseenter() {
- if (!touchStart) {
- this.model.set('regionIsHovered', true);
- }
- },
- mouseleave() {
- if (!touchStart) {
- this.model.close().blur().set('regionIsHovered', false);
- }
- },
- mousemove() {
- // Because there are scenarios where there are both touchscreens
- // and pointer devices, the touchStart flag should be set back to
- // false after mouseenter and mouseleave complete. It will be set to
- // true if another touchstart event occurs.
- touchStart = false;
- },
- };
- },
-
- /**
- * Renders the visual view of a contextual region element.
- *
- * @constructs
- *
- * @augments Backbone.View
- */
- initialize() {
- this.listenTo(this.model, 'change:hasFocus', this.render);
- },
-
- /**
- * {@inheritdoc}
- *
- * @return {Drupal.contextual.RegionView}
- * The current contextual region view.
- */
- render() {
- this.$el.toggleClass('focus', this.model.get('hasFocus'));
-
- return this;
- },
- },
- );
-})(Drupal, Backbone);
diff --git a/core/modules/contextual/js/views/VisualView.js b/core/modules/contextual/js/views/VisualView.js
deleted file mode 100644
index fcd932b1faf..00000000000
--- a/core/modules/contextual/js/views/VisualView.js
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * @file
- * A Backbone View that provides the visual view of a contextual link.
- */
-
-(function (Drupal, Backbone) {
- /**
- * @deprecated in drupal:9.4.0 and is removed from drupal:12.0.0. There is no
- * replacement.
- */
- Drupal.contextual.VisualView = Backbone.View.extend(
- /** @lends Drupal.contextual.VisualView# */ {
- /**
- * Events for the Backbone view.
- *
- * @return {object}
- * A mapping of events to be used in the view.
- */
- events() {
- // Prevents delay and simulated mouse events.
- const touchEndToClick = function (event) {
- event.preventDefault();
- event.target.click();
- };
-
- // Used for tracking the presence of touch events. When true, the
- // mousemove and mouseenter event handlers are effectively disabled.
- // This is used instead of preventDefault() on touchstart as some
- // touchstart events are not cancelable.
- let touchStart = false;
-
- return {
- touchstart() {
- // Set to true so the mouseenter events that follows knows to not
- // execute any hover related logic.
- touchStart = true;
- },
- mouseenter() {
- // We only want mouse hover events on non-touch.
- if (!touchStart) {
- this.model.focus();
- }
- },
- mousemove() {
- // Because there are scenarios where there are both touchscreens
- // and pointer devices, the touchStart flag should be set back to
- // false after mouseenter and mouseleave complete. It will be set to
- // true if another touchstart event occurs.
- touchStart = false;
- },
- 'click .trigger': function () {
- this.model.toggleOpen();
- },
- 'touchend .trigger': touchEndToClick,
- 'click .contextual-links a': function () {
- this.model.close().blur();
- },
- 'touchend .contextual-links a': touchEndToClick,
- };
- },
-
- /**
- * Renders the visual view of a contextual link. Listens to mouse & touch.
- *
- * @constructs
- *
- * @augments Backbone.View
- */
- initialize() {
- this.listenTo(this.model, 'change', this.render);
- },
-
- /**
- * {@inheritdoc}
- *
- * @return {Drupal.contextual.VisualView}
- * The current contextual visual view.
- */
- render() {
- const isOpen = this.model.get('isOpen');
- // The trigger should be visible when:
- // - the mouse hovered over the region,
- // - the trigger is locked,
- // - and for as long as the contextual menu is open.
- const isVisible =
- this.model.get('isLocked') ||
- this.model.get('regionIsHovered') ||
- isOpen;
-
- this.$el
- // The open state determines if the links are visible.
- .toggleClass('open', isOpen)
- // Update the visibility of the trigger.
- .find('.trigger')
- .toggleClass('visually-hidden', !isVisible);
-
- // Nested contextual region handling: hide any nested contextual triggers.
- if ('isOpen' in this.model.changed) {
- this.$el
- .closest('.contextual-region')
- .find('.contextual .trigger:not(:first)')
- .toggle(!isOpen);
- }
-
- return this;
- },
- },
- );
-})(Drupal, Backbone);
diff --git a/core/modules/contextual/src/Hook/ContextualThemeHooks.php b/core/modules/contextual/src/Hook/ContextualThemeHooks.php
index 760a42c9785..7d873196b43 100644
--- a/core/modules/contextual/src/Hook/ContextualThemeHooks.php
+++ b/core/modules/contextual/src/Hook/ContextualThemeHooks.php
@@ -2,7 +2,7 @@
namespace Drupal\contextual\Hook;
-use Drupal\Core\Hook\Attribute\Preprocess;
+use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
/**
@@ -21,7 +21,7 @@ class ContextualThemeHooks {
* @see contextual_page_attachments()
* @see \Drupal\contextual\ContextualController::render()
*/
- #[Preprocess]
+ #[Hook('preprocess')]
public function preprocess(&$variables, $hook, $info): void {
// Determine the primary theme function argument.
if (!empty($info['variables'])) {
diff --git a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php
index 75e56b5f76b..1d4fa243c49 100644
--- a/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php
+++ b/core/modules/contextual/tests/src/FunctionalJavascript/EditModeTest.php
@@ -73,47 +73,40 @@ class EditModeTest extends WebDriverTestBase {
$page = $this->getSession()->getPage();
// Get the page twice to ensure edit mode remains enabled after a new page
// request.
- for ($page_get_count = 0; $page_get_count < 2; $page_get_count++) {
- $this->drupalGet('user');
- $expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]'));
-
- // After the page loaded we need to additionally wait until the settings
- // tray Ajax activity is done.
- if ($page_get_count === 0) {
- $web_assert->assertWaitOnAjaxRequest();
- }
-
- if ($page_get_count == 0) {
- $unrestricted_tab_count = $this->getTabbableElementsCount();
- $this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count);
-
- // Enable edit mode.
- // After the first page load the page will be in edit mode when loaded.
- $this->pressToolbarEditButton();
- }
-
- $this->assertAnnounceEditMode();
- $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount());
-
- // Disable edit mode.
- $this->pressToolbarEditButton();
- $this->assertAnnounceLeaveEditMode();
- $this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount());
- // Enable edit mode again.
- $this->pressToolbarEditButton();
- // Finally assert that the 'edit mode enabled' announcement is still
- // correct after toggling the edit mode at least once.
- $this->assertAnnounceEditMode();
- $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount());
-
- // Test while Edit Mode is enabled it doesn't interfere with pages with
- // no contextual links.
- $this->drupalGet('admin/structure/block');
- $web_assert->elementContains('css', 'h1.page-title', 'Block layout');
- $this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]')));
- $this->assertGreaterThan(0, $this->getTabbableElementsCount());
- }
-
+ $this->drupalGet('user');
+ $expected_restricted_tab_count = 1 + count($page->findAll('css', '[data-contextual-id]'));
+
+ // After the page loaded we need to additionally wait until the settings
+ // tray Ajax activity is done.
+ $web_assert->assertWaitOnAjaxRequest();
+
+ $unrestricted_tab_count = $this->getTabbableElementsCount();
+ $this->assertGreaterThan($expected_restricted_tab_count, $unrestricted_tab_count);
+
+ // Enable edit mode.
+ // After the first page load the page will be in edit mode when loaded.
+ $this->pressToolbarEditButton();
+
+ $this->assertAnnounceEditMode();
+ $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount());
+
+ // Disable edit mode.
+ $this->pressToolbarEditButton();
+ $this->assertAnnounceLeaveEditMode();
+ $this->assertSame($unrestricted_tab_count, $this->getTabbableElementsCount());
+ // Enable edit mode again.
+ $this->pressToolbarEditButton();
+ // Finally assert that the 'edit mode enabled' announcement is still
+ // correct after toggling the edit mode at least once.
+ $this->assertAnnounceEditMode();
+ $this->assertSame($expected_restricted_tab_count, $this->getTabbableElementsCount());
+
+ // Test while Edit Mode is enabled it doesn't interfere with pages with
+ // no contextual links.
+ $this->drupalGet('admin/structure/block');
+ $web_assert->elementContains('css', 'h1.page-title', 'Block layout');
+ $this->assertEquals(0, count($page->findAll('css', '[data-contextual-id]')));
+ $this->assertGreaterThan(0, $this->getTabbableElementsCount());
}
/**
diff --git a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php
index f2c2578f320..d6dee40b55e 100644
--- a/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php
+++ b/core/modules/datetime/tests/src/Functional/DateTimeFieldTest.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Drupal\Tests\datetime\Functional;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Datetime\Entity\DateFormat;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
@@ -190,11 +189,15 @@ class DateTimeFieldTest extends DateTestBase {
$display_repository->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full')
->setComponent($field_name, $this->displayOptions)
->save();
- $expected = new FormattableMarkup($this->displayOptions['settings']['past_format'], [
- '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]),
- ]);
+ $expected = str_replace(
+ '@interval',
+ $this->dateFormatter->formatTimeDiffSince(
+ $timestamp,
+ ['granularity' => $this->displayOptions['settings']['granularity']]),
+ $this->displayOptions['settings']['past_format']
+ );
$output = $this->renderTestEntity($id);
- $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone.");
+ $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone.");
// Verify that the 'datetime_time_ago' formatter works for intervals in
// the future. First update the test entity so that the date difference
@@ -211,11 +214,15 @@ class DateTimeFieldTest extends DateTestBase {
$display_repository->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full')
->setComponent($field_name, $this->displayOptions)
->save();
- $expected = new FormattableMarkup($this->displayOptions['settings']['future_format'], [
- '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]),
- ]);
+ $expected = str_replace(
+ '@interval',
+ $this->dateFormatter->formatTimeDiffUntil(
+ $timestamp,
+ ['granularity' => $this->displayOptions['settings']['granularity']]),
+ $this->displayOptions['settings']['future_format']
+ );
$output = $this->renderTestEntity($id);
- $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone.");
+ $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected in $timezone.");
}
}
@@ -341,11 +348,15 @@ class DateTimeFieldTest extends DateTestBase {
$display_repository->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full')
->setComponent($field_name, $this->displayOptions)
->save();
- $expected = new FormattableMarkup($this->displayOptions['settings']['past_format'], [
- '@interval' => $this->dateFormatter->formatTimeDiffSince($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]),
- ]);
+ $expected = str_replace(
+ '@interval',
+ $this->dateFormatter->formatTimeDiffSince(
+ $timestamp,
+ ['granularity' => $this->displayOptions['settings']['granularity']]),
+ $this->displayOptions['settings']['past_format']
+ );
$output = $this->renderTestEntity($id);
- $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected.");
+ $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected.");
// Verify that the 'datetime_time_ago' formatter works for intervals in the
// future. First update the test entity so that the date difference always
@@ -363,11 +374,15 @@ class DateTimeFieldTest extends DateTestBase {
->getViewDisplay($this->field->getTargetEntityTypeId(), $this->field->getTargetBundle(), 'full')
->setComponent($field_name, $this->displayOptions)
->save();
- $expected = new FormattableMarkup($this->displayOptions['settings']['future_format'], [
- '@interval' => $this->dateFormatter->formatTimeDiffUntil($timestamp, ['granularity' => $this->displayOptions['settings']['granularity']]),
- ]);
+ $expected = str_replace(
+ '@interval',
+ $this->dateFormatter->formatTimeDiffUntil(
+ $timestamp,
+ ['granularity' => $this->displayOptions['settings']['granularity']]),
+ $this->displayOptions['settings']['future_format']
+ );
$output = $this->renderTestEntity($id);
- $this->assertStringContainsString((string) $expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected.");
+ $this->assertStringContainsString($expected, $output, "Formatted date field using datetime_time_ago format displayed as $expected.");
// Test the required field validation error message.
$entity = EntityTest::create(['name' => 'test datetime required message']);
@@ -375,9 +390,9 @@ class DateTimeFieldTest extends DateTestBase {
$form_state = new FormState();
\Drupal::formBuilder()->submitForm($form, $form_state);
$errors = $form_state->getErrors();
- $expected_error_message = new FormattableMarkup('The %field date is required.', ['%field' => $field_label]);
+ $expected_error_message = "The <em class=\"placeholder\">$field_label</em> date is required.";
$actual_error_message = $errors["{$field_name}][0][value"]->__toString();
- $this->assertEquals($expected_error_message->__toString(), $actual_error_message);
+ $this->assertEquals($expected_error_message, $actual_error_message);
}
/**
diff --git a/core/modules/dblog/tests/src/Functional/DbLogTest.php b/core/modules/dblog/tests/src/Functional/DbLogTest.php
index 95c46392443..d1a09aed265 100644
--- a/core/modules/dblog/tests/src/Functional/DbLogTest.php
+++ b/core/modules/dblog/tests/src/Functional/DbLogTest.php
@@ -4,14 +4,12 @@ declare(strict_types=1);
namespace Drupal\Tests\dblog\Functional;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Database\Database;
use Drupal\Core\Logger\RfcLogLevel;
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\dblog\Controller\DbLogController;
-use Drupal\error_test\Controller\ErrorTestController;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\system\Functional\Menu\AssertBreadcrumbTrait;
@@ -914,16 +912,9 @@ class DbLogTest extends BrowserTestBase {
$wid = $query->execute()->fetchField();
$this->drupalGet('admin/reports/dblog/event/' . $wid);
- $error_user_notice = [
- '%type' => 'User warning',
- '@message' => 'Drupal & awesome',
- '%function' => ErrorTestController::class . '->generateWarnings()',
- '%file' => $this->getModulePath('error_test') . '/error_test.module',
- ];
-
// Check if the full message displays on the details page and backtrace is a
// pre-formatted text.
- $message = new FormattableMarkup('%type: @message in %function (line', $error_user_notice);
+ $message = '<em class="placeholder">User warning</em>: Drupal &amp; awesome in <em class="placeholder">Drupal\error_test\Controller\ErrorTestController-&gt;generateWarnings()</em> (line';
$this->assertSession()->responseContains($message);
$this->assertSession()->responseContains('<pre class="backtrace">');
}
diff --git a/core/modules/editor/tests/src/Functional/EditorAdminTest.php b/core/modules/editor/tests/src/Functional/EditorAdminTest.php
index 639aa030618..12ec751f41c 100644
--- a/core/modules/editor/tests/src/Functional/EditorAdminTest.php
+++ b/core/modules/editor/tests/src/Functional/EditorAdminTest.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Drupal\Tests\editor\Functional;
-use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Component\Utility\Html;
use Drupal\filter\Entity\FilterFormat;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
@@ -142,7 +142,7 @@ class EditorAdminTest extends BrowserTestBase {
$this->drupalLogin($account);
// The node edit page header.
- $text = (string) new FormattableMarkup('<em>Edit @type</em> @title', ['@type' => $node_type->label(), '@title' => $node->label()]);
+ $text = sprintf('<em>Edit %s</em> %s', Html::escape($node_type->label()), Html::escape($node->label()));
// Go to node edit form.
$this->drupalGet('node/' . $node->id() . '/edit');
diff --git a/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php b/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php
index de14164bd80..48a5c652c8c 100644
--- a/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php
+++ b/core/modules/field/tests/src/Functional/FunctionalString/StringFieldTest.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Drupal\Tests\field\Functional\FunctionalString;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
@@ -95,7 +94,7 @@ class StringFieldTest extends BrowserTestBase {
$this->drupalGet('entity_test/add');
$this->assertSession()->fieldValueEquals("{$field_name}[0][value]", '');
$this->assertSession()->fieldNotExists("{$field_name}[0][format]");
- $this->assertSession()->responseContains(new FormattableMarkup('placeholder="A placeholder on @widget_type"', ['@widget_type' => $widget_type]));
+ $this->assertSession()->responseContains('placeholder="A placeholder on ' . $widget_type . '"');
// Submit with some value.
$value = $this->randomMachineName();
diff --git a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php
index 129f28576d6..76907277eae 100644
--- a/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php
+++ b/core/modules/field/tests/src/FunctionalJavascript/EntityReference/EntityReferenceAdminTest.php
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Drupal\Tests\field\FunctionalJavascript\EntityReference;
use Behat\Mink\Element\NodeElement;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Url;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\field_ui\Traits\FieldUiJSTestTrait;
@@ -317,10 +316,9 @@ class EntityReferenceAdminTest extends WebDriverTestBase {
// Try to select the views handler.
$this->drupalGet($bundle_path . '/fields/' . $field_name);
$page->findField('settings[handler]')->setValue('views');
- $views_text = (string) new FormattableMarkup('No eligible views were found. <a href=":create">Create a view</a> with an <em>Entity Reference</em> display, or add such a display to an <a href=":existing">existing view</a>.', [
- ':create' => Url::fromRoute('views_ui.add')->toString(),
- ':existing' => Url::fromRoute('entity.view.collection')->toString(),
- ]);
+ $create = Url::fromRoute('views_ui.add')->toString();
+ $existing = Url::fromRoute('entity.view.collection')->toString();
+ $views_text = 'No eligible views were found. <a href="' . $create . '">Create a view</a> with an <em>Entity Reference</em> display, or add such a display to an <a href="' . $existing . '">existing view</a>.';
$assert_session->waitForElement('xpath', '//a[contains(text(), "Create a view")]');
$assert_session->responseContains($views_text);
diff --git a/core/modules/file/tests/src/Functional/SaveUploadTest.php b/core/modules/file/tests/src/Functional/SaveUploadTest.php
index 66cfe08cad3..14509ea426c 100644
--- a/core/modules/file/tests/src/Functional/SaveUploadTest.php
+++ b/core/modules/file/tests/src/Functional/SaveUploadTest.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Drupal\Tests\file\Functional;
-use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Component\Utility\Html;
use Drupal\Core\File\FileExists;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
@@ -738,8 +738,8 @@ class SaveUploadTest extends FileManagedTestBase {
$content = (string) $response->getBody();
$this->htmlOutput($content);
- $error_text = new FormattableMarkup('The file %filename could not be uploaded because the name is invalid.', ['%filename' => $filename]);
- $this->assertStringContainsString((string) $error_text, $content);
+ $error_text = 'The file <em class="placeholder">' . Html::escape($filename) . '</em> could not be uploaded because the name is invalid.';
+ $this->assertStringContainsString($error_text, $content);
$this->assertStringContainsString('Epic upload FAIL!', $content);
$this->assertFileDoesNotExist('temporary://' . $filename);
}
diff --git a/core/modules/help/src/HelpTopicTwigLoader.php b/core/modules/help/src/HelpTopicTwigLoader.php
index fc2e61bbaaf..9178166597c 100644
--- a/core/modules/help/src/HelpTopicTwigLoader.php
+++ b/core/modules/help/src/HelpTopicTwigLoader.php
@@ -96,7 +96,7 @@ class HelpTopicTwigLoader extends FilesystemLoader {
/**
* {@inheritdoc}
*/
- protected function findTemplate($name, $throw = TRUE) {
+ protected function findTemplate($name, $throw = TRUE): ?string {
if (!str_ends_with($name, '.html.twig')) {
if (!$throw) {
return NULL;
diff --git a/core/modules/help/src/HelpTwigExtension.php b/core/modules/help/src/HelpTwigExtension.php
index e41ad66503d..b8a77a914f6 100644
--- a/core/modules/help/src/HelpTwigExtension.php
+++ b/core/modules/help/src/HelpTwigExtension.php
@@ -41,7 +41,7 @@ class HelpTwigExtension extends AbstractExtension {
/**
* {@inheritdoc}
*/
- public function getFunctions() {
+ public function getFunctions(): array {
return [
new TwigFunction('help_route_link', [$this, 'getRouteLink']),
new TwigFunction('help_topic_link', [$this, 'getTopicLink']),
diff --git a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php
index f54e15e882a..abe16ebdb48 100644
--- a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php
+++ b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigExtension.php
@@ -14,7 +14,7 @@ class HelpTestTwigExtension extends AbstractExtension {
/**
* {@inheritdoc}
*/
- public function getNodeVisitors() {
+ public function getNodeVisitors(): array {
return [
new HelpTestTwigNodeVisitor(),
];
diff --git a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php
index 953f2aa2ce4..9c53a2e0cf3 100644
--- a/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php
+++ b/core/modules/help/tests/modules/help_topics_twig_tester/src/HelpTestTwigNodeVisitor.php
@@ -97,7 +97,7 @@ class HelpTestTwigNodeVisitor implements NodeVisitorInterface {
/**
* {@inheritdoc}
*/
- public function getPriority() {
+ public function getPriority(): int {
return -100;
}
diff --git a/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php b/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php
index 1e182076608..13e6bdffda1 100644
--- a/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php
+++ b/core/modules/help/tests/src/Unit/HelpTopicTwigTest.php
@@ -6,8 +6,8 @@ namespace Drupal\Tests\help\Unit;
use Drupal\Core\Cache\Cache;
use Drupal\help\HelpTopicTwig;
-use Drupal\Tests\Core\Template\StubTwigTemplate;
use Drupal\Tests\UnitTestCase;
+use Twig\Template;
use Twig\TemplateWrapper;
/**
@@ -101,8 +101,8 @@ class HelpTopicTwigTest extends UnitTestCase {
->getMock();
$template = $this
- ->getMockBuilder(StubTwigTemplate::class)
- ->onlyMethods(['render'])
+ ->getMockBuilder(Template::class)
+ ->onlyMethods(['render', 'getTemplateName', 'getDebugInfo', 'getSourceContext', 'doDisplay'])
->setConstructorArgs([$twig])
->getMock();
diff --git a/core/modules/image/config/install/image.style.large.yml b/core/modules/image/config/install/image.style.large.yml
index e0b8394552e..1e327eea8e5 100644
--- a/core/modules/image/config/install/image.style.large.yml
+++ b/core/modules/image/config/install/image.style.large.yml
@@ -14,7 +14,7 @@ effects:
upscale: false
6e8fe467-84c1-4ef0-a73b-7eccf1cc20e8:
uuid: 6e8fe467-84c1-4ef0-a73b-7eccf1cc20e8
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/modules/image/config/install/image.style.medium.yml b/core/modules/image/config/install/image.style.medium.yml
index f096610c659..d7ea09a6789 100644
--- a/core/modules/image/config/install/image.style.medium.yml
+++ b/core/modules/image/config/install/image.style.medium.yml
@@ -14,7 +14,7 @@ effects:
upscale: false
c410ed2f-aa30-4d9c-a224-d2865d9188cd:
uuid: c410ed2f-aa30-4d9c-a224-d2865d9188cd
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/modules/image/config/install/image.style.thumbnail.yml b/core/modules/image/config/install/image.style.thumbnail.yml
index c03c60e00e2..c2d7a4e5042 100644
--- a/core/modules/image/config/install/image.style.thumbnail.yml
+++ b/core/modules/image/config/install/image.style.thumbnail.yml
@@ -14,7 +14,7 @@ effects:
upscale: false
c4eb9942-2c9e-4a81-949f-6161a44b6559:
uuid: c4eb9942-2c9e-4a81-949f-6161a44b6559
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/modules/image/config/install/image.style.wide.yml b/core/modules/image/config/install/image.style.wide.yml
index 8573ae26346..b62e05f3e38 100644
--- a/core/modules/image/config/install/image.style.wide.yml
+++ b/core/modules/image/config/install/image.style.wide.yml
@@ -14,7 +14,7 @@ effects:
upscale: false
294c5f76-42a4-43ce-82c2-81c2f4723da0:
uuid: 294c5f76-42a4-43ce-82c2-81c2f4723da0
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/modules/image/config/schema/image.schema.yml b/core/modules/image/config/schema/image.schema.yml
index f805caa378c..68edccf507a 100644
--- a/core/modules/image/config/schema/image.schema.yml
+++ b/core/modules/image/config/schema/image.schema.yml
@@ -52,6 +52,10 @@ image.effect.image_convert:
Choice:
callback: 'Drupal\Core\ImageToolkit\ImageToolkitManager::getAllValidExtensions'
+image.effect.image_convert_avif:
+ type: image.effect.image_convert
+ label: 'Convert to AVIF'
+
image.effect.image_resize:
type: image_size
label: 'Image resize'
diff --git a/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php b/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php
new file mode 100644
index 00000000000..595743eece7
--- /dev/null
+++ b/core/modules/image/src/Plugin/ImageEffect/AvifImageEffect.php
@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\image\Plugin\ImageEffect;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Image\ImageInterface;
+use Drupal\Core\ImageToolkit\ImageToolkitManager;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\image\Attribute\ImageEffect;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Converts an image resource to AVIF, with fallback.
+ */
+#[ImageEffect(
+ id: "image_convert_avif",
+ label: new TranslatableMarkup("Convert to AVIF"),
+ description: new TranslatableMarkup("Converts an image to AVIF, with a fallback if AVIF is not supported."),
+)]
+class AvifImageEffect extends ConvertImageEffect {
+
+ /**
+ * The image toolkit manager.
+ *
+ * @var \Drupal\Core\ImageToolkit\ImageToolkitManager
+ */
+ protected ImageToolkitManager $imageToolkitManager;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+ $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
+ $instance->imageToolkitManager = $container->get(ImageToolkitManager::class);
+ return $instance;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function applyEffect(ImageInterface $image) {
+ // If avif is not supported fallback to the parent.
+ if (!$this->isAvifSupported()) {
+ return parent::applyEffect($image);
+ }
+
+ if (!$image->convert('avif')) {
+ $this->logger->error('Image convert failed using the %toolkit toolkit on %path (%mimetype)', ['%toolkit' => $image->getToolkitId(), '%path' => $image->getSource(), '%mimetype' => $image->getMimeType()]);
+ return FALSE;
+ }
+
+ return TRUE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDerivativeExtension($extension) {
+ return $this->isAvifSupported() ? 'avif' : $this->configuration['extension'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+ $form = parent::buildConfigurationForm($form, $form_state);
+ unset($form['extension']['#options']['avif']);
+ $form['extension']['#title'] = $this->t('Fallback format');
+ $form['extension']['#description'] = $this->t('Format to use if AVIF is not available.');
+ return $form;
+ }
+
+ /**
+ * Is AVIF supported by the image toolkit.
+ */
+ protected function isAvifSupported(): bool {
+ return in_array('avif', $this->imageToolkitManager->getDefaultToolkit()->getSupportedExtensions());
+ }
+
+}
diff --git a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php
index 1e5c7533922..54130e7818b 100644
--- a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php
+++ b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php
@@ -120,6 +120,31 @@ class ImageEffectsTest extends KernelTestBase {
}
/**
+ * Tests the 'image_convert_avif' effect when avif is supported.
+ */
+ public function testConvertAvifEffect(): void {
+ $this->container->get('keyvalue')->get('image_test')->set('avif_enabled', TRUE);
+ $this->assertImageEffect(['convert'], 'image_convert_avif', [
+ 'extension' => 'webp',
+ ]);
+
+ $calls = $this->imageTestGetAllCalls();
+ $this->assertEquals('avif', $calls['convert'][0][0]);
+ }
+
+ /**
+ * Tests the 'image_convert_avif' effect with webp fallback.
+ */
+ public function testConvertAvifEffectFallback(): void {
+ $this->assertImageEffect(['convert'], 'image_convert_avif', [
+ 'extension' => 'webp',
+ ]);
+
+ $calls = $this->imageTestGetAllCalls();
+ $this->assertEquals('webp', $calls['convert'][0][0]);
+ }
+
+ /**
* Tests the 'image_scale_and_crop' effect.
*/
public function testScaleAndCropEffect(): void {
diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php
index 4067edd2616..dc44888a8b2 100644
--- a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php
+++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderDisableInteractionsTest.php
@@ -7,7 +7,6 @@ namespace Drupal\Tests\layout_builder\FunctionalJavascript;
use Behat\Mink\Element\NodeElement;
use Drupal\block_content\Entity\BlockContent;
use Drupal\block_content\Entity\BlockContentType;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\FunctionalJavascriptTests\JSWebAssert;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait;
@@ -190,7 +189,7 @@ class LayoutBuilderDisableInteractionsTest extends WebDriverTestBase {
try {
$element->click();
$tag_name = $element->getTagName();
- $this->fail(new FormattableMarkup("@tag_name was clickable when it shouldn't have been", ['@tag_name' => $tag_name]));
+ $this->fail("$tag_name was clickable when it shouldn't have been");
}
catch (\Exception $e) {
$this->assertTrue(JSWebAssert::isExceptionNotClickable($e));
diff --git a/core/modules/locale/src/Hook/LocaleThemeHooks.php b/core/modules/locale/src/Hook/LocaleThemeHooks.php
index d1e438f50ac..4ef5ca0b498 100644
--- a/core/modules/locale/src/Hook/LocaleThemeHooks.php
+++ b/core/modules/locale/src/Hook/LocaleThemeHooks.php
@@ -2,7 +2,7 @@
namespace Drupal\locale\Hook;
-use Drupal\Core\Hook\Attribute\Preprocess;
+use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
@@ -18,7 +18,7 @@ class LocaleThemeHooks {
/**
* Implements hook_preprocess_HOOK() for node templates.
*/
- #[Preprocess('node')]
+ #[Hook('preprocess_node')]
public function preprocessNode(&$variables): void {
/** @var \Drupal\node\NodeInterface $node */
$node = $variables['node'];
diff --git a/core/modules/mailer/mailer.info.yml b/core/modules/mailer/mailer.info.yml
new file mode 100644
index 00000000000..40c9afb713a
--- /dev/null
+++ b/core/modules/mailer/mailer.info.yml
@@ -0,0 +1,6 @@
+name: Mailer
+type: module
+description: 'Provides an experimental API to build and deliver email messages.'
+package: Core (Experimental)
+lifecycle: experimental
+version: VERSION
diff --git a/core/modules/mailer/mailer.services.yml b/core/modules/mailer/mailer.services.yml
new file mode 100644
index 00000000000..d69c04a8461
--- /dev/null
+++ b/core/modules/mailer/mailer.services.yml
@@ -0,0 +1,48 @@
+services:
+ _defaults:
+ autoconfigure: true
+ Symfony\Component\Mailer\Transport\AbstractTransportFactory:
+ abstract: true
+ arguments:
+ - '@Psr\EventDispatcher\EventDispatcherInterface'
+ - '@?Symfony\Contracts\HttpClient\HttpClientInterface'
+ # No logger injected on purpose. Log messages generated by transports are
+ # of little practical use and can lead to errors when a transport instance
+ # is destructed at the end of a request.
+ # See: https://www.drupal.org/i/3420372
+ - null
+ public: false
+ Symfony\Component\Mailer\Transport\NativeTransportFactory:
+ parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory
+ tags:
+ - { name: mailer.transport_factory }
+ Symfony\Component\Mailer\Transport\NullTransportFactory:
+ parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory
+ tags:
+ - { name: mailer.transport_factory }
+ Symfony\Component\Mailer\Transport\SendmailTransportFactory:
+ parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory
+ tags:
+ - { name: mailer.transport_factory }
+ Drupal\Core\Mailer\Transport\SendmailCommandValidationTransportFactory:
+ decorates: Symfony\Component\Mailer\Transport\SendmailTransportFactory
+ autowire: true
+ public: false
+ Symfony\Component\Mailer\Transport\Smtp\EsmtpTransportFactory:
+ parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory
+ tags:
+ - { name: mailer.transport_factory, priority: -100 }
+ Drupal\Core\Mailer\TransportServiceFactory:
+ autowire: true
+ public: false
+ Drupal\Core\Mailer\TransportServiceFactoryInterface: '@Drupal\Core\Mailer\TransportServiceFactory'
+ Symfony\Component\Mailer\Transport\TransportInterface:
+ factory: ['@Drupal\Core\Mailer\TransportServiceFactoryInterface', 'createTransport']
+ Symfony\Component\Mailer\Messenger\MessageHandler:
+ autowire: true
+ public: false
+ tags:
+ - { name: messenger.message_handler }
+ Symfony\Component\Mailer\Mailer:
+ autowire: true
+ Symfony\Component\Mailer\MailerInterface: '@Symfony\Component\Mailer\Mailer'
diff --git a/core/modules/mailer/src/Hook/MailerHooks.php b/core/modules/mailer/src/Hook/MailerHooks.php
new file mode 100644
index 00000000000..6e1b22e3380
--- /dev/null
+++ b/core/modules/mailer/src/Hook/MailerHooks.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\mailer\Hook;
+
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Hook implementations for mailer.
+ */
+class MailerHooks {
+
+ use StringTranslationTrait;
+
+ /**
+ * Implements hook_help().
+ */
+ #[Hook('help')]
+ public function help($route_name, RouteMatchInterface $route_match) : ?string {
+ switch ($route_name) {
+ case 'help.page.mailer':
+ $output = '';
+ $output .= '<h3>' . $this->t('About') . '</h3>';
+ $output .= '<p>' . $this->t('The Mailer module provides an experimental API to build and deliver email messages based on Symfony mailer component. For more information, see the <a href=":mailer">online documentation for the Mailer module</a>.', [
+ ':mailer' => 'https://www.drupal.org/docs/core-modules-and-themes/experimental-extensions/experimental-modules/mailer',
+ ]) . '</p>';
+ return $output;
+
+ default:
+ return NULL;
+ }
+ }
+
+}
diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml
new file mode 100644
index 00000000000..731d5b9bfee
--- /dev/null
+++ b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.info.yml
@@ -0,0 +1,5 @@
+name: 'Mailer transport factory functional test'
+type: module
+description: 'Support module for mailer transport factory functional testing.'
+package: Testing
+version: VERSION
diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml
new file mode 100644
index 00000000000..8294939c42f
--- /dev/null
+++ b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/mailer_transport_factory_functional_test.routing.yml
@@ -0,0 +1,6 @@
+mailer_transport_factory_functional_test.transport_info:
+ path: '/mailer-transport-factory-functional-test/transport-info'
+ defaults:
+ _controller: '\Drupal\mailer_transport_factory_functional_test\Controller\TransportInfoController::transportInfo'
+ requirements:
+ _access: 'TRUE'
diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php
new file mode 100644
index 00000000000..6f26f95ee81
--- /dev/null
+++ b/core/modules/mailer/tests/modules/mailer_transport_factory_functional_test/src/Controller/TransportInfoController.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\mailer_transport_factory_functional_test\Controller;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+
+/**
+ * Returns responses for transport info routes.
+ */
+class TransportInfoController implements ContainerInjectionInterface {
+
+ /**
+ * Constructs a new transport info controller.
+ *
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
+ * The config factory.
+ * @param \Symfony\Component\Mailer\Transport\TransportInterface $transport
+ * The mailer transport.
+ */
+ public function __construct(
+ protected ConfigFactoryInterface $configFactory,
+ protected TransportInterface $transport,
+ ) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container): static {
+ return new static(
+ $container->get(ConfigFactoryInterface::class),
+ $container->get(TransportInterface::class)
+ );
+ }
+
+ /**
+ * Returns info about the configured mailer dsn and the resulting transport.
+ */
+ public function transportInfo(): Response {
+ $mailerDsn = $this->configFactory->get('system.mail')->get('mailer_dsn');
+ return new JsonResponse([
+ 'mailerDsn' => $mailerDsn,
+ 'mailerTransportClass' => $this->transport::class,
+ ]);
+ }
+
+}
diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml
new file mode 100644
index 00000000000..cbe2e01e9b4
--- /dev/null
+++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.info.yml
@@ -0,0 +1,5 @@
+name: 'Mailer transport factory kernel test'
+type: module
+description: 'Support module for mailer transport factory kernel testing.'
+package: Testing
+version: VERSION
diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml
new file mode 100644
index 00000000000..1d9dec1cd6b
--- /dev/null
+++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/mailer_transport_factory_kernel_test.services.yml
@@ -0,0 +1,7 @@
+services:
+ _defaults:
+ autoconfigure: true
+ Drupal\mailer_transport_factory_kernel_test\Transport\CanaryTransportFactory:
+ parent: Symfony\Component\Mailer\Transport\AbstractTransportFactory
+ tags:
+ - { name: mailer.transport_factory }
diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php
new file mode 100644
index 00000000000..a13c57e140a
--- /dev/null
+++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransport.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\mailer_transport_factory_kernel_test\Transport;
+
+use Symfony\Component\Mailer\SentMessage;
+use Symfony\Component\Mailer\Transport\AbstractTransport;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+
+/**
+ * A transport only used to test the transport factory adapter.
+ */
+class CanaryTransport extends AbstractTransport implements TransportInterface {
+
+ protected function doSend(SentMessage $message): void {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __toString(): string {
+ return 'drupal.test-canary://default';
+ }
+
+}
diff --git a/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php
new file mode 100644
index 00000000000..4ffc33dfe8a
--- /dev/null
+++ b/core/modules/mailer/tests/modules/mailer_transport_factory_kernel_test/src/Transport/CanaryTransportFactory.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\mailer_transport_factory_kernel_test\Transport;
+
+use Symfony\Component\Mailer\Exception\UnsupportedSchemeException;
+use Symfony\Component\Mailer\Transport\AbstractTransportFactory;
+use Symfony\Component\Mailer\Transport\Dsn;
+use Symfony\Component\Mailer\Transport\TransportFactoryInterface;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+
+/**
+ * A transport factory only used to test the transport factory adapter.
+ */
+class CanaryTransportFactory extends AbstractTransportFactory implements TransportFactoryInterface {
+
+ protected function getSupportedSchemes(): array {
+ return ['drupal.test-canary'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function create(Dsn $dsn): TransportInterface {
+ if ($dsn->getScheme() === 'drupal.test-canary') {
+ return new CanaryTransport($this->dispatcher, $this->logger);
+ }
+
+ throw new UnsupportedSchemeException($dsn, 'test_canary', $this->getSupportedSchemes());
+ }
+
+}
diff --git a/core/modules/mailer/tests/src/Functional/GenericTest.php b/core/modules/mailer/tests/src/Functional/GenericTest.php
new file mode 100644
index 00000000000..e6c24144c70
--- /dev/null
+++ b/core/modules/mailer/tests/src/Functional/GenericTest.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\mailer\Functional;
+
+use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
+
+/**
+ * Generic module test for mailer.
+ *
+ * @group mailer
+ */
+class GenericTest extends GenericModuleTestBase {}
diff --git a/core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php b/core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php
new file mode 100644
index 00000000000..318b60829db
--- /dev/null
+++ b/core/modules/mailer/tests/src/Functional/TransportServiceFactoryTest.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\mailer\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Symfony\Component\Mailer\Transport\NullTransport;
+
+/**
+ * Tests the transport service factory in the child site of browser tests.
+ *
+ * @group mailer
+ */
+class TransportServiceFactoryTest extends BrowserTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'system',
+ 'mailer',
+ 'mailer_transport_factory_functional_test',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $defaultTheme = 'stark';
+
+ /**
+ * Test that the transport is set to null://null by default in the child site.
+ *
+ * The mailer configuration is set to a safe default during test setUp by
+ * FunctionalTestSetupTrait::initConfig(). This is in order to prevent tests
+ * from accidentally sending out emails. This test ensures that the transport
+ * service is configured correctly in the test child site.
+ */
+ public function testDefaultTestMailFactory(): void {
+ $response = $this->drupalGet('mailer-transport-factory-functional-test/transport-info');
+ $actual = json_decode($response, TRUE);
+
+ $expected = [
+ 'mailerDsn' => [
+ 'scheme' => 'null',
+ 'host' => 'null',
+ 'user' => NULL,
+ 'password' => NULL,
+ 'port' => NULL,
+ 'options' => [],
+ ],
+ 'mailerTransportClass' => NullTransport::class,
+ ];
+ $this->assertEquals($expected, $actual);
+ }
+
+}
diff --git a/core/modules/mailer/tests/src/Kernel/TransportTest.php b/core/modules/mailer/tests/src/Kernel/TransportTest.php
new file mode 100644
index 00000000000..f686fe86cc3
--- /dev/null
+++ b/core/modules/mailer/tests/src/Kernel/TransportTest.php
@@ -0,0 +1,160 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\mailer\Kernel;
+
+use Drupal\Core\Site\Settings;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\mailer_transport_factory_kernel_test\Transport\CanaryTransport;
+use PHPUnit\Framework\Attributes\After;
+use Symfony\Component\Mailer\Transport\NullTransport;
+use Symfony\Component\Mailer\Transport\SendmailTransport;
+use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+
+/**
+ * Tests the transport factory service.
+ *
+ * @group mailer
+ * @coversDefaultClass \Drupal\Core\Mailer\TransportServiceFactory
+ */
+class TransportTest extends KernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['mailer', 'system'];
+
+ /**
+ * Sets up a mailer DSN config override.
+ *
+ * @param string $scheme
+ * The mailer DSN scheme.
+ * @param string $host
+ * The mailer DSN host.
+ * @param string|null $user
+ * The mailer DSN username.
+ * @param string|null $password
+ * The mailer DSN password.
+ * @param int|null $port
+ * The mailer DSN port.
+ * @param array<string, mixed> $options
+ * Options for the mailer transport.
+ */
+ protected function setUpMailerDsnConfigOverride(
+ string $scheme,
+ string $host,
+ ?string $user = NULL,
+ #[\SensitiveParameter] ?string $password = NULL,
+ ?int $port = NULL,
+ array $options = [],
+ ): void {
+ $GLOBALS['config']['system.mail']['mailer_dsn'] = [
+ 'scheme' => $scheme,
+ 'host' => $host,
+ 'user' => $user,
+ 'password' => $password,
+ 'port' => $port,
+ 'options' => $options,
+ ];
+ }
+
+ /**
+ * Resets a mailer DSN config override.
+ *
+ * Clean up the globals modified by setUpMailerDsnConfigOverride() during a
+ * test.
+ */
+ #[After]
+ protected function resetMailerDsnConfigOverride(): void {
+ $this->setUpMailerDsnConfigOverride('null', 'null');
+ }
+
+ /**
+ * @covers ::createTransport
+ */
+ public function testDefaultTestMailFactory(): void {
+ $actual = $this->container->get(TransportInterface::class);
+ $this->assertInstanceOf(NullTransport::class, $actual);
+ }
+
+ /**
+ * @dataProvider providerTestBuiltinFactory
+ * @covers ::createTransport
+ */
+ public function testBuiltinFactory(string $schema, string $host, string $expected): void {
+ $this->setUpMailerDsnConfigOverride($schema, $host);
+
+ $actual = $this->container->get(TransportInterface::class);
+ $this->assertInstanceOf($expected, $actual);
+ }
+
+ /**
+ * Provides test data for testBuiltinFactory().
+ */
+ public static function providerTestBuiltinFactory(): iterable {
+ yield ['null', 'null', NullTransport::class];
+ yield ['sendmail', 'default', SendmailTransport::class];
+ yield ['smtp', 'default', EsmtpTransport::class];
+ }
+
+ /**
+ * @covers ::createTransport
+ * @covers \Drupal\Core\Mailer\Transport\SendmailCommandValidationTransportFactory::create
+ */
+ public function testSendmailFactoryAllowedCommand(): void {
+ // Test sendmail command allowlist.
+ $settings = Settings::getAll();
+ $settings['mailer_sendmail_commands'] = ['/usr/local/bin/sendmail -bs'];
+ new Settings($settings);
+
+ // Test allowlisted command.
+ $this->setUpMailerDsnConfigOverride('sendmail', 'default', options: [
+ 'command' => '/usr/local/bin/sendmail -bs',
+ ]);
+ $actual = $this->container->get(TransportInterface::class);
+ $this->assertInstanceOf(SendmailTransport::class, $actual);
+ }
+
+ /**
+ * @covers ::createTransport
+ * @covers \Drupal\Core\Mailer\Transport\SendmailCommandValidationTransportFactory::create
+ */
+ public function testSendmailFactoryUnlistedCommand(): void {
+ // Test sendmail command allowlist.
+ $settings = Settings::getAll();
+ $settings['mailer_sendmail_commands'] = ['/usr/local/bin/sendmail -bs'];
+ new Settings($settings);
+
+ // Test unlisted command.
+ $this->setUpMailerDsnConfigOverride('sendmail', 'default', options: [
+ 'command' => '/usr/bin/bc',
+ ]);
+ $this->expectExceptionMessage('Unsafe sendmail command /usr/bin/bc');
+ $this->container->get(TransportInterface::class);
+ }
+
+ /**
+ * @covers ::createTransport
+ */
+ public function testMissingFactory(): void {
+ $this->setUpMailerDsnConfigOverride('drupal.no-transport', 'default');
+
+ $this->expectExceptionMessage('The "drupal.no-transport" scheme is not supported');
+ $this->container->get(TransportInterface::class);
+ }
+
+ /**
+ * @covers ::createTransport
+ */
+ public function testThirdPartyFactory(): void {
+ $this->enableModules(['mailer_transport_factory_kernel_test']);
+
+ $this->setUpMailerDsnConfigOverride('drupal.test-canary', 'default');
+
+ $actual = $this->container->get(TransportInterface::class);
+ $this->assertInstanceOf(CanaryTransport::class, $actual);
+ }
+
+}
diff --git a/core/modules/media_library/config/install/image.style.media_library.yml b/core/modules/media_library/config/install/image.style.media_library.yml
index 5da64cfdcc3..4383a8c2cba 100644
--- a/core/modules/media_library/config/install/image.style.media_library.yml
+++ b/core/modules/media_library/config/install/image.style.media_library.yml
@@ -17,7 +17,7 @@ effects:
upscale: false
1021da71-fc2a-43d0-be5d-efaf1c79e2ea:
uuid: 1021da71-fc2a-43d0-be5d-efaf1c79e2ea
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php
index 3a1cb8a1b69..77c8b45d00f 100644
--- a/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php
+++ b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php
@@ -609,15 +609,15 @@ abstract class SourcePluginBase extends PluginBase implements MigrateSourceInter
* {@inheritdoc}
*/
public function preRollback(MigrateRollbackEvent $event) {
- // Nothing to do in this implementation.
+ // Reset the high-water mark.
+ $this->saveHighWater(NULL);
}
/**
* {@inheritdoc}
*/
public function postRollback(MigrateRollbackEvent $event) {
- // Reset the high-water mark.
- $this->saveHighWater(NULL);
+ // Nothing to do in this implementation.
}
/**
diff --git a/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php b/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php
index 2f0b85ffbc4..e344e3e23e8 100644
--- a/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php
+++ b/core/modules/migrate/tests/src/Unit/MigrateSourceTest.php
@@ -9,6 +9,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
+use Drupal\migrate\Event\MigrateRollbackEvent;
use Drupal\migrate\MigrateException;
use Drupal\migrate\MigrateExecutable;
use Drupal\migrate\MigrateSkipRowException;
@@ -448,6 +449,32 @@ class MigrateSourceTest extends MigrateTestCase {
return new MigrateExecutable($migration, $message, $event_dispatcher);
}
+ /**
+ * @covers ::preRollback
+ */
+ public function testPreRollback(): void {
+ $this->migrationConfiguration['id'] = 'test_migration';
+ $plugin_id = 'test_migration';
+ $migration = $this->getMigration();
+
+ // Verify that preRollback() sets the high water mark to NULL.
+ $key_value = $this->createMock(KeyValueStoreInterface::class);
+ $key_value->expects($this->once())
+ ->method('set')
+ ->with($plugin_id, NULL);
+ $key_value_factory = $this->createMock(KeyValueFactoryInterface::class);
+ $key_value_factory->expects($this->once())
+ ->method('get')
+ ->with('migrate:high_water')
+ ->willReturn($key_value);
+ $container = new ContainerBuilder();
+ $container->set('keyvalue', $key_value_factory);
+ \Drupal::setContainer($container);
+
+ $source = new StubSourceGeneratorPlugin([], $plugin_id, [], $migration);
+ $source->preRollback(new MigrateRollbackEvent($migration));
+ }
+
}
/**
diff --git a/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php b/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php
index 1f54f94848e..efe2b150928 100644
--- a/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php
+++ b/core/modules/migrate_drupal/tests/src/Kernel/d7/FieldDiscoveryTest.php
@@ -18,6 +18,7 @@ use Drupal\field_discovery_test\FieldDiscoveryTestClass;
* Test FieldDiscovery Service against Drupal 7.
*
* @group migrate_drupal
+ * @group #slow
* @coversDefaultClass \Drupal\migrate_drupal\FieldDiscovery
*/
class FieldDiscoveryTest extends MigrateDrupal7TestBase {
diff --git a/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php b/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php
index ca8a9a0d06b..27ab60bc0c0 100644
--- a/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php
+++ b/core/modules/migrate_drupal/tests/src/Kernel/d7/MigrateDrupal7AuditIdsTest.php
@@ -16,6 +16,7 @@ use Drupal\Tests\migrate_drupal\Traits\CreateTestContentEntitiesTrait;
* Tests the migration auditor for ID conflicts.
*
* @group migrate_drupal
+ * @group #slow
*/
class MigrateDrupal7AuditIdsTest extends MigrateDrupal7TestBase {
diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php
index 64dc7a1ea86..daf06a65468 100644
--- a/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php
+++ b/core/modules/migrate_drupal_ui/tests/src/Functional/d6/Upgrade6Test.php
@@ -73,7 +73,7 @@ class Upgrade6Test extends MigrateUpgradeExecuteTestBase {
*/
protected function getEntityCounts(): array {
return [
- 'block' => 37,
+ 'block' => 36,
'block_content' => 2,
'block_content_type' => 1,
'comment' => 8,
diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php b/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php
index 46b3447e159..f9b702d22e3 100644
--- a/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php
+++ b/core/modules/migrate_drupal_ui/tests/src/Functional/d7/Upgrade7Test.php
@@ -76,7 +76,7 @@ class Upgrade7Test extends MigrateUpgradeExecuteTestBase {
*/
protected function getEntityCounts(): array {
return [
- 'block' => 27,
+ 'block' => 26,
'block_content' => 1,
'block_content_type' => 1,
'comment' => 4,
diff --git a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php
index 2371bef31aa..5bf9d2477f0 100644
--- a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php
+++ b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php
@@ -73,14 +73,14 @@ class PerformanceTest extends PerformanceTestBase {
$expected = [
'QueryCount' => 4,
- 'CacheGetCount' => 48,
+ 'CacheGetCount' => 47,
'CacheGetCountByBin' => [
'config' => 11,
'data' => 4,
'discovery' => 10,
'bootstrap' => 6,
'dynamic_page_cache' => 1,
- 'render' => 15,
+ 'render' => 14,
'menu' => 1,
],
'CacheSetCount' => 2,
@@ -89,9 +89,9 @@ class PerformanceTest extends PerformanceTestBase {
],
'CacheDeleteCount' => 0,
'CacheTagInvalidationCount' => 0,
- 'CacheTagLookupQueryCount' => 14,
+ 'CacheTagLookupQueryCount' => 13,
'ScriptCount' => 3,
- 'ScriptBytes' => 213500,
+ 'ScriptBytes' => 167569,
'StylesheetCount' => 2,
'StylesheetBytes' => 46000,
];
diff --git a/core/modules/node/js/node.preview.js b/core/modules/node/js/node.preview.js
index 50bc58ade77..e23be0b71e2 100644
--- a/core/modules/node/js/node.preview.js
+++ b/core/modules/node/js/node.preview.js
@@ -34,13 +34,13 @@
const $previewDialog = $(
`<div>${Drupal.theme('nodePreviewModal')}</div>`,
).appendTo('body');
- Drupal.dialog($previewDialog, {
+ const confirmationDialog = Drupal.dialog($previewDialog, {
title: Drupal.t('Leave preview?'),
buttons: [
{
text: Drupal.t('Cancel'),
click() {
- $(this).dialog('close');
+ confirmationDialog.close();
},
},
{
@@ -50,7 +50,8 @@
},
},
],
- }).showModal();
+ });
+ confirmationDialog.showModal();
}
}
diff --git a/core/modules/node/src/Controller/NodeController.php b/core/modules/node/src/Controller/NodeController.php
index e860d0c1d2a..87c9586daee 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;
@@ -200,7 +199,7 @@ class NodeController extends ControllerBase implements ContainerInjectionInterfa
],
];
// @todo Simplify once https://www.drupal.org/node/2334319 lands.
- $this->renderer->addCacheableDependency($column['data'], CacheableMetadata::createFromRenderArray($username));
+ $this->renderer->addCacheableDependency($column['data'], $username);
$row[] = $column;
if ($is_current_revision) {
diff --git a/core/modules/node/src/Hook/NodeHooks.php b/core/modules/node/src/Hook/NodeHooks.php
index d5f84e0359b..8a6b4d887c8 100644
--- a/core/modules/node/src/Hook/NodeHooks.php
+++ b/core/modules/node/src/Hook/NodeHooks.php
@@ -66,4 +66,13 @@ class NodeHooks {
}
}
+ /**
+ * Implements hook_block_alter().
+ */
+ #[Hook('block_alter')]
+ public function blockAlter(&$definitions): void {
+ // Hide the deprecated Syndicate block from the UI.
+ $definitions['node_syndicate_block']['_block_ui_hidden'] = TRUE;
+ }
+
}
diff --git a/core/modules/node/src/Hook/NodeThemeHooks.php b/core/modules/node/src/Hook/NodeThemeHooks.php
index 7ee443c458f..7ed0ef91f5f 100644
--- a/core/modules/node/src/Hook/NodeThemeHooks.php
+++ b/core/modules/node/src/Hook/NodeThemeHooks.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Drupal\node\Hook;
-use Drupal\Core\Hook\Attribute\Preprocess;
+use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for the node module.
@@ -14,7 +14,7 @@ class NodeThemeHooks {
/**
* Implements hook_preprocess_HOOK() for node field templates.
*/
- #[Preprocess('field__node')]
+ #[Hook('preprocess_field__node')]
public function preprocessFieldNode(&$variables): void {
// Set a variable 'is_inline' in cases where inline markup is required,
// without any block elements such as <div>.
diff --git a/core/modules/node/src/NodeAccessControlHandler.php b/core/modules/node/src/NodeAccessControlHandler.php
index 963ab53ded4..7121f62e283 100644
--- a/core/modules/node/src/NodeAccessControlHandler.php
+++ b/core/modules/node/src/NodeAccessControlHandler.php
@@ -223,7 +223,16 @@ class NodeAccessControlHandler extends EntityAccessControlHandler implements Nod
return NULL;
}
+ // When access is granted due to the 'view own unpublished content'
+ // permission and for no other reason, node grants are bypassed. However,
+ // to ensure the full set of cacheable metadata is available to variation
+ // cache, additionally add the node_grants cache context so that if the
+ // status or the owner of the node changes, cache redirects will continue to
+ // reflect the latest state without needing to be invalidated.
$cacheability->addCacheContexts(['user']);
+ if ($this->moduleHandler->hasImplementations('node_grants')) {
+ $cacheability->addCacheContexts(['user.node_grants:view']);
+ }
if ($account->id() != $node->getOwnerId()) {
return NULL;
}
diff --git a/core/modules/node/src/NodePermissions.php b/core/modules/node/src/NodePermissions.php
index e913f5326f3..5f651830192 100644
--- a/core/modules/node/src/NodePermissions.php
+++ b/core/modules/node/src/NodePermissions.php
@@ -2,6 +2,9 @@
namespace Drupal\node;
+use Drupal\Core\DependencyInjection\AutowireTrait;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\BundlePermissionHandlerTrait;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\node\Entity\NodeType;
@@ -9,19 +12,34 @@ use Drupal\node\Entity\NodeType;
/**
* Provides dynamic permissions for nodes of different types.
*/
-class NodePermissions {
+class NodePermissions implements ContainerInjectionInterface {
+
+ use AutowireTrait;
use BundlePermissionHandlerTrait;
use StringTranslationTrait;
+ public function __construct(
+ protected ?EntityTypeManagerInterface $entityTypeManager = NULL,
+ ) {
+ if ($entityTypeManager === NULL) {
+ @trigger_error('Calling ' . __METHOD__ . ' without the $entityTypeManager argument is deprecated in drupal:11.2.0 and it will be required in drupal:12.0.0. See https://www.drupal.org/node/3515921', E_USER_DEPRECATED);
+ $this->entityTypeManager = \Drupal::entityTypeManager();
+ }
+ }
+
/**
* Returns an array of node type permissions.
*
* @return array
* The node type permissions.
- * @see \Drupal\user\PermissionHandlerInterface::getPermissions()
+ *
+ * @see \Drupal\user\PermissionHandlerInterface::getPermissions()
*/
public function nodeTypePermissions() {
- return $this->generatePermissions(NodeType::loadMultiple(), [$this, 'buildPermissions']);
+ return $this->generatePermissions(
+ $this->entityTypeManager->getStorage('node_type')->loadMultiple(),
+ [$this, 'buildPermissions']
+ );
}
/**
diff --git a/core/modules/node/src/Plugin/Block/SyndicateBlock.php b/core/modules/node/src/Plugin/Block/SyndicateBlock.php
index b10c63527e5..45cfe1eb45c 100644
--- a/core/modules/node/src/Plugin/Block/SyndicateBlock.php
+++ b/core/modules/node/src/Plugin/Block/SyndicateBlock.php
@@ -14,6 +14,11 @@ use Drupal\Core\Url;
/**
* Provides a 'Syndicate' block that links to the site's RSS feed.
+ *
+ * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no
+ * replacement.
+ *
+ * @see https://www.drupal.org/node/3519248
*/
#[Block(
id: "node_syndicate_block",
@@ -43,6 +48,7 @@ class SyndicateBlock extends BlockBase implements ContainerFactoryPluginInterfac
* The config factory.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ConfigFactoryInterface $configFactory) {
+ @trigger_error('The Syndicate block is deprecated in drupal:11.2.0 and will be removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3519248', E_USER_DEPRECATED);
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->configFactory = $configFactory;
}
diff --git a/core/modules/node/src/Plugin/views/UidRevisionTrait.php b/core/modules/node/src/Plugin/views/UidRevisionTrait.php
new file mode 100644
index 00000000000..5cbf21d56d4
--- /dev/null
+++ b/core/modules/node/src/Plugin/views/UidRevisionTrait.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\node\Plugin\views;
+
+/**
+ * Checks for nodes that a user posted or created a revision on.
+ */
+trait UidRevisionTrait {
+
+ /**
+ * Checks for nodes that a user posted or created a revision on.
+ *
+ * @param array $uids
+ * A list of user ids.
+ * @param int $group
+ * See \Drupal\views\Plugin\views\query\Sql::addWhereExpression() $group.
+ */
+ public function uidRevisionQuery(array $uids, int $group = 0): void {
+ $this->ensureMyTable();
+
+ // As per https://www.php.net/manual/en/pdo.prepare.php "you cannot use a
+ // named parameter marker of the same name more than once in a prepared
+ // statement".
+ $placeholder_1 = $this->placeholder() . '[]';
+ $placeholder_2 = $this->placeholder() . '[]';
+
+ $args = array_values($uids);
+
+ $this->query->addWhereExpression($group, "$this->tableAlias.uid IN ($placeholder_1) OR
+ EXISTS (SELECT 1 FROM {node_revision} nr WHERE nr.revision_uid IN ($placeholder_2) AND nr.nid = $this->tableAlias.nid)", [
+ $placeholder_1 => $args,
+ $placeholder_2 => $args,
+ ]);
+ }
+
+}
diff --git a/core/modules/node/src/Plugin/views/argument/UidRevision.php b/core/modules/node/src/Plugin/views/argument/UidRevision.php
index 982152080a6..9be0cc9d7b6 100644
--- a/core/modules/node/src/Plugin/views/argument/UidRevision.php
+++ b/core/modules/node/src/Plugin/views/argument/UidRevision.php
@@ -2,6 +2,7 @@
namespace Drupal\node\Plugin\views\argument;
+use Drupal\node\Plugin\views\UidRevisionTrait;
use Drupal\user\Plugin\views\argument\Uid;
use Drupal\views\Attribute\ViewsArgument;
@@ -15,13 +16,13 @@ use Drupal\views\Attribute\ViewsArgument;
)]
class UidRevision extends Uid {
+ use UidRevisionTrait;
+
/**
* {@inheritdoc}
*/
public function query($group_by = FALSE) {
- $this->ensureMyTable();
- $placeholder = $this->placeholder();
- $this->query->addWhereExpression(0, "$this->tableAlias.uid = $placeholder OR ((SELECT COUNT(DISTINCT vid) FROM {node_revision} nr WHERE nr.revision_uid = $placeholder AND nr.nid = $this->tableAlias.nid) > 0)", [$placeholder => $this->argument]);
+ $this->uidRevisionQuery([$this->argument]);
}
}
diff --git a/core/modules/node/src/Plugin/views/filter/UidRevision.php b/core/modules/node/src/Plugin/views/filter/UidRevision.php
index b7f186fa07d..cf962a2897e 100644
--- a/core/modules/node/src/Plugin/views/filter/UidRevision.php
+++ b/core/modules/node/src/Plugin/views/filter/UidRevision.php
@@ -2,6 +2,7 @@
namespace Drupal\node\Plugin\views\filter;
+use Drupal\node\Plugin\views\UidRevisionTrait;
use Drupal\user\Plugin\views\filter\Name;
use Drupal\views\Attribute\ViewsFilter;
@@ -13,19 +14,13 @@ use Drupal\views\Attribute\ViewsFilter;
#[ViewsFilter("node_uid_revision")]
class UidRevision extends Name {
+ use UidRevisionTrait;
+
/**
* {@inheritdoc}
*/
public function query($group_by = FALSE) {
- $this->ensureMyTable();
-
- $placeholder = $this->placeholder() . '[]';
-
- $args = array_values($this->value);
-
- $this->query->addWhereExpression($this->options['group'], "$this->tableAlias.uid IN($placeholder) OR
- ((SELECT COUNT(DISTINCT vid) FROM {node_revision} nr WHERE nr.revision_uid IN ($placeholder) AND nr.nid = $this->tableAlias.nid) > 0)", [$placeholder => $args],
- $args);
+ $this->uidRevisionQuery($this->value, $this->options['group']);
}
}
diff --git a/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php
new file mode 100644
index 00000000000..0d49a7c416c
--- /dev/null
+++ b/core/modules/node/tests/src/Functional/NodeAccessCacheRedirectWarningTest.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\node\Functional;
+
+/**
+ * Tests the node access grants cache context service.
+ *
+ * @group node
+ * @group Cache
+ */
+class NodeAccessCacheRedirectWarningTest extends NodeTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['block', 'node_access_test_empty'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $defaultTheme = 'stark';
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ node_access_rebuild();
+ }
+
+ /**
+ * Ensures that node access checks don't cause cache redirect warnings.
+ *
+ * @covers \Drupal\node\NodeAccessControlHandler
+ */
+ public function testNodeAccessCacheRedirectWarning(): void {
+ $this->drupalPlaceBlock('local_tasks_block');
+
+ // Ensure that both a node_grants implementation exists, and that the
+ // current user has 'view own unpublished nodes' permission. Node's access
+ // control handler bypasses node grants when 'view own published nodes' is
+ // granted and the node is unpublished, which means that the code path is
+ // significantly different when a node is published vs. unpublished, and
+ // that cache contexts vary depend on the state of the node.
+ $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('node_grants'));
+
+ $author = $this->drupalCreateUser([
+ 'create page content',
+ 'edit any page content',
+ 'view own unpublished content',
+ ]);
+ $this->drupalLogin($author);
+
+ $node = $this->drupalCreateNode(['uid' => $author->id(), 'status' => 0]);
+
+ $this->drupalGet($node->toUrl());
+ $this->assertSession()->statusCodeEquals(200);
+ $this->assertSession()->pageTextContains($node->label());
+
+ $node->setPublished();
+ $node->save();
+
+ $this->drupalGet($node->toUrl());
+ $this->assertSession()->statusCodeEquals(200);
+ $this->assertSession()->pageTextContains($node->label());
+
+ // When the node has been viewed in both the unpublished and published state
+ // a cache redirect should exist for the local tasks block. Repeating the
+ // process of changing the node status and viewing the node will test that
+ // no stale redirect is found.
+ $node->setUnpublished();
+ $node->save();
+
+ $this->drupalGet($node->toUrl());
+ $this->assertSession()->statusCodeEquals(200);
+ $this->assertSession()->pageTextContains($node->label());
+
+ $node->setPublished();
+ $node->save();
+
+ $this->drupalGet($node->toUrl());
+ $this->assertSession()->statusCodeEquals(200);
+ $this->assertSession()->pageTextContains($node->label());
+ }
+
+}
diff --git a/core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php b/core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php
new file mode 100644
index 00000000000..5a930df3e2d
--- /dev/null
+++ b/core/modules/node/tests/src/Functional/NodeRevisionsAuthorTest.php
@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\node\Functional;
+
+use Drupal\Core\Url;
+
+/**
+ * Tests reverting node revisions correctly sets authorship information.
+ *
+ * @group node
+ */
+class NodeRevisionsAuthorTest extends NodeTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $defaultTheme = 'stark';
+
+ /**
+ * Tests node authorship is retained after reverting revisions.
+ */
+ public function testNodeRevisionRevertAuthors(): void {
+ // Create and log in user.
+ $initialUser = $this->drupalCreateUser([
+ 'view page revisions',
+ 'revert page revisions',
+ 'edit any page content',
+ ]);
+ $initialRevisionUser = $this->drupalCreateUser();
+ // Third user is an author only and needs no permissions
+ $initialRevisionAuthor = $this->drupalCreateUser();
+
+ // Create initial node (author: $user1).
+ $this->drupalLogin($initialUser);
+ $node = $this->drupalCreateNode();
+ $originalRevisionId = $node->getRevisionId();
+ $originalBody = $node->body->value;
+ $originalTitle = $node->getTitle();
+
+ // Create a revision (as $initialUser) showing $initialRevisionAuthor
+ // as author.
+ $node->setRevisionLogMessage('Changed author');
+ $revisedTitle = $this->randomMachineName();
+ $node->setTitle($revisedTitle);
+ $revisedBody = $this->randomMachineName(32);
+ $node->set('body', [
+ 'value' => $revisedBody,
+ 'format' => filter_default_format(),
+ ]);
+ $node->setOwnerId($initialRevisionAuthor->id());
+ $node->setRevisionUserId($initialRevisionUser->id());
+ $node->setNewRevision();
+ $node->save();
+ $revisedRevisionId = $node->getRevisionId();
+
+ $nodeStorage = \Drupal::entityTypeManager()->getStorage('node');
+
+ self::assertEquals($node->getOwnerId(), $initialRevisionAuthor->id());
+ self::assertEquals($node->getRevisionUserId(), $initialRevisionUser->id());
+
+ // Revert to the original node revision.
+ $this->drupalGet(Url::fromRoute('node.revision_revert_confirm', [
+ 'node' => $node->id(),
+ 'node_revision' => $originalRevisionId,
+ ]));
+ $this->submitForm([], 'Revert');
+ $this->assertSession()->pageTextContains(\sprintf('Basic page %s has been reverted', $originalTitle));
+
+ // With the revert done, reload the node and verify that the authorship
+ // fields have reverted correctly.
+ $nodeStorage->resetCache([$node->id()]);
+ /** @var \Drupal\node\NodeInterface $revertedNode */
+ $revertedNode = $nodeStorage->load($node->id());
+ self::assertEquals($originalBody, $revertedNode->body->value);
+ self::assertEquals($initialUser->id(), $revertedNode->getOwnerId());
+ self::assertEquals($initialUser->id(), $revertedNode->getRevisionUserId());
+
+ // Revert again to the revised version and check that node author and
+ // revision author fields are correct.
+ // Revert to the original node.
+ $this->drupalGet(Url::fromRoute('node.revision_revert_confirm', [
+ 'node' => $revertedNode->id(),
+ 'node_revision' => $revisedRevisionId,
+ ]));
+ $this->submitForm([], 'Revert');
+ $this->assertSession()->pageTextContains(\sprintf('Basic page %s has been reverted', $revisedTitle));
+
+ // With the reversion done, reload the node and verify that the
+ // authorship fields have reverted correctly.
+ $nodeStorage->resetCache([$revertedNode->id()]);
+ /** @var \Drupal\node\NodeInterface $re_reverted_node */
+ $re_reverted_node = $nodeStorage->load($revertedNode->id());
+ self::assertEquals($revisedBody, $re_reverted_node->body->value);
+ self::assertEquals($initialRevisionAuthor->id(), $re_reverted_node->getOwnerId());
+ // The new revision user will be the current logged in user as set in
+ // NodeRevisionRevertForm.
+ self::assertEquals($initialUser->id(), $re_reverted_node->getRevisionUserId());
+ }
+
+}
diff --git a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php
index 201d4b6c7d2..88fe3e34e3e 100644
--- a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php
+++ b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php
@@ -215,20 +215,4 @@ class NodeRevisionsUiTest extends NodeTestBase {
$this->assertSession()->elementsCount('xpath', $xpath, 1);
}
- /**
- * Tests the node revisions page is cacheable by dynamic page cache.
- */
- public function testNodeRevisionsCacheability(): void {
- $this->drupalLogin($this->editor);
- $node = $this->drupalCreateNode();
- // Admin paths are always uncacheable by dynamic page cache, swap node
- // to non admin theme to test cacheability.
- $this->config('node.settings')->set('use_admin_theme', FALSE)->save();
- \Drupal::service('router.builder')->rebuild();
- $this->drupalGet($node->toUrl('version-history'));
- $this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', 'MISS');
- $this->drupalGet($node->toUrl('version-history'));
- $this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', 'HIT');
- }
-
}
diff --git a/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php b/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php
index c3a3d46b496..f8d52b06ecb 100644
--- a/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php
+++ b/core/modules/node/tests/src/Functional/NodeSyndicateBlockTest.php
@@ -8,6 +8,7 @@ namespace Drupal\Tests\node\Functional;
* Tests if the syndicate block is available.
*
* @group node
+ * @group legacy
*/
class NodeSyndicateBlockTest extends NodeTestBase {
@@ -40,6 +41,7 @@ class NodeSyndicateBlockTest extends NodeTestBase {
$this->drupalPlaceBlock('node_syndicate_block', ['id' => 'test_syndicate_block', 'label' => 'Subscribe to RSS Feed']);
$this->drupalGet('');
$this->assertSession()->elementExists('xpath', '//div[@id="block-test-syndicate-block"]/*');
+ $this->expectDeprecation('The Syndicate block is deprecated in drupal:11.2.0 and will be removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3519248');
// Verify syndicate block title.
$this->assertSession()->pageTextContains('Subscribe to RSS Feed');
diff --git a/core/modules/node/tests/src/Functional/NodeTranslationUITest.php b/core/modules/node/tests/src/Functional/NodeTranslationUITest.php
index 2bb252f7c6e..ac1e8664bad 100644
--- a/core/modules/node/tests/src/Functional/NodeTranslationUITest.php
+++ b/core/modules/node/tests/src/Functional/NodeTranslationUITest.php
@@ -242,21 +242,19 @@ class NodeTranslationUITest extends ContentTranslationUITestBase {
// Set up the default admin theme and use it for node editing.
$this->container->get('theme_installer')->install(['claro']);
- $edit = [];
- $edit['admin_theme'] = 'claro';
- $edit['use_admin_theme'] = TRUE;
- $this->drupalGet('admin/appearance');
- $this->submitForm($edit, 'Save configuration');
- $this->drupalGet('node/' . $article->id() . '/translations');
+ $this->config('system.theme')->set('admin', 'claro')->save();
+
// Verify that translation uses the admin theme if edit is admin.
+ $this->drupalGet('node/' . $article->id() . '/translations');
$this->assertSession()->responseContains('core/themes/claro/css/base/elements.css');
// Turn off admin theme for editing, assert inheritance to translations.
- $edit['use_admin_theme'] = FALSE;
- $this->drupalGet('admin/appearance');
- $this->submitForm($edit, 'Save configuration');
- $this->drupalGet('node/' . $article->id() . '/translations');
+ $this->config('node.settings')->set('use_admin_theme', FALSE)->save();
+ // Changing node.settings:use_admin_theme requires a route rebuild.
+ $this->container->get('router.builder')->rebuild();
+
// Verify that translation uses the frontend theme if edit is frontend.
+ $this->drupalGet('node/' . $article->id() . '/translations');
$this->assertSession()->responseNotContains('core/themes/claro/css/base/elements.css');
// Assert presence of translation page itself (vs. DisabledBundle below).
@@ -561,12 +559,10 @@ class NodeTranslationUITest extends ContentTranslationUITestBase {
'translatable' => TRUE,
])->save();
- $this->drupalLogin($this->administrator);
// Make the image field a multi-value field in order to display a
// details form element.
- $edit = ['field_storage[subform][cardinality_number]' => 2];
- $this->drupalGet('admin/structure/types/manage/article/fields/node.article.field_image');
- $this->submitForm($edit, 'Save');
+ $fieldStorage = FieldStorageConfig::loadByName('node', 'field_image');
+ $fieldStorage->setCardinality(2)->save();
// Enable the display of the image field.
EntityFormDisplay::load('node.article.default')
diff --git a/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php b/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php
index cbe9b346623..ac47588d5ec 100644
--- a/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php
+++ b/core/modules/node/tests/src/Kernel/Migrate/d7/MigrateNodeCompleteTest.php
@@ -18,6 +18,7 @@ use Drupal\Tests\migrate_drupal\Traits\NodeMigrateTypeTestTrait;
* Test class for a complete node migration for Drupal 7.
*
* @group migrate_drupal_7
+ * @group #slow
*/
class MigrateNodeCompleteTest extends MigrateDrupal7TestBase {
diff --git a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php
index 80f92c2d286..28dc50ef60f 100644
--- a/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php
+++ b/core/modules/options/tests/src/FunctionalJavascript/OptionsFieldUIAllowedValuesTest.php
@@ -321,13 +321,7 @@ JS;
],
'List string' => [
'list_string',
- ['first' => 'First', 'second' => 'Second', 'third' => 'Third'],
- TRUE,
- ],
- // Example with empty key and label values like string '0'.
- 'List string with 0 value' => [
- 'list_string',
- ['0' => '0', '1' => '1', '2' => '2'],
+ ['0' => '0', '1' => '1', 'two' => 'two'],
TRUE,
],
];
diff --git a/core/modules/package_manager/package_manager.api.php b/core/modules/package_manager/package_manager.api.php
index 216737e1573..9fa34742ef9 100644
--- a/core/modules/package_manager/package_manager.api.php
+++ b/core/modules/package_manager/package_manager.api.php
@@ -95,6 +95,8 @@
* for event subscribers to flag errors before the active directory is
* modified, because once that has happened, the changes cannot be undone.
* This event may be dispatched multiple times during the stage life cycle.
+ * Note that this event is NOT dispatched when the sandbox manager is
+ * operating in direct-write mode.
*
* - \Drupal\package_manager\Event\PostApplyEvent
* Dispatched after changes in the stage directory have been copied to the
@@ -109,6 +111,11 @@
* life cycle, and should *never* be used for schema changes (i.e., operations
* that should happen in `hook_update_N()` or a post-update function).
*
+ * Since the apply events are not dispatched in direct-write mode, event
+ * subscribers that want to prevent a sandbox from moving through its life cycle
+ * in direct-write mode should do it by subscribing to PreCreateEvent or
+ * StatusCheckEvent.
+ *
* @section sec_stage_api Stage API: Public methods
* The public API of any stage consists of the following methods:
*
diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml
index 54c8fb846e0..d7bbaf94820 100644
--- a/core/modules/package_manager/package_manager.services.yml
+++ b/core/modules/package_manager/package_manager.services.yml
@@ -47,6 +47,7 @@ services:
Drupal\package_manager\EventSubscriber\ChangeLogger:
calls:
- [setLogger, ['@logger.channel.package_manager_change_log']]
+ Drupal\package_manager\EventSubscriber\DirectWriteSubscriber: {}
Drupal\package_manager\ComposerInspector: {}
# Validators.
@@ -201,3 +202,9 @@ services:
PhpTuf\ComposerStager\Internal\Translation\Service\SymfonyTranslatorProxyInterface:
class: PhpTuf\ComposerStager\Internal\Translation\Service\SymfonyTranslatorProxy
public: false
+
+ Drupal\package_manager\DirectWritePreconditionBypass:
+ decorates: 'PhpTuf\ComposerStager\API\Precondition\Service\ActiveAndStagingDirsAreDifferentInterface'
+ arguments:
+ - '@.inner'
+ public: false
diff --git a/core/modules/package_manager/src/Attribute/AllowDirectWrite.php b/core/modules/package_manager/src/Attribute/AllowDirectWrite.php
new file mode 100644
index 00000000000..d41de1a87e4
--- /dev/null
+++ b/core/modules/package_manager/src/Attribute/AllowDirectWrite.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\package_manager\Attribute;
+
+/**
+ * Identifies sandbox managers which can operate on the running code base.
+ *
+ * Package Manager normally creates and operates on a fully separate, sandboxed
+ * copy of the site. This is pretty safe, but not always necessary for certain
+ * kinds of operations (e.g., adding a new module to the site).
+ * SandboxManagerBase subclasses with this attribute are allowed to skip the
+ * sandboxing and operate directly on the live site, but ONLY if the
+ * `package_manager_allow_direct_write` setting is set to TRUE.
+ *
+ * @see \Drupal\package_manager\SandboxManagerBase::isDirectWrite()
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final class AllowDirectWrite {
+}
diff --git a/core/modules/package_manager/src/ComposerInspector.php b/core/modules/package_manager/src/ComposerInspector.php
index 69d30738850..32bde1002ea 100644
--- a/core/modules/package_manager/src/ComposerInspector.php
+++ b/core/modules/package_manager/src/ComposerInspector.php
@@ -54,7 +54,7 @@ class ComposerInspector implements LoggerAwareInterface {
*
* @var string
*/
- final public const SUPPORTED_VERSION = '^2.6';
+ final public const SUPPORTED_VERSION = '^2.7';
public function __construct(
private readonly ComposerProcessRunnerInterface $runner,
diff --git a/core/modules/package_manager/src/DirectWritePreconditionBypass.php b/core/modules/package_manager/src/DirectWritePreconditionBypass.php
new file mode 100644
index 00000000000..ba456d270d7
--- /dev/null
+++ b/core/modules/package_manager/src/DirectWritePreconditionBypass.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use PhpTuf\ComposerStager\API\Path\Value\PathInterface;
+use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
+use PhpTuf\ComposerStager\API\Precondition\Service\ActiveAndStagingDirsAreDifferentInterface;
+use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface;
+use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface;
+
+/**
+ * Allows certain Composer Stager preconditions to be bypassed.
+ *
+ * Only certain preconditions can be bypassed; this class implements all of
+ * those interfaces, and only accepts them in its constructor.
+ *
+ * @internal
+ * This is an internal part of Package Manager and may be changed or removed
+ * at any time without warning. External code should not interact with this
+ * class.
+ */
+final class DirectWritePreconditionBypass implements ActiveAndStagingDirsAreDifferentInterface {
+
+ use StringTranslationTrait;
+
+ /**
+ * Whether or not the decorated precondition is being bypassed.
+ *
+ * @var bool
+ */
+ private static bool $isBypassed = FALSE;
+
+ public function __construct(
+ private readonly ActiveAndStagingDirsAreDifferentInterface $decorated,
+ ) {}
+
+ /**
+ * Bypasses the decorated precondition.
+ */
+ public static function activate(): void {
+ static::$isBypassed = TRUE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getName(): TranslatableInterface {
+ return $this->decorated->getName();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription(): TranslatableInterface {
+ return $this->decorated->getDescription();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getStatusMessage(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): TranslatableInterface {
+ if (static::$isBypassed) {
+ return new TranslatableStringAdapter('This precondition has been skipped because it is not needed in direct-write mode.');
+ }
+ return $this->decorated->getStatusMessage($activeDir, $stagingDir, $exclusions, $timeout);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): bool {
+ if (static::$isBypassed) {
+ return TRUE;
+ }
+ return $this->decorated->isFulfilled($activeDir, $stagingDir, $exclusions, $timeout);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function assertIsFulfilled(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void {
+ if (static::$isBypassed) {
+ return;
+ }
+ $this->decorated->assertIsFulfilled($activeDir, $stagingDir, $exclusions, $timeout);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getLeaves(): array {
+ return [$this];
+ }
+
+}
diff --git a/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php b/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php
index 703dbf4603b..c8c19324c87 100644
--- a/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php
+++ b/core/modules/package_manager/src/EventSubscriber/ChangeLogger.php
@@ -85,15 +85,21 @@ final class ChangeLogger implements EventSubscriberInterface, LoggerAwareInterfa
$event->getDevPackages(),
);
$event->sandboxManager->setMetadata(static::REQUESTED_PACKAGES_KEY, $requested_packages);
+
+ // If we're in direct-write mode, the changes have already been made, so
+ // we should log them right away.
+ if ($event->sandboxManager->isDirectWrite()) {
+ $this->logChanges($event);
+ }
}
/**
* Logs changes made by Package Manager.
*
- * @param \Drupal\package_manager\Event\PostApplyEvent $event
+ * @param \Drupal\package_manager\Event\PostApplyEvent|\Drupal\package_manager\Event\PostRequireEvent $event
* The event being handled.
*/
- public function logChanges(PostApplyEvent $event): void {
+ public function logChanges(PostApplyEvent|PostRequireEvent $event): void {
$installed_at_start = $event->sandboxManager->getMetadata(static::INSTALLED_PACKAGES_KEY);
$installed_post_apply = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot());
diff --git a/core/modules/package_manager/src/EventSubscriber/DirectWriteSubscriber.php b/core/modules/package_manager/src/EventSubscriber/DirectWriteSubscriber.php
new file mode 100644
index 00000000000..7785a9168a3
--- /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\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 Drupal\system\SystemManager;
+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(SystemManager::REQUIREMENT_ERROR);
+
+ 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/SandboxManagerBase.php b/core/modules/package_manager/src/SandboxManagerBase.php
index 4b3c6065432..15836def8f8 100644
--- a/core/modules/package_manager/src/SandboxManagerBase.php
+++ b/core/modules/package_manager/src/SandboxManagerBase.php
@@ -8,11 +8,13 @@ use Composer\Semver\VersionParser;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\Random;
use Drupal\Core\Queue\QueueFactory;
+use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TempStore\SharedTempStore;
use Drupal\Core\TempStore\SharedTempStoreFactory;
use Drupal\Core\Utility\Error;
+use Drupal\package_manager\Attribute\AllowDirectWrite;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\Event\PostApplyEvent;
use Drupal\package_manager\Event\PostCreateEvent;
@@ -147,9 +149,9 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
*
* Consists of a unique random string and the current class name.
*
- * @var string[]
+ * @var string[]|null
*/
- private $lock;
+ private ?array $lock = NULL;
/**
* The shared temp store.
@@ -338,6 +340,7 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
$id,
static::class,
$this->getType(),
+ $this->isDirectWrite(),
]);
$this->claim($id);
@@ -351,7 +354,12 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
$this->dispatch($event, [$this, 'markAsAvailable']);
try {
- $this->beginner->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout);
+ if ($this->isDirectWrite()) {
+ $this->logger?->info($this->t('Direct-write is enabled. Skipping sandboxing.'));
+ }
+ else {
+ $this->beginner->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout);
+ }
}
catch (\Throwable $error) {
$this->destroy();
@@ -372,7 +380,10 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
}
/**
- * Adds or updates packages in the stage directory.
+ * Adds or updates packages in the sandbox directory.
+ *
+ * If this sandbox manager is running in direct-write mode, the changes will
+ * be made in the active directory.
*
* @param string[] $runtime
* The packages to add as regular top-level dependencies, in the form
@@ -430,8 +441,18 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
// If constraints were changed, update those packages.
if ($runtime || $dev) {
- $command = array_merge(['update', '--with-all-dependencies', '--optimize-autoloader'], $runtime, $dev);
- $do_stage($command);
+ $do_stage([
+ 'update',
+ // Allow updating top-level dependencies.
+ '--with-all-dependencies',
+ // Always optimize the autoloader for better site performance.
+ '--optimize-autoloader',
+ // For extra safety and speed, make Composer do only the necessary
+ // changes to transitive (indirect) dependencies.
+ '--minimal-changes',
+ ...$runtime,
+ ...$dev,
+ ]);
}
$this->dispatch(new PostRequireEvent($this, $runtime, $dev));
}
@@ -458,6 +479,13 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
* a failed commit operation.
*/
public function apply(?int $timeout = 600): void {
+ // In direct-write mode, changes are made directly to the running code base,
+ // so there is nothing to do.
+ if ($this->isDirectWrite()) {
+ $this->logger?->info($this->t('Direct-write is enabled. Changes have been made to the running code base.'));
+ return;
+ }
+
$this->checkOwnership();
$active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
@@ -556,7 +584,7 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
// If the stage directory exists, queue it to be automatically cleaned up
// later by a queue (which may or may not happen during cron).
// @see \Drupal\package_manager\Plugin\QueueWorker\Cleaner
- if ($this->sandboxDirectoryExists()) {
+ if ($this->sandboxDirectoryExists() && !$this->isDirectWrite()) {
$this->queueFactory->get('package_manager_cleanup')
->createItem($this->getSandboxDirectory());
}
@@ -659,8 +687,14 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
)->render());
}
- if ($stored_lock === [$unique_id, static::class, $this->getType()]) {
+ if (array_slice($stored_lock, 0, 3) === [$unique_id, static::class, $this->getType()]) {
$this->lock = $stored_lock;
+
+ if ($this->isDirectWrite()) {
+ // Bypass a hard-coded set of Composer Stager preconditions that prevent
+ // the active directory from being modified directly.
+ DirectWritePreconditionBypass::activate();
+ }
return $this;
}
@@ -717,7 +751,9 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
* Returns the path of the directory where changes should be staged.
*
* @return string
- * The absolute path of the directory where changes should be staged.
+ * The absolute path of the directory where changes should be staged. If
+ * this sandbox manager is operating in direct-write mode, this will be
+ * path of the active directory.
*
* @throws \LogicException
* If this method is called before the stage has been created or claimed.
@@ -726,6 +762,10 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
if (!$this->lock) {
throw new \LogicException(__METHOD__ . '() cannot be called because the stage has not been created or claimed.');
}
+
+ if ($this->isDirectWrite()) {
+ return $this->pathLocator->getProjectRoot();
+ }
return $this->getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0];
}
@@ -848,4 +888,26 @@ abstract class SandboxManagerBase implements LoggerAwareInterface {
$this->tempStore->set(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $id, $message);
}
+ /**
+ * Indicates whether the active directory will be changed directly.
+ *
+ * This can only happen if direct-write is globally enabled by the
+ * `package_manager_allow_direct_write` setting, AND this class explicitly
+ * allows it (by adding the AllowDirectWrite attribute).
+ *
+ * @return bool
+ * TRUE if the sandbox manager is operating in direct-write mode, otherwise
+ * FALSE.
+ */
+ final public function isDirectWrite(): bool {
+ // The use of direct-write is stored as part of the lock so that it will
+ // remain consistent during the sandbox's entire life cycle, even if the
+ // underlying global settings are changed.
+ if ($this->lock) {
+ return $this->lock[3];
+ }
+ $reflector = new \ReflectionClass($this);
+ return Settings::get('package_manager_allow_direct_write', FALSE) && $reflector->getAttributes(AllowDirectWrite::class);
+ }
+
}
diff --git a/core/modules/package_manager/src/Validator/LockFileValidator.php b/core/modules/package_manager/src/Validator/LockFileValidator.php
index ead8740ba84..c63b283b238 100644
--- a/core/modules/package_manager/src/Validator/LockFileValidator.php
+++ b/core/modules/package_manager/src/Validator/LockFileValidator.php
@@ -111,6 +111,12 @@ final class LockFileValidator implements EventSubscriberInterface {
public function validate(SandboxValidationEvent $event): void {
$sandbox_manager = $event->sandboxManager;
+ // If we're going to change the active directory directly, we don't need to
+ // validate the lock file's consistency, since there is no separate
+ // sandbox directory to compare against.
+ if ($sandbox_manager->isDirectWrite()) {
+ return;
+ }
// Early return if the stage is not already created.
if ($event instanceof StatusCheckEvent && $sandbox_manager->isAvailable()) {
return;
diff --git a/core/modules/package_manager/src/Validator/RsyncValidator.php b/core/modules/package_manager/src/Validator/RsyncValidator.php
index 37fe6eb76a5..eeb3f3a8b56 100644
--- a/core/modules/package_manager/src/Validator/RsyncValidator.php
+++ b/core/modules/package_manager/src/Validator/RsyncValidator.php
@@ -38,6 +38,12 @@ final class RsyncValidator implements EventSubscriberInterface {
* The event being handled.
*/
public function validate(SandboxValidationEvent $event): void {
+ // If the we are going to change the active directory directly, we don't
+ // need rsync.
+ if ($event->sandboxManager->isDirectWrite()) {
+ return;
+ }
+
try {
$this->executableFinder->find('rsync');
$rsync_found = TRUE;
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
index be088454061..b7920aba169 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;
@@ -142,6 +143,7 @@ class ApiController extends ControllerBase {
*
* @see \Drupal\package_manager\SandboxManagerBase::claim()
*/
+#[AllowDirectWrite]
final class ControllerSandboxManager extends SandboxManagerBase {
/**
diff --git a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php
index ec53f485dfb..bea2c0d4024 100644
--- a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php
+++ b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php
@@ -15,9 +15,14 @@ class PackageInstallTest extends TemplateProjectTestBase {
/**
* Tests installing packages in a stage directory.
+ *
+ * @testWith [true]
+ * [false]
*/
- public function testPackageInstall(): void {
+ public function testPackageInstall(bool $allow_direct_write): void {
$this->createTestProject('RecommendedProject');
+ $allow_direct_write = var_export($allow_direct_write, TRUE);
+ $this->writeSettings("\n\$settings['package_manager_allow_direct_write'] = $allow_direct_write;");
$this->setReleaseMetadata([
'alpha' => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml',
diff --git a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
index 16cd486ad75..7e0cdb46e4a 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.
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php
index 0411978a175..61f922824bd 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php
@@ -230,7 +230,7 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase {
* ["2.5.0", "<default>"]
* ["2.5.5", "<default>"]
* ["2.5.11", "<default>"]
- * ["2.6.0", null]
+ * ["2.7.0", null]
* ["2.2.11", "<default>"]
* ["2.2.0-dev", "<default>"]
* ["2.3.6", "<default>"]
diff --git a/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php b/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php
new file mode 100644
index 00000000000..3208fddbbf4
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/DirectWriteTest.php
@@ -0,0 +1,234 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use ColinODell\PsrTestLogger\TestLogger;
+use Drupal\Core\Queue\QueueFactory;
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\PostRequireEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreRequireEvent;
+use Drupal\package_manager\Event\SandboxEvent;
+use Drupal\package_manager\Exception\SandboxEventException;
+use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\StatusCheckTrait;
+use Drupal\package_manager\ValidationResult;
+use PhpTuf\ComposerStager\API\Core\BeginnerInterface;
+use PhpTuf\ComposerStager\API\Core\CommitterInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * @covers \Drupal\package_manager\EventSubscriber\DirectWriteSubscriber
+ * @covers \Drupal\package_manager\SandboxManagerBase::isDirectWrite
+ *
+ * @group package_manager
+ */
+class DirectWriteTest extends PackageManagerKernelTestBase implements EventSubscriberInterface {
+
+ use StatusCheckTrait;
+ use StringTranslationTrait;
+
+ /**
+ * Whether we are in maintenance mode before a require operation.
+ *
+ * @var bool|null
+ *
+ * @see ::onPreRequire()
+ */
+ private ?bool $preRequireMaintenanceMode = NULL;
+
+ /**
+ * Whether we are in maintenance mode after a require operation.
+ *
+ * @var bool|null
+ *
+ * @see ::onPostRequire()
+ */
+ private ?bool $postRequireMaintenanceMode = NULL;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getSubscribedEvents(): array {
+ return [
+ // The pre-require and post-require listeners need to run after
+ // \Drupal\package_manager\EventSubscriber\DirectWriteSubscriber.
+ PreRequireEvent::class => ['onPreRequire', -10001],
+ PostRequireEvent::class => ['onPostRequire', 9999],
+ PreApplyEvent::class => 'assertNotDirectWrite',
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->container->get(EventDispatcherInterface::class)
+ ->addSubscriber($this);
+ }
+
+ /**
+ * Event listener that asserts the sandbox manager isn't in direct-write mode.
+ *
+ * @param \Drupal\package_manager\Event\SandboxEvent $event
+ * The event being handled.
+ */
+ public function assertNotDirectWrite(SandboxEvent $event): void {
+ $this->assertFalse($event->sandboxManager->isDirectWrite());
+ }
+
+ /**
+ * Event listener that records the maintenance mode flag on pre-require.
+ */
+ public function onPreRequire(): void {
+ $this->preRequireMaintenanceMode = (bool) $this->container->get(StateInterface::class)
+ ->get('system.maintenance_mode');
+ }
+
+ /**
+ * Event listener that records the maintenance mode flag on post-require.
+ */
+ public function onPostRequire(): void {
+ $this->postRequireMaintenanceMode = (bool) $this->container->get(StateInterface::class)
+ ->get('system.maintenance_mode');
+ }
+
+ /**
+ * Tests that direct-write does not work if it is globally disabled.
+ */
+ public function testSiteSandboxedIfDirectWriteGloballyDisabled(): void {
+ // Even if we use a sandbox manager that supports direct write, it should
+ // not be enabled.
+ $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class);
+ $logger = new TestLogger();
+ $sandbox_manager->setLogger($logger);
+ $this->assertFalse($sandbox_manager->isDirectWrite());
+ $sandbox_manager->create();
+ $this->assertTrue($sandbox_manager->sandboxDirectoryExists());
+ $this->assertNotSame(
+ $this->container->get(PathLocator::class)->getProjectRoot(),
+ $sandbox_manager->getSandboxDirectory(),
+ );
+ $this->assertFalse($logger->hasRecords('info'));
+ }
+
+ /**
+ * Tests direct-write mode when globally enabled.
+ */
+ public function testSiteNotSandboxedIfDirectWriteGloballyEnabled(): void {
+ $mock_beginner = $this->createMock(BeginnerInterface::class);
+ $mock_beginner->expects($this->never())
+ ->method('begin')
+ ->withAnyParameters();
+ $this->container->set(BeginnerInterface::class, $mock_beginner);
+
+ $mock_committer = $this->createMock(CommitterInterface::class);
+ $mock_committer->expects($this->never())
+ ->method('commit')
+ ->withAnyParameters();
+ $this->container->set(CommitterInterface::class, $mock_committer);
+
+ $this->setSetting('package_manager_allow_direct_write', TRUE);
+
+ $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class);
+ $logger = new TestLogger();
+ $sandbox_manager->setLogger($logger);
+ $this->assertTrue($sandbox_manager->isDirectWrite());
+
+ // A status check should flag a warning about running in direct-write mode.
+ $expected_results = [
+ ValidationResult::createWarning([
+ $this->t('Direct-write mode is enabled, which means that changes will be made without sandboxing them first. This can be risky and is not recommended for production environments. For safety, your site will be put into maintenance mode while dependencies are updated.'),
+ ]),
+ ];
+ $actual_results = $this->runStatusCheck($sandbox_manager);
+ $this->assertValidationResultsEqual($expected_results, $actual_results);
+
+ $sandbox_manager->create();
+ // In direct-write mode, the active and sandbox directories are the same.
+ $this->assertTrue($sandbox_manager->sandboxDirectoryExists());
+ $this->assertSame(
+ $this->container->get(PathLocator::class)->getProjectRoot(),
+ $sandbox_manager->getSandboxDirectory(),
+ );
+
+ // Do a require operation so we can assert that we are kicked into, and out
+ // of, maintenance mode.
+ $sandbox_manager->require(['ext-json:*']);
+ $this->assertTrue($this->preRequireMaintenanceMode);
+ $this->assertFalse($this->postRequireMaintenanceMode);
+
+ $sandbox_manager->apply();
+ $sandbox_manager->postApply();
+ // Destroying the sandbox should not populate the clean-up queue.
+ $sandbox_manager->destroy();
+ /** @var \Drupal\Core\Queue\QueueInterface $queue */
+ $queue = $this->container->get(QueueFactory::class)
+ ->get('package_manager_cleanup');
+ $this->assertSame(0, $queue->numberOfItems());
+
+ $records = $logger->recordsByLevel['info'];
+ $this->assertCount(2, $records);
+ $this->assertSame('Direct-write is enabled. Skipping sandboxing.', (string) $records[0]['message']);
+ $this->assertSame('Direct-write is enabled. Changes have been made to the running code base.', (string) $records[1]['message']);
+
+ // A sandbox manager that doesn't support direct-write should not be
+ // influenced by the setting.
+ $this->assertFalse($this->createStage()->isDirectWrite());
+ }
+
+ /**
+ * Tests that pre-require errors prevent maintenance mode during direct-write.
+ */
+ public function testMaintenanceModeNotEnteredIfErrorOnPreRequire(): void {
+ $this->setSetting('package_manager_allow_direct_write', TRUE);
+ // Sanity check: we shouldn't be in maintenance mode to begin with.
+ $state = $this->container->get(StateInterface::class);
+ $this->assertEmpty($state->get('system.maintenance_mode'));
+
+ // Set up an event subscriber which will flag an error.
+ $this->container->get(EventDispatcherInterface::class)
+ ->addListener(PreRequireEvent::class, function (PreRequireEvent $event): void {
+ $event->addError([
+ $this->t('Maintenance mode should not happen.'),
+ ]);
+ });
+
+ $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class);
+ $sandbox_manager->create();
+ try {
+ $sandbox_manager->require(['ext-json:*']);
+ $this->fail('Expected an exception to be thrown on pre-require.');
+ }
+ catch (SandboxEventException $e) {
+ $this->assertSame("Maintenance mode should not happen.\n", $e->getMessage());
+ // We should never have entered maintenance mode.
+ $this->assertFalse($this->preRequireMaintenanceMode);
+ // Sanity check: the post-require event should never have been dispatched.
+ $this->assertNull($this->postRequireMaintenanceMode);
+ }
+ }
+
+ /**
+ * Tests that the sandbox's direct-write status is part of its locking info.
+ */
+ public function testDirectWriteFlagIsLocked(): void {
+ $this->setSetting('package_manager_allow_direct_write', TRUE);
+ $sandbox_manager = $this->createStage(TestDirectWriteSandboxManager::class);
+ $this->assertTrue($sandbox_manager->isDirectWrite());
+ $sandbox_manager->create();
+ $this->setSetting('package_manager_allow_direct_write', FALSE);
+ $this->assertTrue($sandbox_manager->isDirectWrite());
+ // Only once the sandbox is destroyed should the sandbox manager reflect the
+ // changed setting.
+ $sandbox_manager->destroy();
+ $this->assertFalse($sandbox_manager->isDirectWrite());
+ }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
index 3c2e32b1e7c..5bcc43a8138 100644
--- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
+++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
@@ -12,6 +12,7 @@ use Drupal\Core\Queue\QueueFactory;
use Drupal\Core\Site\Settings;
use Drupal\fixture_manipulator\StageFixtureManipulator;
use Drupal\KernelTests\KernelTestBase;
+use Drupal\package_manager\Attribute\AllowDirectWrite;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\SandboxValidationEvent;
use Drupal\package_manager\Exception\SandboxEventException;
@@ -173,11 +174,15 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase {
/**
* Creates a stage object for testing purposes.
*
+ * @param class-string $class
+ * (optional) The class of the sandbox manager to create. Defaults to
+ * \Drupal\Tests\package_manager\Kernel\TestSandboxManager.
+ *
* @return \Drupal\Tests\package_manager\Kernel\TestSandboxManager
* A stage object, with test-only modifications.
*/
- protected function createStage(): TestSandboxManager {
- return new TestSandboxManager(
+ protected function createStage(?string $class = TestSandboxManager::class): TestSandboxManager {
+ return new $class(
$this->container->get(PathLocator::class),
$this->container->get(BeginnerInterface::class),
$this->container->get(StagerInterface::class),
@@ -476,6 +481,19 @@ class TestSandboxManager extends SandboxManagerBase {
}
/**
+ * Defines a test-only sandbox manager that allows direct-write.
+ */
+#[AllowDirectWrite]
+class TestDirectWriteSandboxManager extends TestSandboxManager {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected string $type = 'package_manager:test_direct_write';
+
+}
+
+/**
* A test version of the disk space validator to bypass system-level functions.
*/
class TestDiskSpaceValidator extends DiskSpaceValidator {
diff --git a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php
index 188c654929d..02be8f298aa 100644
--- a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php
@@ -76,4 +76,13 @@ class RsyncValidatorTest extends PackageManagerKernelTestBase {
$this->assertResults([$result], PreCreateEvent::class);
}
+ /**
+ * Tests that the presence of rsync is not checked in direct-write mode.
+ */
+ public function testRsyncNotNeededForDirectWrite(): void {
+ $this->executableFinder->find('rsync')->shouldNotBeCalled();
+ $this->setSetting('package_manager_allow_direct_write', TRUE);
+ $this->createStage(TestDirectWriteSandboxManager::class)->create();
+ }
+
}
diff --git a/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php b/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php
index 41f2e8b8e4f..da6d22bfb05 100644
--- a/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php
+++ b/core/modules/page_cache/tests/src/Functional/PageCacheTagsIntegrationTest.php
@@ -155,7 +155,6 @@ class PageCacheTagsIntegrationTest extends BrowserTestBase {
'config:block.block.olivero_messages',
'config:block.block.olivero_primary_local_tasks',
'config:block.block.olivero_secondary_local_tasks',
- 'config:block.block.olivero_syndicate',
'config:block.block.olivero_primary_admin_actions',
'config:block.block.olivero_page_title',
'node_view',
@@ -195,7 +194,6 @@ class PageCacheTagsIntegrationTest extends BrowserTestBase {
'config:block.block.olivero_messages',
'config:block.block.olivero_primary_local_tasks',
'config:block.block.olivero_secondary_local_tasks',
- 'config:block.block.olivero_syndicate',
'config:block.block.olivero_primary_admin_actions',
'config:block.block.olivero_page_title',
'node_view',
diff --git a/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php b/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php
index 5d862a86421..5c5e6be5838 100644
--- a/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php
+++ b/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php
@@ -328,7 +328,7 @@ class ResponsiveImageFieldDisplayTest extends ImageFieldTestBase {
if (!$empty_styles) {
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:image.style.medium');
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:image.style.thumbnail');
- $this->assertSession()->responseContains('type="image/webp"');
+ $this->assertSession()->responseContains('type="image/avif"');
}
$this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:image.style.large');
@@ -504,7 +504,7 @@ class ResponsiveImageFieldDisplayTest extends ImageFieldTestBase {
// Assert the picture tag has source tags that include dimensions.
$this->drupalGet('node/' . $nid);
- $this->assertSession()->responseMatches('/<picture>\s+<source srcset="' . \preg_quote($large_transform_url, '/') . ' 1x" media="\(min-width: 851px\)" type="image\/webp" width="480" height="480"\/>\s+<source srcset="' . \preg_quote($medium_transform_url, '/') . ' 1x, ' . \preg_quote($large_transform_url, '/') . ' 1.5x, ' . \preg_quote($large_transform_url, '/') . ' 2x" type="image\/webp" width="220" height="220"\/>\s+<img loading="eager" width="480" height="480" src="' . \preg_quote($large_transform_url, '/') . '" alt="\w+" \/>\s+<\/picture>/');
+ $this->assertSession()->responseMatches('/<picture>\s+<source srcset="' . \preg_quote($large_transform_url, '/') . ' 1x" media="\(min-width: 851px\)" type="image\/avif" width="480" height="480"\/>\s+<source srcset="' . \preg_quote($medium_transform_url, '/') . ' 1x, ' . \preg_quote($large_transform_url, '/') . ' 1.5x, ' . \preg_quote($large_transform_url, '/') . ' 2x" type="image\/avif" width="220" height="220"\/>\s+<img loading="eager" width="480" height="480" src="' . \preg_quote($large_transform_url, '/') . '" alt="\w+" \/>\s+<\/picture>/');
}
/**
diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php
index 8158de67c50..46bea0731d0 100644
--- a/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php
+++ b/core/modules/sqlite/src/Driver/Database/sqlite/Connection.php
@@ -415,7 +415,7 @@ class Connection extends DatabaseConnection implements SupportsTemporaryTablesIn
public function prepareStatement(string $query, array $options, bool $allow_row_count = FALSE): StatementInterface {
assert(!isset($options['return']), 'Passing "return" option to prepareStatement() has no effect. See https://www.drupal.org/node/3185520');
if (isset($options['fetch']) && is_int($options['fetch'])) {
- @trigger_error("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED);
+ @trigger_error("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED);
}
try {
diff --git a/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php b/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php
index 1c7378a0173..c3060a57234 100644
--- a/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php
+++ b/core/modules/sqlite/src/Driver/Database/sqlite/Statement.php
@@ -87,7 +87,7 @@ class Statement extends StatementPrefetchIterator implements StatementInterface
*/
public function execute($args = [], $options = []) {
if (isset($options['fetch']) && is_int($options['fetch'])) {
- @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED);
+ @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED);
}
try {
diff --git a/core/modules/system/css/components/position-container.module.css b/core/modules/system/css/components/position-container.module.css
deleted file mode 100644
index ae209f3aa61..00000000000
--- a/core/modules/system/css/components/position-container.module.css
+++ /dev/null
@@ -1,8 +0,0 @@
-/*
- * @file
- * Contain positioned elements.
- */
-
-.position-container {
- position: relative;
-}
diff --git a/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php b/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php
index 670fbc06cf7..ccd9725e821 100644
--- a/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php
+++ b/core/modules/system/src/Plugin/ImageToolkit/GDToolkit.php
@@ -439,9 +439,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 = [];
@@ -556,7 +553,7 @@ class GDToolkit extends ImageToolkitBase {
* @return bool
* TRUE if AVIF is fully supported, FALSE otherwise.
*/
- protected function checkAvifSupport(): bool {
+ protected static function checkAvifSupport(): bool {
static $supported = NULL;
if ($supported !== NULL) {
@@ -578,13 +575,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/system.libraries.yml b/core/modules/system/system.libraries.yml
index 03baf83d3bb..af0eeea05d2 100644
--- a/core/modules/system/system.libraries.yml
+++ b/core/modules/system/system.libraries.yml
@@ -9,7 +9,6 @@ base:
css/components/hidden.module.css: { weight: -10 }
css/components/item-list.module.css: { weight: -10 }
css/components/js.module.css: { weight: -10 }
- css/components/position-container.module.css: { weight: -10 }
css/components/reset-appearance.module.css: { weight: -10 }
admin:
diff --git a/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz b/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz
index 5d8c9974469..077d0645ddc 100644
--- a/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz
+++ b/core/modules/system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz
Binary files differ
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
index 423f49a1d40..5db0b3a5aae 100644
--- a/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz
+++ b/core/modules/system/tests/fixtures/update/drupal-10.3.0.filled.standard.php.gz
Binary files differ
diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php
index 542c4e162e2..78328f9f8e4 100644
--- a/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php
+++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestClickedButtonForm.php
@@ -35,32 +35,28 @@ class FormTestClickedButtonForm extends FormBase {
'#type' => 'textfield',
];
+ // Get button configurations, filter out NULL values.
+ $args = array_filter([$first, $second, $third]);
+
+ // Define button types for each argument.
+ $button_types = [
+ 's' => 'submit',
+ 'i' => 'image_button',
+ 'b' => 'button',
+ ];
+
// Loop through each path argument, adding buttons based on the information
// in the argument. For example, if the path is
// form-test/clicked-button/s/i/rb, then 3 buttons are added: a 'submit', an
// 'image_button', and a 'button' with #access=FALSE. This enables form.test
// to test a variety of combinations.
- $i = 0;
- $args = [$first, $second, $third];
- foreach ($args as $arg) {
- $name = 'button' . ++$i;
- // 's', 'b', or 'i' in the argument define the button type wanted.
- if (!is_string($arg)) {
- $type = NULL;
- }
- elseif (str_contains($arg, 's')) {
- $type = 'submit';
- }
- elseif (str_contains($arg, 'b')) {
- $type = 'button';
- }
- elseif (str_contains($arg, 'i')) {
- $type = 'image_button';
- }
- else {
- $type = NULL;
- }
- if (isset($type)) {
+ foreach ($args as $index => $arg) {
+ // Get the button type based on the index of the argument.
+ $type = $button_types[$arg] ?? NULL;
+ $name = 'button' . ($index + 1);
+
+ if ($type) {
+ // Define the button.
$form[$name] = [
'#type' => $type,
'#name' => $name,
diff --git a/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php b/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php
index 908d0d8d454..09dbf982cf7 100644
--- a/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php
+++ b/core/modules/system/tests/modules/image_test/src/Plugin/ImageToolkit/TestToolkit.php
@@ -253,7 +253,11 @@ class TestToolkit extends ImageToolkitBase {
* IMAGETYPE_* constant (e.g. IMAGETYPE_JPEG, IMAGETYPE_PNG, etc.).
*/
protected static function supportedTypes() {
- return [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF];
+ $types = [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_GIF];
+ if (\Drupal::keyValue('image_test')->get('avif_enabled', FALSE)) {
+ $types[] = IMAGETYPE_AVIF;
+ }
+ return $types;
}
/**
diff --git a/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php b/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php
index 1cbb9e6b422..db923382a21 100644
--- a/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php
+++ b/core/modules/system/tests/modules/module_test_oop_preprocess/src/Hook/ModuleTestOopPreprocessThemeHooks.php
@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace Drupal\module_test_oop_preprocess\Hook;
-use Drupal\Core\Hook\Attribute\Preprocess;
+use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for module_test_oop_preprocess.
*/
class ModuleTestOopPreprocessThemeHooks {
- #[Preprocess]
+ #[Hook('preprocess')]
public function rootPreprocess($arg): mixed {
return $arg;
}
- #[Preprocess('test')]
+ #[Hook('preprocess_test')]
public function preprocessTest($arg): mixed {
return $arg;
}
diff --git a/core/modules/system/tests/modules/session_test/session_test.routing.yml b/core/modules/system/tests/modules/session_test/session_test.routing.yml
index fe85de11032..f11bd86b4d7 100644
--- a/core/modules/system/tests/modules/session_test/session_test.routing.yml
+++ b/core/modules/system/tests/modules/session_test/session_test.routing.yml
@@ -179,3 +179,25 @@ session_test.trigger_write_exception:
no_cache: TRUE
requirements:
_access: 'TRUE'
+
+session_test.legacy_get:
+ path: '/session-test/legacy-get'
+ defaults:
+ _title: 'Legacy session value'
+ _controller: '\Drupal\session_test\Controller\LegacySessionTestController::get'
+ options:
+ no_cache: TRUE
+ requirements:
+ _access: 'TRUE'
+
+session_test.legacy_set:
+ path: '/session-test/legacy-set/{test_value}'
+ defaults:
+ _title: 'Set legacy session value'
+ _controller: '\Drupal\session_test\Controller\LegacySessionTestController::set'
+ options:
+ no_cache: TRUE
+ converters:
+ test_value: '\s+'
+ requirements:
+ _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php b/core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php
new file mode 100644
index 00000000000..a1438a0108e
--- /dev/null
+++ b/core/modules/system/tests/modules/session_test/src/Controller/LegacySessionTestController.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\session_test\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+
+/**
+ * Controller providing page callbacks for legacy session tests.
+ */
+class LegacySessionTestController extends ControllerBase {
+
+ /**
+ * Prints the stored session value to the screen.
+ */
+ public function get(): array {
+ return empty($_SESSION['legacy_test_value'])
+ ? []
+ : ['#markup' => $this->t('The current value of the stored session variable is: %val', ['%val' => $_SESSION['legacy_test_value']])];
+ }
+
+ /**
+ * Stores a value in $_SESSION['legacy_test_value'].
+ *
+ * @param string $test_value
+ * A session value.
+ */
+ public function set(string $test_value): array {
+ $_SESSION['legacy_test_value'] = $test_value;
+
+ return ['#markup' => $this->t('The current value of the stored session variable has been set to %val', ['%val' => $test_value])];
+ }
+
+}
diff --git a/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php b/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php
index 9c7bb97e24b..461581abaa7 100644
--- a/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php
+++ b/core/modules/system/tests/modules/session_test/src/Controller/SessionTestController.php
@@ -11,20 +11,21 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
- * Controller providing page callbacks for the action admin interface.
+ * Controller providing page callbacks for session tests.
*/
class SessionTestController extends ControllerBase {
/**
* Prints the stored session value to the screen.
*
- * @return string
- * A notification message.
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The incoming request.
*/
- public function get() {
- return empty($_SESSION['session_test_value'])
+ public function get(Request $request): array {
+ $value = $request->getSession()->get('session_test_value');
+ return empty($value)
? []
- : ['#markup' => $this->t('The current value of the stored session variable is: %val', ['%val' => $_SESSION['session_test_value']])];
+ : ['#markup' => $this->t('The current value of the stored session variable is: %val', ['%val' => $value])];
}
/**
@@ -32,11 +33,8 @@ class SessionTestController extends ControllerBase {
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The incoming request.
- *
- * @return string
- * A notification message.
*/
- public function getFromSessionObject(Request $request) {
+ public function getFromSessionObject(Request $request): array {
$value = $request->getSession()->get("session_test_key");
return empty($value)
? []
@@ -48,16 +46,13 @@ class SessionTestController extends ControllerBase {
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The incoming request.
- *
- * @return string
- * A notification message with session ID.
*/
- public function getId(Request $request) {
- // Set a value in $_SESSION, so that SessionManager::save() will start
+ public function getId(Request $request): array {
+ // Set a value in session, so that SessionManager::save() will start
// a session.
- $_SESSION['test'] = 'test';
-
- $request->getSession()->save();
+ $session = $request->getSession();
+ $session->set('test', 'test');
+ $session->save();
return ['#markup' => 'session_id:' . session_id() . "\n"];
}
@@ -67,11 +62,8 @@ class SessionTestController extends ControllerBase {
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
- *
- * @return string
- * A notification message with session ID.
*/
- public function getIdFromCookie(Request $request) {
+ public function getIdFromCookie(Request $request): array {
return [
'#markup' => 'session_id:' . $request->cookies->get(session_name()) . "\n",
'#cache' => ['contexts' => ['cookies:' . session_name()]],
@@ -79,16 +71,15 @@ class SessionTestController extends ControllerBase {
}
/**
- * Stores a value in $_SESSION['session_test_value'].
+ * Stores a value in 'session_test_value' session attribute.
*
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request object.
* @param string $test_value
* A session value.
- *
- * @return string
- * A notification message.
*/
- public function set($test_value) {
- $_SESSION['session_test_value'] = $test_value;
+ public function set(Request $request, $test_value): array {
+ $request->getSession()->set('session_test_value', $test_value);
return ['#markup' => $this->t('The current value of the stored session variable has been set to %val', ['%val' => $test_value])];
}
@@ -96,25 +87,21 @@ class SessionTestController extends ControllerBase {
/**
* Turns off session saving and then tries to save a value anyway.
*
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request object.
* @param string $test_value
* A session value.
- *
- * @return string
- * A notification message.
*/
- public function noSet($test_value) {
+ public function noSet(Request $request, $test_value): array {
\Drupal::service('session_handler.write_safe')->setSessionWritable(FALSE);
- $this->set($test_value);
+ $this->set($request, $test_value);
return ['#markup' => $this->t('session saving was disabled, and then %val was set', ['%val' => $test_value])];
}
/**
* Sets a message to me displayed on the following page.
- *
- * @return string
- * A notification message.
*/
- public function setMessage() {
+ public function setMessage(): Response {
$this->messenger()->addStatus($this->t('This is a dummy message.'));
return new Response((string) $this->t('A message was set.'));
// Do not return anything, so the current request does not result in a
@@ -124,11 +111,8 @@ class SessionTestController extends ControllerBase {
/**
* Sets a message but call drupal_save_session(FALSE).
- *
- * @return string
- * A notification message.
*/
- public function setMessageButDoNotSave() {
+ public function setMessageButDoNotSave(): array {
\Drupal::service('session_handler.write_safe')->setSessionWritable(FALSE);
$this->setMessage();
return ['#markup' => ''];
@@ -136,11 +120,8 @@ class SessionTestController extends ControllerBase {
/**
* Only available if current user is logged in.
- *
- * @return string
- * A notification message.
*/
- public function isLoggedIn() {
+ public function isLoggedIn(): array {
return ['#markup' => $this->t('User is logged in.')];
}
@@ -149,20 +130,13 @@ class SessionTestController extends ControllerBase {
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The incoming request.
- *
- * @return \Symfony\Component\HttpFoundation\JsonResponse
- * The response.
*/
- public function traceHandler(Request $request) {
- // Start a session if necessary, set a value and then save and close it.
- $request->getSession()->start();
- if (empty($_SESSION['trace-handler'])) {
- $_SESSION['trace-handler'] = 1;
- }
- else {
- $_SESSION['trace-handler']++;
- }
- $request->getSession()->save();
+ public function traceHandler(Request $request): Response {
+ // Increment trace-handler counter and save the session.
+ $session = $request->getSession();
+ $counter = $session->get('trace-handler', 0);
+ $session->set('trace-handler', $counter + 1);
+ $session->save();
// Collect traces and return them in JSON format.
$trace = \Drupal::service('session_test.session_handler_proxy_trace')->getArrayCopy();
@@ -182,15 +156,13 @@ class SessionTestController extends ControllerBase {
* @param \Symfony\Component\HttpFoundation\Request $request
* The incoming request.
*
- * @return \Symfony\Component\HttpFoundation\JsonResponse
- * The response.
- *
* @throws \AssertionError
*/
- public function traceHandlerRewriteUnmodified(Request $request) {
+ public function traceHandlerRewriteUnmodified(Request $request): Response {
// Assert that there is an existing session with stacked handler trace data.
+ $session = $request->getSession();
assert(
- is_int($_SESSION['trace-handler']) && $_SESSION['trace-handler'] > 0,
+ is_int($session->get('trace-handler')) && $session->get('trace-handler') > 0,
'Existing stacked session handler trace not found'
);
@@ -199,7 +171,7 @@ class SessionTestController extends ControllerBase {
ini_get('session.lazy_write'),
'session.lazy_write must be enabled to invoke updateTimestamp()'
);
- $request->getSession()->save();
+ $session->save();
// Collect traces and return them in JSON format.
$trace = \Drupal::service('session_test.session_handler_proxy_trace')->getArrayCopy();
@@ -212,11 +184,8 @@ class SessionTestController extends ControllerBase {
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
- *
- * @return \Symfony\Component\HttpFoundation\JsonResponse
- * A response object containing the session values and the user ID.
*/
- public function getSession(Request $request) {
+ public function getSession(Request $request): Response {
return new JsonResponse(['session' => $request->getSession()->all(), 'user' => $this->currentUser()->id()]);
}
@@ -227,11 +196,8 @@ class SessionTestController extends ControllerBase {
* The request object.
* @param string $test_value
* A value to set on the session.
- *
- * @return \Symfony\Component\HttpFoundation\JsonResponse
- * A response object containing the session values and the user ID.
*/
- public function setSession(Request $request, $test_value) {
+ public function setSession(Request $request, $test_value): Response {
$session = $request->getSession();
$session->set('test_value', $test_value);
return new JsonResponse(['session' => $session->all(), 'user' => $this->currentUser()->id()]);
@@ -242,11 +208,8 @@ class SessionTestController extends ControllerBase {
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
- *
- * @return \Symfony\Component\HttpFoundation\Response
- * The response object.
*/
- public function setSessionBagFlag(Request $request) {
+ public function setSessionBagFlag(Request $request): Response {
/** @var \Drupal\session_test\Session\TestSessionBag */
$bag = $request->getSession()->getBag(TestSessionBag::BAG_NAME);
$bag->setFlag();
@@ -258,11 +221,8 @@ class SessionTestController extends ControllerBase {
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
- *
- * @return \Symfony\Component\HttpFoundation\Response
- * The response object.
*/
- public function clearSessionBagFlag(Request $request) {
+ public function clearSessionBagFlag(Request $request): Response {
/** @var \Drupal\session_test\Session\TestSessionBag */
$bag = $request->getSession()->getBag(TestSessionBag::BAG_NAME);
$bag->clearFlag();
@@ -274,11 +234,8 @@ class SessionTestController extends ControllerBase {
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
- *
- * @return \Symfony\Component\HttpFoundation\Response
- * The response object.
*/
- public function hasSessionBagFlag(Request $request) {
+ public function hasSessionBagFlag(Request $request): Response {
/** @var \Drupal\session_test\Session\TestSessionBag */
$bag = $request->getSession()->getBag(TestSessionBag::BAG_NAME);
return new Response(empty($bag->hasFlag())
@@ -293,7 +250,7 @@ class SessionTestController extends ControllerBase {
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*/
- public function triggerWriteException(Request $request) {
+ public function triggerWriteException(Request $request): Response {
$session = $request->getSession();
$session->set('test_value', 'Ensure session contains some data');
diff --git a/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php b/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php
index fc48756de51..7bfc10ef0ef 100644
--- a/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php
+++ b/core/modules/system/tests/modules/theme_test/src/Hook/ThemeTestThemeHooks.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Drupal\theme_test\Hook;
-use Drupal\Core\Hook\Attribute\Preprocess;
+use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for theme_test.
@@ -14,7 +14,7 @@ class ThemeTestThemeHooks {
/**
* Implements hook_preprocess_HOOK().
*/
- #[Preprocess('theme_test_preprocess_suggestions__monkey')]
+ #[Hook('preprocess_theme_test_preprocess_suggestions__monkey')]
public function preprocessTestSuggestions(&$variables): void {
$variables['foo'] = 'Monkey';
}
diff --git a/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php b/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php
index 272ad65eff3..f5d0c150118 100644
--- a/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php
+++ b/core/modules/system/tests/modules/twig_loader_test/src/Loader/TestLoader.php
@@ -24,7 +24,7 @@ class TestLoader implements LoaderInterface {
/**
* {@inheritdoc}
*/
- public function exists(string $name) {
+ public function exists(string $name): bool {
return TRUE;
}
diff --git a/core/modules/system/tests/src/Functional/Form/FormTest.php b/core/modules/system/tests/src/Functional/Form/FormTest.php
index 6812903dccc..f45e45e6159 100644
--- a/core/modules/system/tests/src/Functional/Form/FormTest.php
+++ b/core/modules/system/tests/src/Functional/Form/FormTest.php
@@ -6,7 +6,6 @@ namespace Drupal\Tests\system\Functional\Form;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Html;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Form\FormState;
use Drupal\Core\Render\Element;
use Drupal\Core\Url;
@@ -199,7 +198,7 @@ class FormTest extends BrowserTestBase {
$expected_key = array_search($error->getText(), $expected);
// If the error message is not one of the expected messages, fail.
if ($expected_key === FALSE) {
- $this->fail(new FormattableMarkup("Unexpected error message: @error", ['@error' => $error[0]]));
+ $this->fail("Unexpected error message: " . $error[0]);
}
// Remove the expected message from the list once it is found.
else {
@@ -209,7 +208,7 @@ class FormTest extends BrowserTestBase {
// Fail if any expected messages were not found.
foreach ($expected as $not_found) {
- $this->fail(new FormattableMarkup("Found error message: @error", ['@error' => $not_found]));
+ $this->fail("Found error message: " . $not_found);
}
// Verify that input elements are still empty.
@@ -610,14 +609,6 @@ class FormTest extends BrowserTestBase {
public function testNumber(): void {
$form = \Drupal::formBuilder()->getForm('\Drupal\form_test\Form\FormTestNumberForm');
- // Array with all the error messages to be checked.
- $error_messages = [
- 'no_number' => '%name must be a number.',
- 'too_low' => '%name must be higher than or equal to %min.',
- 'too_high' => '%name must be lower than or equal to %max.',
- 'step_mismatch' => '%name is not a valid number.',
- ];
-
// The expected errors.
$expected = [
'integer_no_number' => 'no_number',
@@ -648,21 +639,26 @@ class FormTest extends BrowserTestBase {
$this->submitForm([], 'Submit');
foreach ($expected as $element => $error) {
- // Create placeholder array.
- $placeholders = [
- '%name' => $form[$element]['#title'],
- '%min' => $form[$element]['#min'] ?? '0',
- '%max' => $form[$element]['#max'] ?? '0',
+ // Array with all the error messages to be checked.
+ $name = $form[$element]['#title'];
+ $min = $form[$element]['#min'] ?? '0';
+ $max = $form[$element]['#max'] ?? '0';
+
+ $error_messages = [
+ 'no_number' => "<em class=\"placeholder\">$name</em> must be a number.",
+ 'too_low' => "<em class=\"placeholder\">$name</em> must be higher than or equal to <em class=\"placeholder\">$min</em>.",
+ 'too_high' => "<em class=\"placeholder\">$name</em> must be lower than or equal to <em class=\"placeholder\">$max</em>.",
+ 'step_mismatch' => "<em class=\"placeholder\">$name</em> is not a valid number.",
];
foreach ($error_messages as $id => $message) {
// Check if the error exists on the page, if the current message ID is
// expected. Otherwise ensure that the error message is not present.
if ($id === $error) {
- $this->assertSession()->responseContains(new FormattableMarkup($message, $placeholders));
+ $this->assertSession()->responseContains($message);
}
else {
- $this->assertSession()->responseNotContains(new FormattableMarkup($message, $placeholders));
+ $this->assertSession()->responseNotContains($message);
}
}
}
diff --git a/core/modules/system/tests/src/Functional/Session/LegacySessionTest.php b/core/modules/system/tests/src/Functional/Session/LegacySessionTest.php
new file mode 100644
index 00000000000..84ab1ed9d5b
--- /dev/null
+++ b/core/modules/system/tests/src/Functional/Session/LegacySessionTest.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\system\Functional\Session;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Drupal legacy session handling tests.
+ *
+ * @group legacy
+ * @group Session
+ */
+class LegacySessionTest extends BrowserTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['session_test'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected $defaultTheme = 'stark';
+
+ /**
+ * Tests data persistence via the session_test module callbacks.
+ */
+ public function testLegacyDataPersistence(): void {
+ $this->expectDeprecation('Storing values directly in $_SESSION is deprecated in drupal:11.2.0 and will become unsupported in drupal:12.0.0. Use $request-&gt;getSession()-&gt;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/taxonomy/tests/src/Functional/VocabularyUiTest.php b/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php
index 7747682a42e..b751f6b52ba 100644
--- a/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php
+++ b/core/modules/taxonomy/tests/src/Functional/VocabularyUiTest.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Drupal\Tests\taxonomy\Functional;
-use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Component\Utility\Html;
use Drupal\Core\Url;
use Drupal\taxonomy\Entity\Vocabulary;
@@ -101,9 +101,10 @@ class VocabularyUiTest extends TaxonomyTestBase {
$link->click();
// Confirm deletion.
- $this->assertSession()->responseContains(new FormattableMarkup('Are you sure you want to delete the vocabulary %name?', ['%name' => $edit['name']]));
+ $name = Html::escape($edit['name']);
+ $this->assertSession()->responseContains("Are you sure you want to delete the vocabulary <em class=\"placeholder\">$name</em>?");
$this->submitForm([], 'Delete');
- $this->assertSession()->responseContains(new FormattableMarkup('Deleted vocabulary %name.', ['%name' => $edit['name']]));
+ $this->assertSession()->responseContains("Deleted vocabulary <em class=\"placeholder\">$name</em>.");
$this->container->get('entity_type.manager')->getStorage('taxonomy_vocabulary')->resetCache();
$this->assertNull(Vocabulary::load($edit['vid']), 'Vocabulary not found.');
}
diff --git a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php
index 8d9465f61a3..7fcb764eac3 100644
--- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php
+++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d6/MigrateTermNodeTranslationTest.php
@@ -11,6 +11,7 @@ use Drupal\node\Entity\Node;
* Upgrade taxonomy term node associations.
*
* @group migrate_drupal_6
+ * @group #slow
*/
class MigrateTermNodeTranslationTest extends MigrateDrupal6TestBase {
diff --git a/core/modules/toolbar/js/escapeAdmin.js b/core/modules/toolbar/js/escapeAdmin.js
index 2d76991e9dc..f7956befe23 100644
--- a/core/modules/toolbar/js/escapeAdmin.js
+++ b/core/modules/toolbar/js/escapeAdmin.js
@@ -14,7 +14,7 @@
// loaded within an existing "workflow".
if (
!pathInfo.currentPathIsAdmin &&
- !/destination=/.test(windowLocation.search)
+ !windowLocation.search.includes('destination=')
) {
sessionStorage.setItem('escapeAdminPath', windowLocation);
}
diff --git a/core/modules/toolbar/js/views/ToolbarVisualView.js b/core/modules/toolbar/js/views/ToolbarVisualView.js
index 89f472f0eaf..00bd236973f 100644
--- a/core/modules/toolbar/js/views/ToolbarVisualView.js
+++ b/core/modules/toolbar/js/views/ToolbarVisualView.js
@@ -210,7 +210,7 @@
// Deactivate the previous tab.
$(this.model.previous('activeTab'))
.removeClass('is-active')
- .prop('aria-pressed', false);
+ .attr('aria-pressed', false);
// Deactivate the previous tray.
$(this.model.previous('activeTray')).removeClass('is-active');
@@ -222,7 +222,7 @@
$tab
.addClass('is-active')
// Mark the tab as pressed.
- .prop('aria-pressed', true);
+ .attr('aria-pressed', true);
const name = $tab.attr('data-toolbar-tray');
// Store the active tab name or remove the setting.
const id = $tab.get(0).id;
diff --git a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php
index c315f9f6ebb..dcf0ff6d79c 100644
--- a/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php
+++ b/core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Drupal\Tests\toolbar\FunctionalJavascript;
+use Behat\Mink\Element\NodeElement;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
/**
@@ -43,12 +44,22 @@ class ToolbarIntegrationTest extends WebDriverTestBase {
$page = $this->getSession()->getPage();
// Test that it is possible to toggle the toolbar tray.
- $content = $page->findLink('Content');
- $this->assertTrue($content->isVisible(), 'Toolbar tray is open by default.');
- $page->clickLink('Manage');
- $this->assertFalse($content->isVisible(), 'Toolbar tray is closed after clicking the "Manage" link.');
- $page->clickLink('Manage');
- $this->assertTrue($content->isVisible(), 'Toolbar tray is visible again after clicking the "Manage" button a second time.');
+ $content_link = $page->findLink('Content');
+ $manage_link = $page->find('css', '#toolbar-item-administration');
+
+ // Start with open tray.
+ $this->waitAndAssertAriaPressedState($manage_link, TRUE);
+ $this->assertTrue($content_link->isVisible(), 'Toolbar tray is open by default.');
+
+ // Click to close.
+ $manage_link->click();
+ $this->waitAndAssertAriaPressedState($manage_link, FALSE);
+ $this->assertFalse($content_link->isVisible(), 'Toolbar tray is closed after clicking the "Manage" link.');
+
+ // Click to open.
+ $manage_link->click();
+ $this->waitAndAssertAriaPressedState($manage_link, TRUE);
+ $this->assertTrue($content_link->isVisible(), 'Toolbar tray is visible again after clicking the "Manage" button a second time.');
// Test toggling the toolbar tray between horizontal and vertical.
$tray = $page->findById('toolbar-item-administration-tray');
@@ -87,4 +98,33 @@ class ToolbarIntegrationTest extends WebDriverTestBase {
$this->assertFalse($button->isVisible(), 'Orientation toggle from other tray is not visible');
}
+ /**
+ * Asserts that an element's `aria-pressed` attribute matches expected state.
+ *
+ * Uses `waitFor()` to pause until either the condition is met or the timeout
+ * of `1` second has passed.
+ *
+ * @param \Behat\Mink\Element\NodeElement $element
+ * The element to be tested.
+ * @param bool $expected
+ * The expected value of `aria-pressed`, as a boolean.
+ *
+ * @throws ExpectationFailedException
+ */
+ private function waitAndAssertAriaPressedState(NodeElement $element, bool $expected): void {
+ $this->assertTrue(
+ $this
+ ->getSession()
+ ->getPage()
+ ->waitFor(1, function () use ($element, $expected): bool {
+ // Get boolean representation of `aria-pressed`.
+ // TRUE if `aria-pressed="true"`, FALSE otherwise.
+ $actual = $element->getAttribute('aria-pressed') == 'true';
+
+ // Exit `waitFor()` when $actual == $expected.
+ return $actual == $expected;
+ })
+ );
+ }
+
}
diff --git a/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js b/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js
index cbba417abe3..0bed815f330 100644
--- a/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js
+++ b/core/modules/toolbar/tests/src/Nightwatch/Tests/toolbarTest.js
@@ -13,27 +13,10 @@ const userOrientationBtn = `${itemUserTray} .toolbar-toggle-orientation button`;
module.exports = {
'@tags': ['core'],
before(browser) {
- browser
- .drupalInstall()
- .drupalInstallModule('toolbar', true)
- .drupalCreateUser({
- name: 'user',
- password: '123',
- permissions: [
- 'access site reports',
- 'access toolbar',
- 'access administration pages',
- 'administer menu',
- 'administer modules',
- 'administer site configuration',
- 'administer account settings',
- 'administer software updates',
- 'access content',
- 'administer permissions',
- 'administer users',
- ],
- })
- .drupalLogin({ name: 'user', password: '123' });
+ browser.drupalInstall({
+ setupFile:
+ 'core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php',
+ });
},
beforeEach(browser) {
// Set the resolution to the default desktop resolution. Ensure the default
@@ -189,7 +172,7 @@ module.exports = {
browser.drupalRelativeURL('/admin');
// Don't check the visibility as stark doesn't add the .path-admin class
// to the <body> required to display the button.
- browser.assert.attributeContains(escapeSelector, 'href', '/user/2');
+ browser.assert.attributeContains(escapeSelector, 'href', '/user/login');
},
'Aural view test: tray orientation': (browser) => {
browser.waitForElementPresent(
diff --git a/core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php b/core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php
new file mode 100644
index 00000000000..47dd0e6e50a
--- /dev/null
+++ b/core/modules/toolbar/tests/src/Nightwatch/ToolbarTestSetup.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\toolbar\Nightwatch;
+
+use Drupal\Core\Extension\ModuleInstallerInterface;
+use Drupal\TestSite\TestSetupInterface;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+
+/**
+ * Sets up the site for testing the toolbar module.
+ */
+class ToolbarTestSetup implements TestSetupInterface {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setup(): void {
+ $module_installer = \Drupal::service('module_installer');
+ assert($module_installer instanceof ModuleInstallerInterface);
+ $module_installer->install(['toolbar']);
+
+ $role = Role::load(RoleInterface::ANONYMOUS_ID);
+ foreach ([
+ 'access toolbar',
+ 'access administration pages',
+ 'administer modules',
+ 'administer site configuration',
+ 'administer account settings',
+ ] as $permission) {
+ $role->grantPermission($permission);
+ }
+ $role->save();
+ }
+
+}
diff --git a/core/modules/views/js/ajax_view.js b/core/modules/views/js/ajax_view.js
index dd3da9b8350..8e646697d83 100644
--- a/core/modules/views/js/ajax_view.js
+++ b/core/modules/views/js/ajax_view.js
@@ -83,7 +83,7 @@
if (queryString !== '') {
// If there is a '?' in ajaxPath, clean URL are on and & should be
// used to add parameters.
- queryString = (/\?/.test(ajaxPath) ? '&' : '?') + queryString;
+ queryString = (ajaxPath.includes('?') ? '&' : '?') + queryString;
}
}
diff --git a/core/modules/views/src/Entity/View.php b/core/modules/views/src/Entity/View.php
index cd1b2a0a42e..f6bb32cec87 100644
--- a/core/modules/views/src/Entity/View.php
+++ b/core/modules/views/src/Entity/View.php
@@ -481,7 +481,7 @@ class View extends ConfigEntityBase implements ViewEntityInterface {
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
- $changed = FALSE;
+ $changed = parent::onDependencyRemoval($dependencies);
// Don't intervene if the views module is removed.
if (isset($dependencies['module']) && in_array('views', $dependencies['module'])) {
diff --git a/core/modules/views/src/Form/ViewsExposedForm.php b/core/modules/views/src/Form/ViewsExposedForm.php
index d68b1dd5363..9f90160ff55 100644
--- a/core/modules/views/src/Form/ViewsExposedForm.php
+++ b/core/modules/views/src/Form/ViewsExposedForm.php
@@ -196,7 +196,6 @@ class ViewsExposedForm extends FormBase implements WorkspaceSafeFormInterface {
$view->exposed_data = $values;
$view->exposed_raw_input = [];
- $exclude = ['submit', 'form_build_id', 'form_id', 'form_token', 'exposed_form_plugin', 'reset'];
/** @var \Drupal\views\Plugin\views\exposed_form\ExposedFormPluginBase $exposed_form_plugin */
$exposed_form_plugin = $view->display_handler->getPlugin('exposed_form');
$exposed_form_plugin->exposedFormSubmit($form, $form_state, $exclude);
diff --git a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php
index fc4a983f929..d3adc61de5a 100644
--- a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php
+++ b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php
@@ -2117,13 +2117,18 @@ abstract class DisplayPluginBase extends PluginBase implements DisplayPluginInte
$hasMoreRecords = !empty($this->view->pager) && $this->view->pager->hasMoreRecords();
if ($this->isMoreEnabled() && ($this->useMoreAlways() || $hasMoreRecords)) {
$url = $this->getMoreUrl();
+ $access = $url->access(return_as_object: TRUE);
- return [
+ $more_link = [
'#type' => 'more_link',
'#url' => $url,
'#title' => $this->useMoreText(),
'#view' => $this->view,
+ '#access' => $access->isAllowed(),
];
+ $accessCacheability = CacheableMetadata::createFromObject($access);
+ $accessCacheability->applyTo($more_link);
+ return $more_link;
}
}
diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml
new file mode 100644
index 00000000000..eb59548f17f
--- /dev/null
+++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_third_party_uninstall.yml
@@ -0,0 +1,37 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - node
+ - views_third_party_settings_test
+third_party_settings:
+ views_third_party_settings_test:
+ example_setting: true
+id: test_third_party_uninstall
+label: test_third_party_uninstall
+module: views
+description: ''
+tag: ''
+base_table: node_field_data
+base_field: nid
+display:
+ default:
+ display_options:
+ access:
+ type: none
+ cache:
+ type: tag
+ exposed_form:
+ type: basic
+ pager:
+ type: full
+ query:
+ type: views_query
+ style:
+ type: default
+ row:
+ type: fields
+ display_plugin: default
+ display_title: Defaults
+ id: default
+ position: 0
diff --git a/core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml b/core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml
new file mode 100644
index 00000000000..0bdeeed705a
--- /dev/null
+++ b/core/modules/views/tests/modules/views_third_party_settings_test/config/schema/views_third_party_settings_test.schema.yml
@@ -0,0 +1,7 @@
+views.view.*.third_party.views_third_party_settings_test:
+ type: config_entity
+ label: "Example settings"
+ mapping:
+ example_setting:
+ type: boolean
+ label: "Example setting"
diff --git a/core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml b/core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml
new file mode 100644
index 00000000000..be975279565
--- /dev/null
+++ b/core/modules/views/tests/modules/views_third_party_settings_test/views_third_party_settings_test.info.yml
@@ -0,0 +1,8 @@
+name: 'Third Party Settings Test'
+type: module
+description: 'A dummy module that third party settings tests can depend on'
+package: Testing
+version: VERSION
+dependencies:
+ - drupal:node
+ - drupal:views
diff --git a/core/modules/views/tests/src/Functional/GlossaryTest.php b/core/modules/views/tests/src/Functional/GlossaryTest.php
index 292f9176771..25c08d5f159 100644
--- a/core/modules/views/tests/src/Functional/GlossaryTest.php
+++ b/core/modules/views/tests/src/Functional/GlossaryTest.php
@@ -83,7 +83,6 @@ class GlossaryTest extends ViewTestBase {
'url',
'user.node_grants:view',
'user.permissions',
- 'route',
],
[
'config:views.view.glossary',
diff --git a/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php b/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php
index 8af887d1ef1..5aecbea3e36 100644
--- a/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php
+++ b/core/modules/views/tests/src/Functional/Plugin/DisplayTest.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Drupal\Tests\views\Functional\Plugin;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Views;
@@ -317,6 +316,14 @@ class DisplayTest extends ViewTestBase {
$output = $view->preview();
$output = (string) $renderer->renderRoot($output);
$this->assertStringContainsString('/node?date=22&amp;foo=bar#22', $output, 'The read more link with href "/node?date=22&foo=bar#22" was found.');
+
+ // Test more link isn't rendered if user doesn't have permission to the
+ // more link URL.
+ $view->display_handler->setOption('link_url', 'admin/content');
+ $this->executeView($view);
+ $output = $view->preview();
+ $output = (string) $renderer->renderRoot($output);
+ $this->assertStringNotContainsString('/admin/content', $output, 'The read more link with href "/admin/content" was not found.');
}
/**
@@ -389,8 +396,8 @@ class DisplayTest extends ViewTestBase {
$errors = $view->validate();
// Check that the error messages are shown.
$this->assertCount(2, $errors['default'], 'Error messages found for required relationship');
- $this->assertEquals(new FormattableMarkup('The %relationship_name relationship used in %handler_type %handler is not present in the %display_name display.', ['%relationship_name' => 'uid', '%handler_type' => 'field', '%handler' => 'User: Last login', '%display_name' => 'Default']), $errors['default'][0]);
- $this->assertEquals(new FormattableMarkup('The %relationship_name relationship used in %handler_type %handler is not present in the %display_name display.', ['%relationship_name' => 'uid', '%handler_type' => 'field', '%handler' => 'User: Created', '%display_name' => 'Default']), $errors['default'][1]);
+ $this->assertEquals("The uid relationship used in field User: Last login is not present in the Default display.", $errors['default'][0]);
+ $this->assertEquals("The uid relationship used in field User: Created is not present in the Default display.", $errors['default'][1]);
}
/**
diff --git a/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php b/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php
index 03488125064..e19f1414615 100644
--- a/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php
+++ b/core/modules/views/tests/src/Kernel/Handler/ArgumentSummaryTest.php
@@ -150,4 +150,38 @@ class ArgumentSummaryTest extends ViewsKernelTestBase {
$this->assertStringContainsString($tags[1]->label() . ' (2)', $output);
}
+ /**
+ * Tests that the active link is set correctly.
+ */
+ public function testActiveLink(): void {
+ require_once $this->root . '/core/modules/views/views.theme.inc';
+
+ // We need at least one node.
+ Node::create([
+ 'type' => $this->nodeType->id(),
+ 'title' => $this->randomMachineName(),
+ ])->save();
+
+ $view = Views::getView('test_argument_summary');
+ $view->execute();
+ $view->build();
+ $variables = [
+ 'view' => $view,
+ 'rows' => $view->result,
+ ];
+
+ template_preprocess_views_view_summary_unformatted($variables);
+ $this->assertFalse($variables['rows'][0]->active);
+
+ template_preprocess_views_view_summary($variables);
+ $this->assertFalse($variables['rows'][0]->active);
+
+ // Checks that the row with the current path is active.
+ \Drupal::service('path.current')->setPath('/test-argument-summary');
+ template_preprocess_views_view_summary_unformatted($variables);
+ $this->assertTrue($variables['rows'][0]->active);
+ template_preprocess_views_view_summary($variables);
+ $this->assertTrue($variables['rows'][0]->active);
+ }
+
}
diff --git a/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php b/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php
index 97d670634b3..14f90fd0c33 100644
--- a/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php
+++ b/core/modules/views/tests/src/Kernel/Plugin/ExposedFormRenderTest.php
@@ -137,12 +137,13 @@ class ExposedFormRenderTest extends ViewsKernelTestBase {
$view->save();
$this->executeView($view);
+ // The "type" filter should be excluded from the raw input because its
+ // value is "All".
$expected = [
- 'type' => 'All',
'type_with_default_value' => 'article',
'multiple_types_with_default_value' => ['article' => 'article'],
];
- $this->assertSame($view->exposed_raw_input, $expected);
+ $this->assertSame($expected, $view->exposed_raw_input);
}
}
diff --git a/core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php b/core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php
new file mode 100644
index 00000000000..0f3d3eb5291
--- /dev/null
+++ b/core/modules/views/tests/src/Kernel/ThirdPartyUninstallTest.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\views\Kernel;
+
+use Drupal\views\Entity\View;
+
+/**
+ * Tests proper removal of third-party settings from views.
+ *
+ * @group views
+ */
+class ThirdPartyUninstallTest extends ViewsKernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['node', 'views_third_party_settings_test'];
+
+ /**
+ * Views used by this test.
+ *
+ * @var array
+ */
+ public static $testViews = ['test_third_party_uninstall'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp($import_test_views = TRUE): void {
+ parent::setUp($import_test_views);
+
+ $this->installEntitySchema('user');
+ $this->installSchema('user', ['users_data']);
+ }
+
+ /**
+ * Tests removing third-party settings when a provider module is uninstalled.
+ */
+ public function testThirdPartyUninstall(): void {
+ $view = View::load('test_third_party_uninstall');
+ $this->assertNotEmpty($view);
+ $this->assertContains('views_third_party_settings_test', $view->getDependencies()['module']);
+ $this->assertTrue($view->getThirdPartySetting('views_third_party_settings_test', 'example_setting'));
+
+ \Drupal::service('module_installer')->uninstall(['views_third_party_settings_test']);
+
+ $view = View::load('test_third_party_uninstall');
+ $this->assertNotEmpty($view);
+ $this->assertNotContains('views_third_party_settings_test', $view->getDependencies()['module']);
+ $this->assertNull($view->getThirdPartySetting('views_third_party_settings_test', 'example_setting'));
+ }
+
+}
diff --git a/core/modules/views/views.theme.inc b/core/modules/views/views.theme.inc
index 10c29c5dbf3..04c5de5a535 100644
--- a/core/modules/views/views.theme.inc
+++ b/core/modules/views/views.theme.inc
@@ -253,15 +253,12 @@ function template_preprocess_views_view_summary(&$variables): void {
$url_options['query'] = $view->exposed_raw_input;
}
+ $currentPath = \Drupal::service('path.current')->getPath();
$active_urls = [
// Force system path.
- Url::fromRoute('<current>', [], ['alias' => TRUE])->toString(),
- // Force system path.
- Url::fromRouteMatch(\Drupal::routeMatch())->setOption('alias', TRUE)->toString(),
- // Could be an alias.
- Url::fromRoute('<current>')->toString(),
+ Url::fromUserInput($currentPath, ['alias' => TRUE])->toString(),
// Could be an alias.
- Url::fromRouteMatch(\Drupal::routeMatch())->toString(),
+ Url::fromUserInput($currentPath)->toString(),
];
$active_urls = array_combine($active_urls, $active_urls);
@@ -342,11 +339,12 @@ function template_preprocess_views_view_summary_unformatted(&$variables): void {
}
$count = 0;
+ $currentPath = \Drupal::service('path.current')->getPath();
$active_urls = [
// Force system path.
- Url::fromRoute('<current>', [], ['alias' => TRUE])->toString(),
+ Url::fromUserInput($currentPath, ['alias' => TRUE])->toString(),
// Could be an alias.
- Url::fromRoute('<current>')->toString(),
+ Url::fromUserInput($currentPath)->toString(),
];
$active_urls = array_combine($active_urls, $active_urls);
diff --git a/core/profiles/demo_umami/config/install/image.style.large_21_9.yml b/core/profiles/demo_umami/config/install/image.style.large_21_9.yml
index 562912c5af9..5bc5971a299 100644
--- a/core/profiles/demo_umami/config/install/image.style.large_21_9.yml
+++ b/core/profiles/demo_umami/config/install/image.style.large_21_9.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
622f10ec-5f36-427e-a54a-4c0b8a45d6ab:
uuid: 622f10ec-5f36-427e-a54a-4c0b8a45d6ab
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.large_21_9_2x.yml b/core/profiles/demo_umami/config/install/image.style.large_21_9_2x.yml
index 92a413cec5d..b6d61e4087e 100644
--- a/core/profiles/demo_umami/config/install/image.style.large_21_9_2x.yml
+++ b/core/profiles/demo_umami/config/install/image.style.large_21_9_2x.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
20ec38fa-1f1c-422d-9fed-48ed96e3eb50:
uuid: 20ec38fa-1f1c-422d-9fed-48ed96e3eb50
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.large_3_2_2x.yml b/core/profiles/demo_umami/config/install/image.style.large_3_2_2x.yml
index 9163a56dcae..08e5898f094 100644
--- a/core/profiles/demo_umami/config/install/image.style.large_3_2_2x.yml
+++ b/core/profiles/demo_umami/config/install/image.style.large_3_2_2x.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
3363b685-9a5f-4bd8-9246-1ad117054988:
uuid: 3363b685-9a5f-4bd8-9246-1ad117054988
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.large_3_2_768x512.yml b/core/profiles/demo_umami/config/install/image.style.large_3_2_768x512.yml
index 30c45680515..4fa82549294 100644
--- a/core/profiles/demo_umami/config/install/image.style.large_3_2_768x512.yml
+++ b/core/profiles/demo_umami/config/install/image.style.large_3_2_768x512.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
11b52bd1-8d1a-4e61-9578-3a3a290760a2:
uuid: 11b52bd1-8d1a-4e61-9578-3a3a290760a2
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.medium_21_9.yml b/core/profiles/demo_umami/config/install/image.style.medium_21_9.yml
index aea54082182..8273f07fbb0 100644
--- a/core/profiles/demo_umami/config/install/image.style.medium_21_9.yml
+++ b/core/profiles/demo_umami/config/install/image.style.medium_21_9.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
dd8d02b0-2ae5-4e0c-988f-730711911c49:
uuid: dd8d02b0-2ae5-4e0c-988f-730711911c49
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.medium_3_2_2x.yml b/core/profiles/demo_umami/config/install/image.style.medium_3_2_2x.yml
index 365c7ff976b..b7f0c5d619c 100644
--- a/core/profiles/demo_umami/config/install/image.style.medium_3_2_2x.yml
+++ b/core/profiles/demo_umami/config/install/image.style.medium_3_2_2x.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
1d1dd894-df16-49b4-8433-0a41624ccde2:
uuid: 1d1dd894-df16-49b4-8433-0a41624ccde2
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.medium_3_2_600x400.yml b/core/profiles/demo_umami/config/install/image.style.medium_3_2_600x400.yml
index 4c9a7416a85..abb525feb70 100644
--- a/core/profiles/demo_umami/config/install/image.style.medium_3_2_600x400.yml
+++ b/core/profiles/demo_umami/config/install/image.style.medium_3_2_600x400.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
cdd80cc9-5ecc-4040-9d83-a271c5142b7d:
uuid: cdd80cc9-5ecc-4040-9d83-a271c5142b7d
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.medium_8_7.yml b/core/profiles/demo_umami/config/install/image.style.medium_8_7.yml
index f1abccdbc11..37ee3cde532 100644
--- a/core/profiles/demo_umami/config/install/image.style.medium_8_7.yml
+++ b/core/profiles/demo_umami/config/install/image.style.medium_8_7.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
622134fc-29c1-43d4-ba34-beffc0bc4e15:
uuid: 622134fc-29c1-43d4-ba34-beffc0bc4e15
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_large.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_large.yml
index b2d726725c2..faa0ac812df 100644
--- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_large.yml
+++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_large.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
7108ef83-c308-4a0f-b877-e85d4995243b:
uuid: 7108ef83-c308-4a0f-b877-e85d4995243b
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_medium.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_medium.yml
index 5d827975098..36286bd884c 100644
--- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_medium.yml
+++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_medium.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
d83dd527-8378-4a47-9b0c-8c7a753cc89a:
uuid: d83dd527-8378-4a47-9b0c-8c7a753cc89a
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_small.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_small.yml
index 7efcc1c57a8..006657eceb9 100644
--- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_small.yml
+++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_small.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
d5eb26a0-f961-4ba4-9f01-94a44440b4fa:
uuid: d5eb26a0-f961-4ba4-9f01-94a44440b4fa
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_tiny.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_tiny.yml
index e1f05f5757a..e524f904b6c 100644
--- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_tiny.yml
+++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_tiny.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
18643b2d-f1ee-4dd6-ad72-84b67023d3cf:
uuid: 18643b2d-f1ee-4dd6-ad72-84b67023d3cf
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_wide.yml b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_wide.yml
index 478556b2a7b..2b5dd2b65c3 100644
--- a/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_wide.yml
+++ b/core/profiles/demo_umami/config/install/image.style.scale_crop_7_3_wide.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
d88116a5-6a1a-40ba-8a47-c635b2536de9:
uuid: d88116a5-6a1a-40ba-8a47-c635b2536de9
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.small_21_9.yml b/core/profiles/demo_umami/config/install/image.style.small_21_9.yml
index 448c2bbc9a0..c2d31ecc516 100644
--- a/core/profiles/demo_umami/config/install/image.style.small_21_9.yml
+++ b/core/profiles/demo_umami/config/install/image.style.small_21_9.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
f4f34147-c147-4e51-a7e2-5334f5ddb6f7:
uuid: f4f34147-c147-4e51-a7e2-5334f5ddb6f7
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.square_large.yml b/core/profiles/demo_umami/config/install/image.style.square_large.yml
index c395c1d660c..67ccd1a7c98 100644
--- a/core/profiles/demo_umami/config/install/image.style.square_large.yml
+++ b/core/profiles/demo_umami/config/install/image.style.square_large.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
54c8c29a-0a39-4ff0-b61b-8e85cc9367a4:
uuid: 54c8c29a-0a39-4ff0-b61b-8e85cc9367a4
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.square_medium.yml b/core/profiles/demo_umami/config/install/image.style.square_medium.yml
index 958180a249b..1782c9ee793 100644
--- a/core/profiles/demo_umami/config/install/image.style.square_medium.yml
+++ b/core/profiles/demo_umami/config/install/image.style.square_medium.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
b39ee1dd-ee27-4219-ba5d-e49d973f6b0f:
uuid: b39ee1dd-ee27-4219-ba5d-e49d973f6b0f
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/install/image.style.square_small.yml b/core/profiles/demo_umami/config/install/image.style.square_small.yml
index 38780763112..0e3db6d1512 100644
--- a/core/profiles/demo_umami/config/install/image.style.square_small.yml
+++ b/core/profiles/demo_umami/config/install/image.style.square_small.yml
@@ -14,7 +14,7 @@ effects:
anchor: center-center
dc9608c0-7510-4fbf-9474-028565687572:
uuid: dc9608c0-7510-4fbf-9474-028565687572
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/optional/image.style.max_1300x1300.yml b/core/profiles/demo_umami/config/optional/image.style.max_1300x1300.yml
index ec9723761fb..555a91d0a13 100644
--- a/core/profiles/demo_umami/config/optional/image.style.max_1300x1300.yml
+++ b/core/profiles/demo_umami/config/optional/image.style.max_1300x1300.yml
@@ -17,7 +17,7 @@ effects:
upscale: false
45c1e8e2-700e-4300-b0b8-c219e91d256b:
uuid: 45c1e8e2-700e-4300-b0b8-c219e91d256b
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/optional/image.style.max_2600x2600.yml b/core/profiles/demo_umami/config/optional/image.style.max_2600x2600.yml
index 27e9f72aa34..7b350902444 100644
--- a/core/profiles/demo_umami/config/optional/image.style.max_2600x2600.yml
+++ b/core/profiles/demo_umami/config/optional/image.style.max_2600x2600.yml
@@ -17,7 +17,7 @@ effects:
upscale: false
ecb160c9-df96-4b0b-9ef8-8ac74dd319f8:
uuid: ecb160c9-df96-4b0b-9ef8-8ac74dd319f8
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/optional/image.style.max_325x325.yml b/core/profiles/demo_umami/config/optional/image.style.max_325x325.yml
index 177af88aacc..bb3011d86f9 100644
--- a/core/profiles/demo_umami/config/optional/image.style.max_325x325.yml
+++ b/core/profiles/demo_umami/config/optional/image.style.max_325x325.yml
@@ -17,7 +17,7 @@ effects:
upscale: false
317b3f98-979a-4ee6-adec-fd24c7c7332c:
uuid: 317b3f98-979a-4ee6-adec-fd24c7c7332c
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/config/optional/image.style.max_650x650.yml b/core/profiles/demo_umami/config/optional/image.style.max_650x650.yml
index f941bb18648..53ab0716522 100644
--- a/core/profiles/demo_umami/config/optional/image.style.max_650x650.yml
+++ b/core/profiles/demo_umami/config/optional/image.style.max_650x650.yml
@@ -17,7 +17,7 @@ effects:
upscale: false
a4968ae1-83ac-420c-8e81-c571209daa44:
uuid: a4968ae1-83ac-420c-8e81-c571209daa44
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php
index 0b56bdd18c3..ceb9e19d43d 100644
--- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php
+++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php
@@ -30,7 +30,7 @@ class AssetAggregationAcrossPagesTest extends PerformanceTestBase {
'ScriptCount' => 1,
'ScriptBytes' => 11700,
'StylesheetCount' => 6,
- 'StylesheetBytes' => 119600,
+ 'StylesheetBytes' => 119250,
];
$this->assertMetrics($expected, $performance_data);
}
@@ -69,7 +69,7 @@ class AssetAggregationAcrossPagesTest extends PerformanceTestBase {
}, 'umamiFrontAndRecipePagesEditor');
$expected = [
'ScriptCount' => 5,
- 'ScriptBytes' => 338200,
+ 'ScriptBytes' => 335637,
'StylesheetCount' => 5,
'StylesheetBytes' => 205700,
];
diff --git a/core/profiles/standard/config/optional/image.style.max_1300x1300.yml b/core/profiles/standard/config/optional/image.style.max_1300x1300.yml
index fde3282498d..1801907a162 100644
--- a/core/profiles/standard/config/optional/image.style.max_1300x1300.yml
+++ b/core/profiles/standard/config/optional/image.style.max_1300x1300.yml
@@ -18,7 +18,7 @@ effects:
upscale: false
e8c9d6ba-a017-4a87-9999-7ce52e138e1d:
uuid: e8c9d6ba-a017-4a87-9999-7ce52e138e1d
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/standard/config/optional/image.style.max_2600x2600.yml b/core/profiles/standard/config/optional/image.style.max_2600x2600.yml
index a63e72ab6f3..ea0242ce907 100644
--- a/core/profiles/standard/config/optional/image.style.max_2600x2600.yml
+++ b/core/profiles/standard/config/optional/image.style.max_2600x2600.yml
@@ -18,7 +18,7 @@ effects:
upscale: false
3c42f186-7beb-4dbf-b720-bff9dfeaa677:
uuid: 3c42f186-7beb-4dbf-b720-bff9dfeaa677
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/standard/config/optional/image.style.max_325x325.yml b/core/profiles/standard/config/optional/image.style.max_325x325.yml
index e820c8bb01d..153e87c7e87 100644
--- a/core/profiles/standard/config/optional/image.style.max_325x325.yml
+++ b/core/profiles/standard/config/optional/image.style.max_325x325.yml
@@ -18,7 +18,7 @@ effects:
upscale: false
f2b6c795-26ae-4130-aa18-aa120ea3ba98:
uuid: f2b6c795-26ae-4130-aa18-aa120ea3ba98
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/standard/config/optional/image.style.max_650x650.yml b/core/profiles/standard/config/optional/image.style.max_650x650.yml
index d5beda6259f..08c969daca4 100644
--- a/core/profiles/standard/config/optional/image.style.max_650x650.yml
+++ b/core/profiles/standard/config/optional/image.style.max_650x650.yml
@@ -18,7 +18,7 @@ effects:
upscale: false
4a2a7af8-8ea3-419d-b5f8-256d57016102:
uuid: 4a2a7af8-8ea3-419d-b5f8-256d57016102
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php
index b80817d7488..f3a3196fab6 100644
--- a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php
+++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php
@@ -90,6 +90,7 @@ class StandardPerformanceTest extends PerformanceTestBase {
$expected_queries = [
'SELECT "base_table"."id" AS "id", "base_table"."path" AS "path", "base_table"."alias" AS "alias", "base_table"."langcode" AS "langcode" FROM "path_alias" "base_table" WHERE ("base_table"."status" = 1) AND ("base_table"."alias" LIKE "/node" ESCAPE ' . "'\\\\'" . ') AND ("base_table"."langcode" IN ("en", "und")) ORDER BY "base_table"."langcode" ASC, "base_table"."id" DESC',
'SELECT "name", "route", "fit" FROM "router" WHERE "pattern_outline" IN ( "/node" ) AND "number_parts" >= 1',
+ 'SELECT 1 AS "expression" FROM "path_alias" "base_table" WHERE ("base_table"."status" = 1) AND ("base_table"."path" LIKE "/rss.xml%" ESCAPE ' . "'\\\\'" . ') LIMIT 1 OFFSET 0',
'SELECT COUNT(*) AS "expression" FROM (SELECT 1 AS "expression" FROM "node_field_data" "node_field_data" WHERE ("node_field_data"."promote" = 1) AND ("node_field_data"."status" = 1)) "subquery"',
'SELECT "node_field_data"."sticky" AS "node_field_data_sticky", "node_field_data"."created" AS "node_field_data_created", "node_field_data"."nid" AS "nid" FROM "node_field_data" "node_field_data" WHERE ("node_field_data"."promote" = 1) AND ("node_field_data"."status" = 1) ORDER BY "node_field_data_sticky" DESC, "node_field_data_created" DESC LIMIT 10 OFFSET 0',
'SELECT "revision"."vid" AS "vid", "revision"."langcode" AS "langcode", "revision"."revision_uid" AS "revision_uid", "revision"."revision_timestamp" AS "revision_timestamp", "revision"."revision_log" AS "revision_log", "revision"."revision_default" AS "revision_default", "base"."nid" AS "nid", "base"."type" AS "type", "base"."uuid" AS "uuid", CASE "base"."vid" WHEN "revision"."vid" THEN 1 ELSE 0 END AS "isDefaultRevision" FROM "node" "base" INNER JOIN "node_revision" "revision" ON "revision"."vid" = "base"."vid" WHERE "base"."nid" IN (1)',
@@ -133,8 +134,8 @@ class StandardPerformanceTest extends PerformanceTestBase {
$recorded_queries = $performance_data->getQueries();
$this->assertSame($expected_queries, $recorded_queries);
$expected = [
- 'QueryCount' => 41,
- 'CacheGetCount' => 101,
+ 'QueryCount' => 42,
+ 'CacheGetCount' => 100,
'CacheGetCountByBin' => [
'page' => 1,
'config' => 21,
@@ -142,7 +143,7 @@ class StandardPerformanceTest extends PerformanceTestBase {
'discovery' => 38,
'bootstrap' => 8,
'dynamic_page_cache' => 1,
- 'render' => 14,
+ 'render' => 13,
'default' => 5,
'entity' => 2,
'menu' => 3,
@@ -150,7 +151,7 @@ class StandardPerformanceTest extends PerformanceTestBase {
'CacheSetCount' => 47,
'CacheDeleteCount' => 0,
'CacheTagInvalidationCount' => 0,
- 'CacheTagLookupQueryCount' => 17,
+ 'CacheTagLookupQueryCount' => 16,
'CacheTagGroupedLookups' => [
[
'route_match',
@@ -182,7 +183,6 @@ class StandardPerformanceTest extends PerformanceTestBase {
['config:block.block.stark_messages'],
['config:block.block.stark_help'],
['config:block.block.stark_powered'],
- ['config:block.block.stark_syndicate'],
[
'config:block.block.stark_account_menu',
'config:block.block.stark_breadcrumbs',
@@ -234,11 +234,11 @@ class StandardPerformanceTest extends PerformanceTestBase {
$this->assertSame($expected_queries, $recorded_queries);
$expected = [
'QueryCount' => 10,
- 'CacheGetCount' => 72,
+ 'CacheGetCount' => 71,
'CacheSetCount' => 16,
'CacheDeleteCount' => 0,
'CacheTagInvalidationCount' => 0,
- 'CacheTagLookupQueryCount' => 14,
+ 'CacheTagLookupQueryCount' => 13,
'CacheTagGroupedLookups' => [
[
'route_match',
@@ -267,7 +267,6 @@ class StandardPerformanceTest extends PerformanceTestBase {
['config:block.block.stark_messages'],
['config:block.block.stark_help'],
['config:block.block.stark_powered'],
- ['config:block.block.stark_syndicate'],
[
'config:block.block.stark_account_menu',
'config:block.block.stark_breadcrumbs',
@@ -285,7 +284,7 @@ class StandardPerformanceTest extends PerformanceTestBase {
['config:user.role.anonymous'],
],
'StylesheetCount' => 1,
- 'StylesheetBytes' => 1750,
+ 'StylesheetBytes' => 1550,
];
$this->assertMetrics($expected, $performance_data);
@@ -316,11 +315,11 @@ class StandardPerformanceTest extends PerformanceTestBase {
$this->assertSame($expected_queries, $recorded_queries);
$expected = [
'QueryCount' => 14,
- 'CacheGetCount' => 57,
+ 'CacheGetCount' => 56,
'CacheSetCount' => 17,
'CacheDeleteCount' => 0,
'CacheTagInvalidationCount' => 0,
- 'CacheTagLookupQueryCount' => 13,
+ 'CacheTagLookupQueryCount' => 12,
'StylesheetCount' => 1,
'StylesheetBytes' => 1800,
];
@@ -375,11 +374,11 @@ class StandardPerformanceTest extends PerformanceTestBase {
'StylesheetBytes' => 1429,
'StylesheetCount' => 1,
'QueryCount' => 17,
- 'CacheGetCount' => 69,
+ 'CacheGetCount' => 68,
'CacheSetCount' => 1,
'CacheDeleteCount' => 1,
'CacheTagInvalidationCount' => 0,
- 'CacheTagLookupQueryCount' => 14,
+ 'CacheTagLookupQueryCount' => 13,
'CacheTagGroupedLookups' => [
// Form submission and login.
[
@@ -421,7 +420,6 @@ class StandardPerformanceTest extends PerformanceTestBase {
['config:block.block.stark_messages'],
['config:block.block.stark_help'],
['config:block.block.stark_powered'],
- ['config:block.block.stark_syndicate'],
['config:block.block.stark_main_menu'],
[
'config:block.block.stark_account_menu',
diff --git a/core/profiles/standard/tests/src/Traits/StandardTestTrait.php b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
index 6b1ae846acf..6b31dcaf0e2 100644
--- a/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
+++ b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
@@ -192,7 +192,7 @@ trait StandardTestTrait {
foreach (ImageStyle::loadMultiple() as $style) {
$effects = $style->getEffects()->getInstanceIds();
$last = $style->getEffects()->get(end($effects));
- $this->assertSame('image_convert', $last->getConfiguration()['id']);
+ $this->assertSame('image_convert_avif', $last->getConfiguration()['id']);
$this->assertSame('webp', $last->getConfiguration()['data']['extension']);
}
diff --git a/core/recipes/standard/recipe.yml b/core/recipes/standard/recipe.yml
index 4a88f8a9b69..12e764f0ff5 100644
--- a/core/recipes/standard/recipe.yml
+++ b/core/recipes/standard/recipe.yml
@@ -59,7 +59,6 @@ config:
- block.block.olivero_help
- block.block.olivero_search_form_narrow
- block.block.olivero_search_form_wide
- - block.block.olivero_syndicate
user:
- core.entity_view_mode.user.compact
- search.page.user_search
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml
index 2bcee4cda93..eb7e5751011 100644
--- a/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml
+++ b/core/recipes/standard_responsive_images/config/image.style.max_1300x1300.yml
@@ -16,7 +16,7 @@ effects:
upscale: false
e8c9d6ba-a017-4a87-9999-7ce52e138e1d:
uuid: e8c9d6ba-a017-4a87-9999-7ce52e138e1d
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml
index 02d0d777fa7..612747fb34b 100644
--- a/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml
+++ b/core/recipes/standard_responsive_images/config/image.style.max_2600x2600.yml
@@ -16,7 +16,7 @@ effects:
upscale: false
3c42f186-7beb-4dbf-b720-bff9dfeaa677:
uuid: 3c42f186-7beb-4dbf-b720-bff9dfeaa677
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml
index 208d6f62189..45b79210c2a 100644
--- a/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml
+++ b/core/recipes/standard_responsive_images/config/image.style.max_325x325.yml
@@ -16,7 +16,7 @@ effects:
upscale: false
f2b6c795-26ae-4130-aa18-aa120ea3ba98:
uuid: f2b6c795-26ae-4130-aa18-aa120ea3ba98
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml
index c92f4347e2f..d64b225973f 100644
--- a/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml
+++ b/core/recipes/standard_responsive_images/config/image.style.max_650x650.yml
@@ -16,7 +16,7 @@ effects:
upscale: false
4a2a7af8-8ea3-419d-b5f8-256d57016102:
uuid: 4a2a7af8-8ea3-419d-b5f8-256d57016102
- id: image_convert
+ id: image_convert_avif
weight: 2
data:
extension: webp
diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php
index e775b358550..e5901e52fa4 100644
--- a/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php
+++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Drupal\FunctionalTests\Entity;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Entity\RevisionLogInterface;
use Drupal\entity_test\Entity\EntityTestRev;
use Drupal\entity_test\Entity\EntityTestRevPub;
@@ -247,7 +246,7 @@ class RevisionDeleteFormTest extends BrowserTestBase {
*
* @covers ::submitForm
*/
- protected function doTestSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, int $totalRevisions, string $expectedLog, string $expectedMessage, $expectedDestination): void {
+ protected function doTestSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, int $totalRevisions, array $expectedLog, string $expectedMessage, $expectedDestination): void {
if (count($permissions) > 0) {
$this->drupalLogin($this->createUser($permissions));
}
@@ -297,7 +296,9 @@ class RevisionDeleteFormTest extends BrowserTestBase {
// Logger log.
$logs = $this->getLogs($entity->getEntityType()->getProvider());
- $this->assertEquals([0 => $expectedLog], $logs);
+ $this->assertCount(1, $logs);
+ $this->assertEquals("@type: deleted %title revision %revision.", $logs[0]->message);
+ $this->assertEquals($expectedLog, unserialize($logs[0]->variables));
// Messenger message.
$this->assertSession()->pageTextContains($expectedMessage);
\Drupal::database()->delete('watchdog')->execute();
@@ -314,7 +315,11 @@ class RevisionDeleteFormTest extends BrowserTestBase {
'entity_test_rev',
'view all revisions, delete revision',
2,
- 'entity_test_rev: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.',
+ [
+ '@type' => 'entity_test_rev',
+ '%title' => 'view all revisions, delete revision',
+ '%revision' => '1',
+ ],
'Revision of Entity Test Bundle view all revisions, delete revision has been deleted.',
'/entity_test_rev/1/revisions',
];
@@ -324,7 +329,11 @@ class RevisionDeleteFormTest extends BrowserTestBase {
'entity_test_rev',
'view, view all revisions, delete revision',
2,
- 'entity_test_rev: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">3</em>.',
+ [
+ '@type' => 'entity_test_rev',
+ '%title' => 'view, view all revisions, delete revision',
+ '%revision' => '3',
+ ],
'Revision of Entity Test Bundle view, view all revisions, delete revision has been deleted.',
'/entity_test_rev/2/revisions',
];
@@ -334,7 +343,11 @@ class RevisionDeleteFormTest extends BrowserTestBase {
'entity_test_revlog',
'view all revisions, delete revision',
2,
- 'entity_test_revlog: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.',
+ [
+ '@type' => 'entity_test_revlog',
+ '%title' => 'view all revisions, delete revision',
+ '%revision' => '1',
+ ],
'Revision from Sun, 11 Jan 2009 - 16:00 of Test entity - revisions log view all revisions, delete revision has been deleted.',
'/entity_test_revlog/1/revisions',
];
@@ -344,7 +357,11 @@ class RevisionDeleteFormTest extends BrowserTestBase {
'entity_test_revlog',
'view, view all revisions, delete revision',
2,
- 'entity_test_revlog: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">3</em>.',
+ [
+ '@type' => 'entity_test_revlog',
+ '%title' => 'view, view all revisions, delete revision',
+ '%revision' => '3',
+ ],
'Revision from Sun, 11 Jan 2009 - 16:00 of Test entity - revisions log view, view all revisions, delete revision has been deleted.',
'/entity_test_revlog/2/revisions',
];
@@ -362,14 +379,11 @@ class RevisionDeleteFormTest extends BrowserTestBase {
* Watchdog entries.
*/
protected function getLogs(string $channel): array {
- $logs = \Drupal::database()->select('watchdog')
+ return \Drupal::database()->select('watchdog')
->fields('watchdog')
->condition('type', $channel)
->execute()
->fetchAll();
- return array_map(function (object $log) {
- return (string) new FormattableMarkup($log->message, unserialize($log->variables));
- }, $logs);
}
}
diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php
index c6ff4629fd9..8ca59b6100e 100644
--- a/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php
+++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Drupal\FunctionalTests\Entity;
-use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Entity\RevisionLogInterface;
use Drupal\entity_test\Entity\EntityTestRev;
use Drupal\entity_test\Entity\EntityTestRevPub;
@@ -192,7 +191,7 @@ class RevisionRevertFormTest extends BrowserTestBase {
* @covers ::submitForm
* @dataProvider providerSubmitForm
*/
- public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, string $expectedLog, string $expectedMessage, string $expectedDestination): void {
+ public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, array $expectedLog, string $expectedMessage, string $expectedDestination): void {
if (count($permissions) > 0) {
$this->drupalLogin($this->createUser($permissions));
}
@@ -231,7 +230,10 @@ class RevisionRevertFormTest extends BrowserTestBase {
// Logger log.
$logs = $this->getLogs($entity->getEntityType()->getProvider());
- $this->assertEquals([0 => $expectedLog], $logs);
+ $this->assertCount(1, $logs);
+ $this->assertEquals('@type: reverted %title revision %revision.', $logs[0]->message);
+ $this->assertEquals($expectedLog, unserialize($logs[0]->variables));
+
// Messenger message.
$this->assertSession()->pageTextContains($expectedMessage);
}
@@ -246,7 +248,11 @@ class RevisionRevertFormTest extends BrowserTestBase {
['view test entity'],
'entity_test_rev',
'view, revert',
- 'entity_test_rev: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.',
+ [
+ '@type' => 'entity_test_rev',
+ '%title' => 'view, revert',
+ '%revision' => '1',
+ ],
'Entity Test Bundle view, revert has been reverted.',
'/entity_test_rev/manage/1',
];
@@ -255,7 +261,11 @@ class RevisionRevertFormTest extends BrowserTestBase {
['view test entity'],
'entity_test_rev',
'view, view all revisions, revert',
- 'entity_test_rev: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.',
+ [
+ '@type' => 'entity_test_rev',
+ '%title' => 'view, view all revisions, revert',
+ '%revision' => '1',
+ ],
'Entity Test Bundle view, view all revisions, revert has been reverted.',
'/entity_test_rev/1/revisions',
];
@@ -264,7 +274,11 @@ class RevisionRevertFormTest extends BrowserTestBase {
[],
'entity_test_revlog',
'view, revert',
- 'entity_test_revlog: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.',
+ [
+ '@type' => 'entity_test_revlog',
+ '%title' => 'view, revert',
+ '%revision' => '1',
+ ],
'Test entity - revisions log view, revert has been reverted to the revision from Sun, 11 Jan 2009 - 16:00.',
'/entity_test_revlog/manage/1',
];
@@ -273,7 +287,11 @@ class RevisionRevertFormTest extends BrowserTestBase {
[],
'entity_test_revlog',
'view, view all revisions, revert',
- 'entity_test_revlog: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.',
+ [
+ '@type' => 'entity_test_revlog',
+ '%title' => 'view, view all revisions, revert',
+ '%revision' => '1',
+ ],
'Test entity - revisions log view, view all revisions, revert has been reverted to the revision from Sun, 11 Jan 2009 - 16:00.',
'/entity_test_revlog/1/revisions',
];
@@ -347,14 +365,11 @@ class RevisionRevertFormTest extends BrowserTestBase {
* Watchdog entries.
*/
protected function getLogs(string $channel): array {
- $logs = \Drupal::database()->select('watchdog')
+ return \Drupal::database()->select('watchdog')
->fields('watchdog')
->condition('type', $channel)
->execute()
->fetchAll();
- return array_map(function (object $log) {
- return (string) new FormattableMarkup($log->message, unserialize($log->variables));
- }, $logs);
}
/**
diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php
index 3bd0fe48af7..d33d7c4942a 100644
--- a/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php
+++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerNonDefaultDatabaseDriverTest.php
@@ -63,25 +63,20 @@ class InstallerNonDefaultDatabaseDriverTest extends InstallerTestBase {
// Assert that in the settings.php the database connection array has the
// correct values set.
- $contents = file_get_contents($this->container->getParameter('app.root') . '/' . $this->siteDirectory . '/settings.php');
- $this->assertStringContainsString("'namespace' => 'Drupal\\\\driver_test\\\\Driver\\\\Database\\\\{$this->testDriverName}',", $contents);
- $this->assertStringContainsString("'driver' => '{$this->testDriverName}',", $contents);
- $this->assertStringContainsString("'autoload' => 'core/modules/system/tests/modules/driver_test/src/Driver/Database/{$this->testDriverName}/',", $contents);
-
- $dependencies = "'dependencies' => " . PHP_EOL .
- " array (" . PHP_EOL .
- " 'mysql' => " . PHP_EOL .
- " array (" . PHP_EOL .
- " 'namespace' => 'Drupal\\\\mysql'," . PHP_EOL .
- " 'autoload' => 'core/modules/mysql/src/'," . PHP_EOL .
- " )," . PHP_EOL .
- " 'pgsql' => " . PHP_EOL .
- " array (" . PHP_EOL .
- " 'namespace' => 'Drupal\\\\pgsql'," . PHP_EOL .
- " 'autoload' => 'core/modules/pgsql/src/'," . PHP_EOL .
- " )," . PHP_EOL .
- " )," . PHP_EOL;
- $this->assertStringContainsString($dependencies, $contents);
+ $installedDatabaseSettings = $this->getInstalledDatabaseSettings();
+ $this->assertSame("Drupal\\driver_test\\Driver\\Database\\{$this->testDriverName}", $installedDatabaseSettings['default']['default']['namespace']);
+ $this->assertSame($this->testDriverName, $installedDatabaseSettings['default']['default']['driver']);
+ $this->assertSame("core/modules/system/tests/modules/driver_test/src/Driver/Database/{$this->testDriverName}/", $installedDatabaseSettings['default']['default']['autoload']);
+ $this->assertEquals([
+ 'mysql' => [
+ 'namespace' => 'Drupal\\mysql',
+ 'autoload' => 'core/modules/mysql/src/',
+ ],
+ 'pgsql' => [
+ 'namespace' => 'Drupal\\pgsql',
+ 'autoload' => 'core/modules/pgsql/src/',
+ ],
+ ], $installedDatabaseSettings['default']['default']['dependencies']);
// Assert that the module "driver_test" and its dependencies have been
// installed.
@@ -99,4 +94,22 @@ class InstallerNonDefaultDatabaseDriverTest extends InstallerTestBase {
$this->assertSession()->elementTextContains('xpath', '//tr[@data-drupal-selector="edit-pgsql"]', "The following reason prevents PostgreSQL from being uninstalled: Required by: driver_test");
}
+ /**
+ * Returns the databases setup from the SUT's settings.php.
+ *
+ * @return array<string,mixed>
+ * The value of the $databases variable.
+ */
+ protected function getInstalledDatabaseSettings(): array {
+ // The $app_root and $site_path variables are required by the settings.php
+ // file to be parsed correctly. The $databases variable is set in the
+ // included file, we need to inform PHPStan about that since PHPStan itself
+ // is unable to determine it.
+ $app_root = $this->container->getParameter('app.root');
+ $site_path = $this->siteDirectory;
+ include $app_root . '/' . $site_path . '/settings.php';
+ assert(isset($databases));
+ return $databases;
+ }
+
}
diff --git a/core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php b/core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php
new file mode 100644
index 00000000000..1b012c9f726
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Components/ComponentPluginManagerCachedDiscoveryTest.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Components;
+
+/**
+ * Tests discovery of components in a theme being installed or uninstalled.
+ *
+ * @group sdc
+ */
+class ComponentPluginManagerCachedDiscoveryTest extends ComponentKernelTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $themes = ['stark'];
+
+ /**
+ * Tests cached component plugin discovery on theme install and uninstall.
+ */
+ public function testComponentDiscoveryOnThemeInstall(): void {
+ // Component in sdc_theme should not be found without sdc_theme installed.
+ $definitions = \Drupal::service('plugin.manager.sdc')->getDefinitions();
+ $this->assertArrayNotHasKey('sdc_theme_test:bar', $definitions);
+
+ // Component in sdc_theme should be found once sdc_theme is installed.
+ \Drupal::service('theme_installer')->install(['sdc_theme_test']);
+ $definitions = \Drupal::service('plugin.manager.sdc')->getDefinitions();
+ $this->assertArrayHasKey('sdc_theme_test:bar', $definitions);
+
+ // Component in sdc_theme should not be found once sdc_theme is uninstalled.
+ \Drupal::service('theme_installer')->uninstall(['sdc_theme_test']);
+ $definitions = \Drupal::service('plugin.manager.sdc')->getDefinitions();
+ $this->assertArrayNotHasKey('sdc_theme_test:bar', $definitions);
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php
index 3ca62dcced5..f375f6f3dd6 100644
--- a/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php
+++ b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php
@@ -377,6 +377,7 @@ class MappingTest extends KernelTestBase {
'field.value.decimal' => ['value'],
'field.value.float' => ['value'],
'field.value.timestamp' => ['value'],
+ 'field.value.language' => ['value'],
'field.value.comment' => [
'status',
'cid',
diff --git a/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php b/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php
index 340dcc28649..15c97bea71f 100644
--- a/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Asset/ResolvedLibraryDefinitionsFilesMatchTest.php
@@ -99,6 +99,20 @@ class ResolvedLibraryDefinitionsFilesMatchTest extends KernelTestBase {
protected function setUp(): void {
parent::setUp();
+ // Install all core themes.
+ sort($this->allThemes);
+ $this->container->get('theme_installer')->install($this->allThemes);
+
+ $this->themeHandler = $this->container->get('theme_handler');
+ $this->themeInitialization = $this->container->get('theme.initialization');
+ $this->themeManager = $this->container->get('theme.manager');
+ $this->libraryDiscovery = $this->container->get('library.discovery');
+ }
+
+ /**
+ * Ensures that all core module and theme library files exist.
+ */
+ public function testCoreLibraryCompleteness(): void {
// Enable all core modules.
$all_modules = $this->container->get('extension.list.module')->getList();
$all_modules = array_filter($all_modules, function ($module) {
@@ -141,21 +155,37 @@ class ResolvedLibraryDefinitionsFilesMatchTest extends KernelTestBase {
}
sort($this->allModules);
$this->container->get('module_installer')->install($this->allModules);
+ // Get a library discovery from the new container.
+ $this->libraryDiscovery = $this->container->get('library.discovery');
- // Install all core themes.
- sort($this->allThemes);
- $this->container->get('theme_installer')->install($this->allThemes);
+ $this->assertLibraries();
+ }
- $this->themeHandler = $this->container->get('theme_handler');
- $this->themeInitialization = $this->container->get('theme.initialization');
- $this->themeManager = $this->container->get('theme.manager');
+ /**
+ * Ensures that module and theme library files exist for a deprecated modules.
+ *
+ * @group legacy
+ */
+ public function testCoreLibraryCompletenessDeprecated(): void {
+ // Find and install deprecated modules to test.
+ $all_modules = $this->container->get('extension.list.module')->getList();
+ $deprecated_modules_to_test = array_filter($all_modules, function ($module) {
+ if ($module->origin == 'core'
+ && $module->info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] === ExtensionLifecycle::DEPRECATED) {
+ return TRUE;
+ }
+ });
+ $this->container->get('module_installer')->install(array_keys($deprecated_modules_to_test));
$this->libraryDiscovery = $this->container->get('library.discovery');
+ $this->allModules = array_keys(\Drupal::moduleHandler()->getModuleList());
+
+ $this->assertLibraries();
}
/**
- * Ensures that all core module and theme library files exist.
+ * Asserts the libraries for modules and themes exist.
*/
- public function testCoreLibraryCompleteness(): void {
+ public function assertLibraries(): void {
// First verify all libraries with no active theme.
$this->verifyLibraryFilesExist($this->getAllLibraries());
diff --git a/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php b/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php
index c2bff123236..c1ff7e24406 100644
--- a/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Cache/DatabaseBackendTagTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Drupal\KernelTests\Core\Cache;
use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheTagsPurgeInterface;
use Drupal\Core\Database\Database;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\KernelTests\KernelTestBase;
@@ -62,4 +63,34 @@ class DatabaseBackendTagTest extends KernelTestBase {
$this->assertEquals($invalidations_before + 1, $invalidations_after, 'Only one addition cache tag invalidation has occurred after invalidating a tag used in multiple bins.');
}
+ /**
+ * Test cache tag purging.
+ */
+ public function testTagsPurge(): void {
+ $tags = ['test_tag:1', 'test_tag:2', 'test_tag:3'];
+ /** @var \Drupal\Core\Cache\CacheTagsChecksumInterface $checksum_invalidator */
+ $checksum_invalidator = \Drupal::service('cache_tags.invalidator.checksum');
+ // Assert that initial current tag checksum is 0. This also ensures that the
+ // 'cachetags' table is created, which at this point does not exist yet.
+ $this->assertEquals(0, $checksum_invalidator->getCurrentChecksum($tags));
+
+ /** @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface $invalidator */
+ $invalidator = \Drupal::service('cache_tags.invalidator');
+ $invalidator->invalidateTags($tags);
+ // Checksum should be incremented by 1 by the invalidation for each tag.
+ $this->assertEquals(3, $checksum_invalidator->getCurrentChecksum($tags));
+
+ // After purging, confirm checksum is 0 and the 'cachetags' table is empty.
+ $this->assertInstanceOf(CacheTagsPurgeInterface::class, $invalidator);
+ $invalidator->purge();
+ $this->assertEquals(0, $checksum_invalidator->getCurrentChecksum($tags));
+
+ $rows = Database::getConnection()->select('cachetags')
+ ->fields('cachetags')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEmpty($rows, 'cachetags table is empty.');
+ }
+
}
diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php
index c361c7af959..cbda6e3d7f7 100644
--- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php
+++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificTransactionTestBase.php
@@ -432,9 +432,6 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase {
/**
* Tests rollback after a DDL statement when no transactional DDL supported.
- *
- * @todo In drupal:12.0.0, rollBack will throw a
- * TransactionOutOfOrderException. Adjust the test accordingly.
*/
public function testRollbackAfterDdlStatementForNonTransactionalDdlDatabase(): void {
if ($this->connection->supportsTransactionalDDL()) {
@@ -919,9 +916,6 @@ class DriverSpecificTransactionTestBase extends DriverSpecificDatabaseTestBase {
* transaction including DDL statements is not possible, since a commit
* happened already. We cannot decide what should be the status of the
* callback, an exception is thrown.
- *
- * @todo In drupal:12.0.0, rollBack will throw a
- * TransactionOutOfOrderException. Adjust the test accordingly.
*/
public function testRootTransactionEndCallbackFailureUponDdlAndRollbackForNonTransactionalDdlDatabase(): void {
if ($this->connection->supportsTransactionalDDL()) {
diff --git a/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php b/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php
index 6e28787f764..3bfaa18889a 100644
--- a/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Database/FetchLegacyTest.php
@@ -20,9 +20,9 @@ class FetchLegacyTest extends DatabaseTestBase {
*/
#[IgnoreDeprecations]
public function testQueryFetchObject(): void {
- $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() 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");
- $this->expectDeprecation("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");
- $this->expectDeprecation("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");
+ $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() 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");
+ $this->expectDeprecation("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");
+ $this->expectDeprecation("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");
$records = [];
$result = $this->connection->query('SELECT [name] FROM {test} WHERE [age] = :age', [':age' => 25], ['fetch' => \PDO::FETCH_OBJ]);
foreach ($result as $record) {
@@ -39,9 +39,9 @@ class FetchLegacyTest extends DatabaseTestBase {
*/
#[IgnoreDeprecations]
public function testQueryFetchArray(): void {
- $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() 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");
- $this->expectDeprecation("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");
- $this->expectDeprecation("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");
+ $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() 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");
+ $this->expectDeprecation("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");
+ $this->expectDeprecation("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");
$records = [];
$result = $this->connection->query('SELECT [name] FROM {test} WHERE [age] = :age', [':age' => 25], ['fetch' => \PDO::FETCH_ASSOC]);
foreach ($result as $record) {
@@ -59,9 +59,9 @@ class FetchLegacyTest extends DatabaseTestBase {
*/
#[IgnoreDeprecations]
public function testQueryFetchNum(): void {
- $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() 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");
- $this->expectDeprecation("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");
- $this->expectDeprecation("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");
+ $this->expectDeprecation("Passing the 'fetch' key as an integer to \$options in query() 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");
+ $this->expectDeprecation("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");
+ $this->expectDeprecation("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");
$records = [];
$result = $this->connection->query('SELECT [name] FROM {test} WHERE [age] = :age', [':age' => 25], ['fetch' => \PDO::FETCH_NUM]);
foreach ($result as $record) {
@@ -79,7 +79,7 @@ class FetchLegacyTest extends DatabaseTestBase {
*/
#[IgnoreDeprecations]
public function testQueryFetchAllColumn(): void {
- $this->expectDeprecation("Passing the \$mode argument as an integer to fetchAll() 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");
+ $this->expectDeprecation("Passing the \$mode argument as an integer to fetchAll() 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");
$query = $this->connection->select('test');
$query->addField('test', 'name');
$query->orderBy('name');
@@ -94,7 +94,7 @@ class FetchLegacyTest extends DatabaseTestBase {
*/
#[IgnoreDeprecations]
public function testQueryFetchAllAssoc(): void {
- $this->expectDeprecation("Passing the \$fetch argument as an integer to fetchAllAssoc() 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");
+ $this->expectDeprecation("Passing the \$fetch argument as an integer to fetchAllAssoc() 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");
$expected_result = [
"Singer" => [
"id" => "2",
diff --git a/core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php b/core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php
new file mode 100644
index 00000000000..f40a977f6f4
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Database/TransactionTest.php
@@ -0,0 +1,1278 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Database;
+
+use Drupal\Core\Database\Database;
+use Drupal\Core\Database\Transaction;
+use Drupal\Core\Database\Transaction\ClientConnectionTransactionState;
+use Drupal\Core\Database\Transaction\StackItem;
+use Drupal\Core\Database\Transaction\StackItemType;
+use Drupal\Core\Database\Transaction\TransactionManagerBase;
+use Drupal\Core\Database\TransactionNameNonUniqueException;
+use Drupal\Core\Database\TransactionOutOfOrderException;
+
+// cspell:ignore Tinky Winky Dipsy
+
+/**
+ * Tests the transactions, using the explicit ::commitOrRelease method.
+ *
+ * We test nesting by having two transaction layers, an outer and inner. The
+ * outer layer encapsulates the inner layer. Our transaction nesting abstraction
+ * should allow the outer layer function to call any function it wants,
+ * especially the inner layer that starts its own transaction, and be
+ * confident that, when the function it calls returns, its own transaction
+ * is still "alive."
+ *
+ * Call structure:
+ * transactionOuterLayer()
+ * Start transaction "A"
+ * transactionInnerLayer()
+ * Start transaction "B" (does nothing in database)
+ * [Maybe decide to roll back "B"]
+ * Do more stuff
+ * Should still be in transaction "A"
+ *
+ * These method can be overridden by non-core database driver if their
+ * transaction behavior is different from core. For example, both oci8 (Oracle)
+ * and mysqli (MySql) clients do not have a solution to check if a transaction
+ * is active, and mysqli does not fail when rolling back and no transaction
+ * active.
+ *
+ * @group Database
+ */
+class TransactionTest extends DatabaseTestBase {
+
+ /**
+ * Keeps track of the post-transaction callback action executed.
+ */
+ protected ?string $postTransactionCallbackAction = NULL;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ // Set the transaction manager to trigger warnings when appropriate.
+ $this->connection->transactionManager()->triggerWarningWhenUnpilingOnVoidTransaction = TRUE;
+ }
+
+ /**
+ * Create a root Drupal transaction.
+ */
+ protected function createRootTransaction(string $name = '', bool $insertRow = TRUE): Transaction {
+ $this->assertFalse($this->connection->inTransaction());
+ $this->assertSame(0, $this->connection->transactionManager()->stackDepth());
+
+ // Start root transaction. Corresponds to 'BEGIN TRANSACTION' on the
+ // database.
+ $transaction = $this->connection->startTransaction($name);
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+
+ // Insert a single row into the testing table.
+ if ($insertRow) {
+ $this->insertRow('David');
+ $this->assertRowPresent('David');
+ }
+
+ return $transaction;
+ }
+
+ /**
+ * Create a Drupal savepoint transaction after root.
+ */
+ protected function createFirstSavepointTransaction(string $name = '', bool $insertRow = TRUE): Transaction {
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+
+ // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_1'
+ // on the database. The name can be changed by the $name argument.
+ $savepoint = $this->connection->startTransaction($name);
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(2, $this->connection->transactionManager()->stackDepth());
+
+ // Insert a single row into the testing table.
+ if ($insertRow) {
+ $this->insertRow('Roger');
+ $this->assertRowPresent('Roger');
+ }
+
+ return $savepoint;
+ }
+
+ /**
+ * Encapsulates a transaction's "inner layer" with an "outer layer".
+ *
+ * This "outer layer" transaction starts and then encapsulates the "inner
+ * layer" transaction. This nesting is used to evaluate whether the database
+ * transaction API properly supports nesting. By "properly supports," we mean
+ * the outer transaction continues to exist regardless of what functions are
+ * called and whether those functions start their own transactions.
+ *
+ * In contrast, a typical database would commit the outer transaction, start
+ * a new transaction for the inner layer, commit the inner layer transaction,
+ * and then be confused when the outer layer transaction tries to commit its
+ * transaction (which was already committed when the inner transaction
+ * started).
+ *
+ * @param string $suffix
+ * Suffix to add to field values to differentiate tests.
+ */
+ protected function transactionOuterLayer(string $suffix): void {
+ $txn = $this->connection->startTransaction();
+
+ // Insert a single row into the testing table.
+ $this->connection->insert('test')
+ ->fields([
+ 'name' => 'David' . $suffix,
+ 'age' => '24',
+ ])
+ ->execute();
+
+ $this->assertTrue($this->connection->inTransaction(), 'In transaction before calling nested transaction.');
+
+ // We're already in a transaction, but we call ->transactionInnerLayer
+ // to nest another transaction inside the current one.
+ $this->transactionInnerLayer($suffix);
+
+ $this->assertTrue($this->connection->inTransaction(), 'In transaction after calling nested transaction.');
+
+ $txn->commitOrRelease();
+ }
+
+ /**
+ * Creates an "inner layer" transaction.
+ *
+ * This "inner layer" transaction is either used alone or nested inside of the
+ * "outer layer" transaction.
+ *
+ * @param string $suffix
+ * Suffix to add to field values to differentiate tests.
+ */
+ protected function transactionInnerLayer(string $suffix): void {
+ $depth = $this->connection->transactionManager()->stackDepth();
+ // Start a transaction. If we're being called from ->transactionOuterLayer,
+ // then we're already in a transaction. Normally, that would make starting
+ // a transaction here dangerous, but the database API handles this problem
+ // for us by tracking the nesting and avoiding the danger.
+ $txn = $this->connection->startTransaction();
+
+ $depth2 = $this->connection->transactionManager()->stackDepth();
+ $this->assertSame($depth + 1, $depth2, 'Transaction depth has increased with new transaction.');
+
+ // Insert a single row into the testing table.
+ $this->connection->insert('test')
+ ->fields([
+ 'name' => 'Daniel' . $suffix,
+ 'age' => '19',
+ ])
+ ->execute();
+
+ $this->assertTrue($this->connection->inTransaction(), 'In transaction inside nested transaction.');
+
+ $txn->commitOrRelease();
+ }
+
+ /**
+ * Tests root transaction rollback.
+ */
+ public function testRollbackRoot(): void {
+ $transaction = $this->createRootTransaction();
+
+ // Rollback. Since we are at the root, the transaction is closed.
+ // Corresponds to 'ROLLBACK' on the database.
+ $transaction->rollBack();
+ $this->assertRowAbsent('David');
+ $this->assertFalse($this->connection->inTransaction());
+ $this->assertSame(0, $this->connection->transactionManager()->stackDepth());
+ }
+
+ /**
+ * Tests root transaction rollback after savepoint rollback.
+ */
+ public function testRollbackRootAfterSavepointRollback(): void {
+ $transaction = $this->createRootTransaction();
+ $savepoint = $this->createFirstSavepointTransaction();
+
+ // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK
+ // TO savepoint_1' plus 'RELEASE savepoint_1' on the database.
+ $savepoint->rollBack();
+ $this->assertRowPresent('David');
+ $this->assertRowAbsent('Roger');
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+
+ // Try to rollback root. No savepoint is active, this should succeed.
+ $transaction->rollBack();
+ $this->assertRowAbsent('David');
+ $this->assertRowAbsent('Roger');
+ $this->assertFalse($this->connection->inTransaction());
+ $this->assertSame(0, $this->connection->transactionManager()->stackDepth());
+ }
+
+ /**
+ * Tests root transaction rollback failure when savepoint is open.
+ */
+ public function testRollbackRootWithActiveSavepoint(): void {
+ $transaction = $this->createRootTransaction();
+ // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis
+ $savepoint = $this->createFirstSavepointTransaction();
+
+ // Try to rollback root. Since a savepoint is active, this should fail.
+ $this->expectException(TransactionOutOfOrderException::class);
+ $this->expectExceptionMessageMatches("/^Error attempting rollback of .*\\\\drupal_transaction\\. Active stack: .*\\\\drupal_transaction > .*\\\\savepoint_1/");
+ $transaction->rollBack();
+ }
+
+ /**
+ * Tests savepoint transaction rollback.
+ */
+ public function testRollbackSavepoint(): void {
+ $transaction = $this->createRootTransaction();
+ $savepoint = $this->createFirstSavepointTransaction();
+
+ // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK
+ // TO savepoint_1' plus 'RELEASE savepoint_1' on the database.
+ $savepoint->rollBack();
+ $this->assertRowPresent('David');
+ $this->assertRowAbsent('Roger');
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+
+ // Insert a row.
+ $this->insertRow('Syd');
+
+ // Commit root.
+ $transaction->commitOrRelease();
+ $this->assertRowPresent('David');
+ $this->assertRowAbsent('Roger');
+ $this->assertRowPresent('Syd');
+ $this->assertFalse($this->connection->inTransaction());
+ $this->assertSame(0, $this->connection->transactionManager()->stackDepth());
+ }
+
+ /**
+ * Tests savepoint transaction commit after rollback.
+ */
+ public function testCommitAfterRollbackSameSavepoint(): void {
+ $transaction = $this->createRootTransaction();
+ $savepoint = $this->createFirstSavepointTransaction();
+
+ // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK
+ // TO savepoint_1' plus 'RELEASE savepoint_1' on the database.
+ $savepoint->rollBack();
+ $this->assertRowPresent('David');
+ $this->assertRowAbsent('Roger');
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+
+ // Insert a row.
+ $this->insertRow('Syd');
+
+ // Try releasing savepoint. Should fail since it was released already.
+ try {
+ $savepoint->commitOrRelease();
+ $this->fail('Expected TransactionOutOfOrderException was not thrown');
+ }
+ catch (\Exception $e) {
+ $this->assertInstanceOf(TransactionOutOfOrderException::class, $e);
+ $this->assertMatchesRegularExpression("/^Error attempting commit of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage());
+ }
+ $this->assertRowPresent('David');
+ $this->assertRowAbsent('Roger');
+ $this->assertRowPresent('Syd');
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+
+ // Commit root.
+ $transaction->commitOrRelease();
+ $this->assertRowPresent('David');
+ $this->assertRowAbsent('Roger');
+ $this->assertRowPresent('Syd');
+ $this->assertFalse($this->connection->inTransaction());
+ $this->assertSame(0, $this->connection->transactionManager()->stackDepth());
+ }
+
+ /**
+ * Tests savepoint transaction rollback after commit.
+ */
+ public function testRollbackAfterCommitSameSavepoint(): void {
+ $transaction = $this->createRootTransaction();
+ $savepoint = $this->createFirstSavepointTransaction();
+
+ // Release savepoint. Corresponds to 'RELEASE savepoint_1' on the database.
+ $savepoint->commitOrRelease();
+ $this->assertRowPresent('David');
+ $this->assertRowPresent('Roger');
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+
+ // Insert a row.
+ $this->insertRow('Syd');
+
+ // Try rolling back savepoint. Should fail since it was released already.
+ try {
+ $savepoint->rollback();
+ $this->fail('Expected TransactionOutOfOrderException was not thrown');
+ }
+ catch (\Exception $e) {
+ $this->assertInstanceOf(TransactionOutOfOrderException::class, $e);
+ $this->assertMatchesRegularExpression("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage());
+ }
+ $this->assertRowPresent('David');
+ $this->assertRowPresent('Roger');
+ $this->assertRowPresent('Syd');
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+
+ // Commit root.
+ $transaction->commitOrRelease();
+ $this->assertRowPresent('David');
+ $this->assertRowPresent('Roger');
+ $this->assertRowPresent('Syd');
+ $this->assertFalse($this->connection->inTransaction());
+ $this->assertSame(0, $this->connection->transactionManager()->stackDepth());
+ }
+
+ /**
+ * Tests savepoint transaction duplicated rollback.
+ */
+ public function testRollbackTwiceSameSavepoint(): void {
+ // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis
+ $transaction = $this->createRootTransaction();
+ $savepoint = $this->createFirstSavepointTransaction();
+
+ // Rollback savepoint. It should get released too. Corresponds to 'ROLLBACK
+ // TO savepoint_1' plus 'RELEASE savepoint_1' on the database.
+ $savepoint->rollBack();
+ $this->assertRowPresent('David');
+ $this->assertRowAbsent('Roger');
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+
+ // Insert a row.
+ $this->insertRow('Syd');
+
+ // Rollback savepoint again. Should fail since it was released already.
+ try {
+ $savepoint->rollBack();
+ $this->fail('Expected TransactionOutOfOrderException was not thrown');
+ }
+ catch (\Exception $e) {
+ $this->assertInstanceOf(TransactionOutOfOrderException::class, $e);
+ $this->assertMatchesRegularExpression("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction/", $e->getMessage());
+ }
+ $this->assertRowPresent('David');
+ $this->assertRowAbsent('Roger');
+ $this->assertRowPresent('Syd');
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+ }
+
+ /**
+ * Tests savepoint transaction rollback failure when later savepoints exist.
+ */
+ public function testRollbackSavepointWithLaterSavepoint(): void {
+ // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis
+ $transaction = $this->createRootTransaction();
+ $savepoint1 = $this->createFirstSavepointTransaction();
+
+ // Starts another savepoint transaction. Corresponds to 'SAVEPOINT
+ // savepoint_2' on the database.
+ // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis
+ $savepoint2 = $this->connection->startTransaction();
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(3, $this->connection->transactionManager()->stackDepth());
+
+ // Insert a row.
+ $this->insertRow('Syd');
+ $this->assertRowPresent('David');
+ $this->assertRowPresent('Roger');
+ $this->assertRowPresent('Syd');
+
+ // Try to rollback to savepoint 1. Out of order.
+ $this->expectException(TransactionOutOfOrderException::class);
+ $this->expectExceptionMessageMatches("/^Error attempting rollback of .*\\\\savepoint_1\\. Active stack: .*\\\\drupal_transaction > .*\\\\savepoint_1 > .*\\\\savepoint_2/");
+ $savepoint1->rollBack();
+ }
+
+ /**
+ * Tests commit does not fail when committing after DDL.
+ *
+ * In core, SQLite and PostgreSql databases support transactional DDL, MySql
+ * does not.
+ */
+ public function testCommitAfterDdl(): void {
+ $transaction = $this->createRootTransaction();
+ $savepoint = $this->createFirstSavepointTransaction();
+
+ $this->executeDDLStatement();
+
+ $this->assertRowPresent('David');
+ $this->assertRowPresent('Roger');
+ if ($this->connection->supportsTransactionalDDL()) {
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(2, $this->connection->transactionManager()->stackDepth());
+ }
+ else {
+ $this->assertFalse($this->connection->inTransaction());
+ }
+
+ $this->assertRowPresent('David');
+ $this->assertRowPresent('Roger');
+ if ($this->connection->supportsTransactionalDDL()) {
+ $savepoint->commitOrRelease();
+ $this->assertTrue($this->connection->inTransaction());
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+ }
+ else {
+ set_error_handler(static function (int $errno, string $errstr): bool {
+ throw new \ErrorException($errstr);
+ });
+ try {
+ $savepoint->commitOrRelease();
+ }
+ catch (\ErrorException $e) {
+ $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage());
+ }
+ finally {
+ restore_error_handler();
+ }
+ $this->assertFalse($this->connection->inTransaction());
+ }
+
+ if ($this->connection->supportsTransactionalDDL()) {
+ $transaction->commitOrRelease();
+ }
+ else {
+ set_error_handler(static function (int $errno, string $errstr): bool {
+ throw new \ErrorException($errstr);
+ });
+ try {
+ $transaction->commitOrRelease();
+ }
+ catch (\ErrorException $e) {
+ $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage());
+ }
+ finally {
+ restore_error_handler();
+ }
+ }
+ $this->assertRowPresent('David');
+ $this->assertRowPresent('Roger');
+ $this->assertFalse($this->connection->inTransaction());
+ }
+
+ /**
+ * Tests a committed transaction.
+ *
+ * The behavior of this test should be identical for connections that support
+ * transactions and those that do not.
+ */
+ public function testCommittedTransaction(): void {
+ // Create two nested transactions. The changes should be committed.
+ $this->transactionOuterLayer('A');
+
+ // Because we committed, both of the inserted rows should be present.
+ $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'DavidA'])->fetchField();
+ $this->assertSame('24', $saved_age, 'Can retrieve DavidA row after commit.');
+ $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'DanielA'])->fetchField();
+ $this->assertSame('19', $saved_age, 'Can retrieve DanielA row after commit.');
+ }
+
+ /**
+ * Tests the compatibility of transactions with DDL statements.
+ */
+ public function testTransactionWithDdlStatement(): void {
+ // First, test that a commit works normally, even with DDL statements.
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->insertRow('row');
+ $this->executeDDLStatement();
+ if ($this->connection->supportsTransactionalDDL()) {
+ $transaction->commitOrRelease();
+ }
+ else {
+ set_error_handler(static function (int $errno, string $errstr): bool {
+ throw new \ErrorException($errstr);
+ });
+ try {
+ $transaction->commitOrRelease();
+ }
+ catch (\ErrorException $e) {
+ $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage());
+ }
+ finally {
+ restore_error_handler();
+ }
+ }
+ $this->assertRowPresent('row');
+
+ // Even in different order.
+ $this->cleanUp();
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->executeDDLStatement();
+ $this->insertRow('row');
+ if ($this->connection->supportsTransactionalDDL()) {
+ $transaction->commitOrRelease();
+ }
+ else {
+ set_error_handler(static function (int $errno, string $errstr): bool {
+ throw new \ErrorException($errstr);
+ });
+ try {
+ $transaction->commitOrRelease();
+ }
+ catch (\ErrorException $e) {
+ $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage());
+ }
+ finally {
+ restore_error_handler();
+ }
+ }
+ $this->assertRowPresent('row');
+
+ // Even with stacking.
+ $this->cleanUp();
+ $transaction = $this->createRootTransaction('', FALSE);
+ $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
+ $this->executeDDLStatement();
+ if ($this->connection->supportsTransactionalDDL()) {
+ $transaction2->commitOrRelease();
+ }
+ else {
+ set_error_handler(static function (int $errno, string $errstr): bool {
+ throw new \ErrorException($errstr);
+ });
+ try {
+ $transaction2->commitOrRelease();
+ }
+ catch (\ErrorException $e) {
+ $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage());
+ }
+ finally {
+ restore_error_handler();
+ }
+ }
+ $transaction3 = $this->connection->startTransaction();
+ $this->insertRow('row');
+ $transaction3->commitOrRelease();
+
+ if ($this->connection->supportsTransactionalDDL()) {
+ $transaction->commitOrRelease();
+ }
+ else {
+ try {
+ $transaction->commitOrRelease();
+ $this->fail('TransactionOutOfOrderException was expected, but did not throw.');
+ }
+ catch (TransactionOutOfOrderException) {
+ // Just continue, this is out or order since $transaction3 started a
+ // new root.
+ }
+ }
+ $this->assertRowPresent('row');
+
+ // A transaction after a DDL statement should still work the same.
+ $this->cleanUp();
+ $transaction = $this->createRootTransaction('', FALSE);
+ $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
+ $this->executeDDLStatement();
+ if ($this->connection->supportsTransactionalDDL()) {
+ $transaction2->commitOrRelease();
+ }
+ else {
+ set_error_handler(static function (int $errno, string $errstr): bool {
+ throw new \ErrorException($errstr);
+ });
+ try {
+ $transaction2->commitOrRelease();
+ }
+ catch (\ErrorException $e) {
+ $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage());
+ }
+ finally {
+ restore_error_handler();
+ }
+ }
+ $transaction3 = $this->connection->startTransaction();
+ $this->insertRow('row');
+ $transaction3->rollBack();
+ if ($this->connection->supportsTransactionalDDL()) {
+ $transaction->commitOrRelease();
+ }
+ else {
+ try {
+ $transaction->commitOrRelease();
+ $this->fail('TransactionOutOfOrderException was expected, but did not throw.');
+ }
+ catch (TransactionOutOfOrderException) {
+ // Just continue, this is out or order since $transaction3 started a
+ // new root.
+ }
+ }
+ $this->assertRowAbsent('row');
+
+ // The behavior of a rollback depends on the type of database server.
+ if ($this->connection->supportsTransactionalDDL()) {
+ // For database servers that support transactional DDL, a rollback
+ // of a transaction including DDL statements should be possible.
+ $this->cleanUp();
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->insertRow('row');
+ $this->executeDDLStatement();
+ $transaction->rollBack();
+ $this->assertRowAbsent('row');
+
+ // Including with stacking.
+ $this->cleanUp();
+ $transaction = $this->createRootTransaction('', FALSE);
+ $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
+ $this->executeDDLStatement();
+ $transaction2->commitOrRelease();
+ $transaction3 = $this->connection->startTransaction();
+ $this->insertRow('row');
+ $transaction3->commitOrRelease();
+ $this->assertRowPresent('row');
+ $transaction->rollBack();
+ $this->assertRowAbsent('row');
+ }
+ }
+
+ /**
+ * Tests rollback after a DDL statement when no transactional DDL supported.
+ */
+ public function testRollbackAfterDdlStatementForNonTransactionalDdlDatabase(): void {
+ if ($this->connection->supportsTransactionalDDL()) {
+ $this->markTestSkipped('This test only works for database that do not support transactional DDL.');
+ }
+
+ // For database servers that do not support transactional DDL,
+ // the DDL statement should commit the transaction stack.
+ $this->cleanUp();
+ $transaction = $this->createRootTransaction('', FALSE);
+ $reflectionMethod = new \ReflectionMethod(get_class($this->connection->transactionManager()), 'getConnectionTransactionState');
+ $this->assertSame(1, $this->connection->transactionManager()->stackDepth());
+ $this->assertEquals(ClientConnectionTransactionState::Active, $reflectionMethod->invoke($this->connection->transactionManager()));
+ $this->insertRow('row');
+ $this->executeDDLStatement();
+ $this->assertSame(0, $this->connection->transactionManager()->stackDepth());
+ $this->assertEquals(ClientConnectionTransactionState::Voided, $reflectionMethod->invoke($this->connection->transactionManager()));
+
+ // Try to rollback the root transaction. Since the DDL already committed
+ // it, it should fail.
+ set_error_handler(static function (int $errno, string $errstr): bool {
+ throw new \ErrorException($errstr);
+ });
+ try {
+ $transaction->rollBack();
+ }
+ catch (\ErrorException $e) {
+ $this->assertSame('Transaction::rollBack() failed because of a prior execution of a DDL statement.', $e->getMessage());
+ }
+ finally {
+ restore_error_handler();
+ }
+
+ try {
+ $transaction->commitOrRelease();
+ $this->fail('TransactionOutOfOrderException was expected, but did not throw.');
+ }
+ catch (TransactionOutOfOrderException) {
+ // Just continue, the attempted rollback made the overall state to
+ // ClientConnectionTransactionState::RollbackFailed.
+ }
+
+ $manager = $this->connection->transactionManager();
+ $this->assertSame(0, $manager->stackDepth());
+ $reflectedTransactionState = new \ReflectionMethod($manager, 'getConnectionTransactionState');
+ $this->assertSame(ClientConnectionTransactionState::RollbackFailed, $reflectedTransactionState->invoke($manager));
+ $this->assertRowPresent('row');
+ }
+
+ /**
+ * Inserts a single row into the testing table.
+ */
+ protected function insertRow(string $name): void {
+ $this->connection->insert('test')
+ ->fields([
+ 'name' => $name,
+ ])
+ ->execute();
+ }
+
+ /**
+ * Executes a DDL statement.
+ */
+ protected function executeDDLStatement(): void {
+ static $count = 0;
+ $table = [
+ 'fields' => [
+ 'id' => [
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ],
+ ],
+ 'primary key' => ['id'],
+ ];
+ $this->connection->schema()->createTable('database_test_' . ++$count, $table);
+ }
+
+ /**
+ * Starts over for a new test.
+ */
+ protected function cleanUp(): void {
+ $this->connection->truncate('test')
+ ->execute();
+ $this->postTransactionCallbackAction = NULL;
+ $this->assertSame(0, $this->connection->transactionManager()->stackDepth());
+ }
+
+ /**
+ * Asserts that a given row is present in the test table.
+ *
+ * @param string $name
+ * The name of the row.
+ * @param string $message
+ * The message to log for the assertion.
+ *
+ * @internal
+ */
+ public function assertRowPresent(string $name, ?string $message = NULL): void {
+ $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE [name] = :name', [':name' => $name])->fetchField();
+ $this->assertTrue($present, $message ?? "Row '{$name}' should be present, but it actually does not exist.");
+ }
+
+ /**
+ * Asserts that a given row is absent from the test table.
+ *
+ * @param string $name
+ * The name of the row.
+ * @param string $message
+ * The message to log for the assertion.
+ *
+ * @internal
+ */
+ public function assertRowAbsent(string $name, ?string $message = NULL): void {
+ $present = (boolean) $this->connection->query('SELECT 1 FROM {test} WHERE [name] = :name', [':name' => $name])->fetchField();
+ $this->assertFalse($present, $message ?? "Row '{$name}' should be absent, but it actually exists.");
+ }
+
+ /**
+ * Tests transaction stacking, commit, and rollback.
+ */
+ public function testTransactionStacking(): void {
+ // Standard case: pop the inner transaction before the outer transaction.
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->insertRow('outer');
+ $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
+ $this->insertRow('inner');
+ // Pop the inner transaction.
+ $transaction2->commitOrRelease();
+ $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the inner transaction');
+ // Pop the outer transaction.
+ $transaction->commitOrRelease();
+ $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the outer transaction');
+ $this->assertRowPresent('outer');
+ $this->assertRowPresent('inner');
+
+ // Rollback the inner transaction.
+ $this->cleanUp();
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->insertRow('outer');
+ $transaction2 = $this->createFirstSavepointTransaction('', FALSE);
+ $this->insertRow('inner');
+ // Now rollback the inner transaction.
+ $transaction2->rollBack();
+ $this->assertTrue($this->connection->inTransaction(), 'Still in a transaction after popping the outer transaction');
+ // Pop the outer transaction, it should commit.
+ $this->insertRow('outer-after-inner-rollback');
+ $transaction->commitOrRelease();
+ $this->assertFalse($this->connection->inTransaction(), 'Transaction closed after popping the inner transaction');
+ $this->assertRowPresent('outer');
+ $this->assertRowAbsent('inner');
+ $this->assertRowPresent('outer-after-inner-rollback');
+ }
+
+ /**
+ * Tests that transactions can continue to be used if a query fails.
+ */
+ public function testQueryFailureInTransaction(): void {
+ $transaction = $this->createRootTransaction('test_transaction', FALSE);
+ $this->connection->schema()->dropTable('test');
+
+ // Test a failed query using the query() method.
+ try {
+ $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'David'])->fetchField();
+ $this->fail('Using the query method should have failed.');
+ }
+ catch (\Exception) {
+ // Just continue testing.
+ }
+
+ // Test a failed select query.
+ try {
+ $this->connection->select('test')
+ ->fields('test', ['name'])
+ ->execute();
+
+ $this->fail('Select query should have failed.');
+ }
+ catch (\Exception) {
+ // Just continue testing.
+ }
+
+ // Test a failed insert query.
+ try {
+ $this->connection->insert('test')
+ ->fields([
+ 'name' => 'David',
+ 'age' => '24',
+ ])
+ ->execute();
+
+ $this->fail('Insert query should have failed.');
+ }
+ catch (\Exception) {
+ // Just continue testing.
+ }
+
+ // Test a failed update query.
+ try {
+ $this->connection->update('test')
+ ->fields(['name' => 'Tiffany'])
+ ->condition('id', 1)
+ ->execute();
+
+ $this->fail('Update query should have failed.');
+ }
+ catch (\Exception) {
+ // Just continue testing.
+ }
+
+ // Test a failed delete query.
+ try {
+ $this->connection->delete('test')
+ ->condition('id', 1)
+ ->execute();
+
+ $this->fail('Delete query should have failed.');
+ }
+ catch (\Exception) {
+ // Just continue testing.
+ }
+
+ // Test a failed merge query.
+ try {
+ $this->connection->merge('test')
+ ->key('job', 'Presenter')
+ ->fields([
+ 'age' => '31',
+ 'name' => 'Tiffany',
+ ])
+ ->execute();
+
+ $this->fail('Merge query should have failed.');
+ }
+ catch (\Exception) {
+ // Just continue testing.
+ }
+
+ // Test a failed upsert query.
+ try {
+ $this->connection->upsert('test')
+ ->key('job')
+ ->fields(['job', 'age', 'name'])
+ ->values([
+ 'job' => 'Presenter',
+ 'age' => 31,
+ 'name' => 'Tiffany',
+ ])
+ ->execute();
+
+ $this->fail('Upsert query should have failed.');
+ }
+ catch (\Exception) {
+ // Just continue testing.
+ }
+
+ // Create the missing schema and insert a row.
+ $this->installSchema('database_test', ['test']);
+ $this->connection->insert('test')
+ ->fields([
+ 'name' => 'David',
+ 'age' => '24',
+ ])
+ ->execute();
+
+ // Commit the transaction.
+ if ($this->connection->supportsTransactionalDDL()) {
+ $transaction->commitOrRelease();
+ }
+ else {
+ set_error_handler(static function (int $errno, string $errstr): bool {
+ throw new \ErrorException($errstr);
+ });
+ try {
+ $transaction->commitOrRelease();
+ }
+ catch (\ErrorException $e) {
+ $this->assertSame('Transaction::commitOrRelease() was not processed because a prior execution of a DDL statement already committed the transaction.', $e->getMessage());
+ }
+ finally {
+ restore_error_handler();
+ }
+ }
+
+ $saved_age = $this->connection->query('SELECT [age] FROM {test} WHERE [name] = :name', [':name' => 'David'])->fetchField();
+ $this->assertEquals('24', $saved_age);
+ }
+
+ /**
+ * Tests releasing a savepoint before last is safe.
+ */
+ public function testReleaseIntermediateSavepoint(): void {
+ $transaction = $this->createRootTransaction();
+ $savepoint1 = $this->createFirstSavepointTransaction('', FALSE);
+
+ // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_2'
+ // on the database.
+ $savepoint2 = $this->connection->startTransaction();
+ $this->assertSame(3, $this->connection->transactionManager()->stackDepth());
+ // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_3'
+ // on the database.
+ // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis
+ $savepoint3 = $this->connection->startTransaction();
+ $this->assertSame(4, $this->connection->transactionManager()->stackDepth());
+ // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_4'
+ // on the database.
+ // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis
+ $savepoint4 = $this->connection->startTransaction();
+ $this->assertSame(5, $this->connection->transactionManager()->stackDepth());
+
+ $this->insertRow('row');
+
+ // Release savepoint transaction. Corresponds to 'RELEASE SAVEPOINT
+ // savepoint_2' on the database.
+ $savepoint2->commitOrRelease();
+ // Since we have committed an intermediate savepoint Transaction object,
+ // the savepoints created later have been dropped by the database already.
+ $this->assertSame(2, $this->connection->transactionManager()->stackDepth());
+ $this->assertRowPresent('row');
+
+ // Commit the remaining Transaction objects. The client transaction is
+ // eventually committed.
+ $savepoint1->commitOrRelease();
+ $transaction->commitOrRelease();
+ $this->assertFalse($this->connection->inTransaction());
+ $this->assertRowPresent('row');
+ }
+
+ /**
+ * Tests committing a transaction while savepoints are active.
+ */
+ public function testCommitWithActiveSavepoint(): void {
+ $transaction = $this->createRootTransaction();
+ // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis
+ $savepoint1 = $this->createFirstSavepointTransaction('', FALSE);
+
+ // Starts a savepoint transaction. Corresponds to 'SAVEPOINT savepoint_2'
+ // on the database.
+ $savepoint2 = $this->connection->startTransaction();
+ $this->assertSame(3, $this->connection->transactionManager()->stackDepth());
+
+ $this->insertRow('row');
+
+ // Commit the root transaction.
+ $transaction->commitOrRelease();
+ // Since we have committed the outer (root) Transaction object, the inner
+ // (savepoint) ones have been dropped by the database already, and we are
+ // no longer in an active transaction state.
+ $this->assertSame(0, $this->connection->transactionManager()->stackDepth());
+ $this->assertFalse($this->connection->inTransaction());
+ $this->assertRowPresent('row');
+ // Trying to release the inner (savepoint) Transaction object, throws an
+ // exception since it was dropped by the database already, and removed from
+ // our transaction stack.
+ $this->expectException(TransactionOutOfOrderException::class);
+ $this->expectExceptionMessageMatches("/^Error attempting commit of .*\\\\savepoint_2\\. Active stack: .* empty/");
+ $savepoint2->commitOrRelease();
+ }
+
+ /**
+ * Tests for transaction names.
+ */
+ public function testTransactionName(): void {
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->assertSame('drupal_transaction', $transaction->name());
+
+ $savepoint1 = $this->createFirstSavepointTransaction('', FALSE);
+ $this->assertSame('savepoint_1', $savepoint1->name());
+
+ $this->expectException(TransactionNameNonUniqueException::class);
+ $this->expectExceptionMessage("savepoint_1 is already in use.");
+ $this->connection->startTransaction('savepoint_1');
+ }
+
+ /**
+ * Tests for arbitrary transaction names.
+ */
+ public function testArbitraryTransactionNames(): void {
+ $transaction = $this->createRootTransaction('TinkyWinky', FALSE);
+ // Despite setting a name, the root transaction is always named
+ // 'drupal_transaction'.
+ $this->assertSame('drupal_transaction', $transaction->name());
+
+ $savepoint1 = $this->createFirstSavepointTransaction('Dipsy', FALSE);
+ $this->assertSame('Dipsy', $savepoint1->name());
+
+ $this->expectException(TransactionNameNonUniqueException::class);
+ $this->expectExceptionMessage("Dipsy is already in use.");
+ $this->connection->startTransaction('Dipsy');
+ }
+
+ /**
+ * Tests that adding a post-transaction callback fails with no transaction.
+ */
+ public function testRootTransactionEndCallbackAddedWithoutTransaction(): void {
+ $this->expectException(\LogicException::class);
+ $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']);
+ }
+
+ /**
+ * Tests post-transaction callback executes after transaction commit.
+ */
+ public function testRootTransactionEndCallbackCalledOnCommit(): void {
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']);
+ $this->insertRow('row');
+ $this->assertNull($this->postTransactionCallbackAction);
+ $this->assertRowAbsent('rtcCommit');
+
+ // Callbacks are processed only when destructing the transaction.
+ // Executing a commit is not sufficient by itself.
+ $transaction->commitOrRelease();
+ $this->assertNull($this->postTransactionCallbackAction);
+ $this->assertRowPresent('row');
+ $this->assertRowAbsent('rtcCommit');
+
+ // Destruct the transaction.
+ unset($transaction);
+
+ // The post-transaction callback should now have inserted a 'rtcCommit'
+ // row.
+ $this->assertSame('rtcCommit', $this->postTransactionCallbackAction);
+ $this->assertRowPresent('row');
+ $this->assertRowPresent('rtcCommit');
+ }
+
+ /**
+ * Tests post-transaction callback executes after transaction rollback.
+ */
+ public function testRootTransactionEndCallbackCalledAfterRollbackAndDestruction(): void {
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']);
+ $this->insertRow('row');
+ $this->assertNull($this->postTransactionCallbackAction);
+
+ // Callbacks are processed only when destructing the transaction.
+ // Executing a rollback is not sufficient by itself.
+ $transaction->rollBack();
+ $this->assertNull($this->postTransactionCallbackAction);
+ $this->assertRowAbsent('rtcCommit');
+ $this->assertRowAbsent('rtcRollback');
+ $this->assertRowAbsent('row');
+
+ // Destruct the transaction.
+ unset($transaction);
+
+ // The post-transaction callback should now have inserted a 'rtcRollback'
+ // row.
+ $this->assertSame('rtcRollback', $this->postTransactionCallbackAction);
+ $this->assertRowAbsent('rtcCommit');
+ $this->assertRowPresent('rtcRollback');
+ $this->assertRowAbsent('row');
+ }
+
+ /**
+ * Tests post-transaction callback executes after a DDL statement.
+ */
+ public function testRootTransactionEndCallbackCalledAfterDdlAndDestruction(): void {
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']);
+ $this->insertRow('row');
+ $this->assertNull($this->postTransactionCallbackAction);
+
+ // Callbacks are processed only when destructing the transaction.
+ // Executing a DDL statement is not sufficient itself.
+ // We cannot use truncate here, since it has protective code to fall back
+ // to a transactional delete when in transaction. We drop an unrelated
+ // table instead.
+ $this->connection->schema()->dropTable('test_people');
+ $this->assertNull($this->postTransactionCallbackAction);
+ $this->assertRowAbsent('rtcCommit');
+ $this->assertRowAbsent('rtcRollback');
+ $this->assertRowPresent('row');
+
+ // Destruct the transaction.
+ unset($transaction);
+
+ // The post-transaction callback should now have inserted a 'rtcCommit'
+ // row.
+ $this->assertSame('rtcCommit', $this->postTransactionCallbackAction);
+ $this->assertRowPresent('rtcCommit');
+ $this->assertRowAbsent('rtcRollback');
+ $this->assertRowPresent('row');
+ }
+
+ /**
+ * Tests post-transaction rollback executes after a DDL statement.
+ *
+ * For database servers that support transactional DDL, a rollback of a
+ * transaction including DDL statements is possible.
+ */
+ public function testRootTransactionEndCallbackCalledAfterDdlAndRollbackForTransactionalDdlDatabase(): void {
+ if (!$this->connection->supportsTransactionalDDL()) {
+ $this->markTestSkipped('This test only works for database supporting transactional DDL.');
+ }
+
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']);
+ $this->insertRow('row');
+ $this->assertNull($this->postTransactionCallbackAction);
+
+ // Callbacks are processed only when destructing the transaction.
+ // Executing a DDL statement is not sufficient itself.
+ // We cannot use truncate here, since it has protective code to fall back
+ // to a transactional delete when in transaction. We drop an unrelated
+ // table instead.
+ $this->connection->schema()->dropTable('test_people');
+ $this->assertNull($this->postTransactionCallbackAction);
+ $this->assertRowAbsent('rtcCommit');
+ $this->assertRowAbsent('rtcRollback');
+ $this->assertRowPresent('row');
+
+ // Callbacks are processed only when destructing the transaction.
+ // Executing the rollback is not sufficient by itself.
+ $transaction->rollBack();
+ $this->assertNull($this->postTransactionCallbackAction);
+ $this->assertRowAbsent('rtcCommit');
+ $this->assertRowAbsent('rtcRollback');
+ $this->assertRowAbsent('row');
+
+ // Destruct the transaction.
+ unset($transaction);
+
+ // The post-transaction callback should now have inserted a 'rtcRollback'
+ // row.
+ $this->assertSame('rtcRollback', $this->postTransactionCallbackAction);
+ $this->assertRowAbsent('rtcCommit');
+ $this->assertRowPresent('rtcRollback');
+ $this->assertRowAbsent('row');
+ }
+
+ /**
+ * Tests post-transaction rollback failure after a DDL statement.
+ *
+ * For database servers that support transactional DDL, a rollback of a
+ * transaction including DDL statements is not possible, since a commit
+ * happened already. We cannot decide what should be the status of the
+ * callback, an exception is thrown.
+ */
+ public function testRootTransactionEndCallbackFailureUponDdlAndRollbackForNonTransactionalDdlDatabase(): void {
+ if ($this->connection->supportsTransactionalDDL()) {
+ $this->markTestSkipped('This test only works for database that do not support transactional DDL.');
+ }
+
+ $transaction = $this->createRootTransaction('', FALSE);
+ $this->connection->transactionManager()->addPostTransactionCallback([$this, 'rootTransactionCallback']);
+ $this->insertRow('row');
+ $this->assertNull($this->postTransactionCallbackAction);
+
+ // Callbacks are processed only when destructing the transaction.
+ // Executing a DDL statement is not sufficient itself.
+ // We cannot use truncate here, since it has protective code to fall back
+ // to a transactional delete when in transaction. We drop an unrelated
+ // table instead.
+ $this->connection->schema()->dropTable('test_people');
+ $this->assertNull($this->postTransactionCallbackAction);
+ $this->assertRowAbsent('rtcCommit');
+ $this->assertRowAbsent('rtcRollback');
+ $this->assertRowPresent('row');
+
+ set_error_handler(static function (int $errno, string $errstr): bool {
+ throw new \ErrorException($errstr);
+ });
+ try {
+ $transaction->rollBack();
+ }
+ catch (\ErrorException $e) {
+ $this->assertSame('Transaction::rollBack() failed because of a prior execution of a DDL statement.', $e->getMessage());
+ }
+ finally {
+ restore_error_handler();
+ }
+
+ unset($transaction);
+
+ // The post-transaction callback should now have inserted a 'rtcRollback'
+ // row.
+ $this->assertSame('rtcRollback', $this->postTransactionCallbackAction);
+ $this->assertRowAbsent('rtcCommit');
+ $this->assertRowPresent('rtcRollback');
+ $manager = $this->connection->transactionManager();
+ $this->assertSame(0, $manager->stackDepth());
+ $reflectedTransactionState = new \ReflectionMethod($manager, 'getConnectionTransactionState');
+ $this->assertSame(ClientConnectionTransactionState::RollbackFailed, $reflectedTransactionState->invoke($manager));
+ $this->assertRowPresent('row');
+ }
+
+ /**
+ * A post-transaction callback for testing purposes.
+ */
+ public function rootTransactionCallback(bool $success): void {
+ $this->postTransactionCallbackAction = $success ? 'rtcCommit' : 'rtcRollback';
+ $this->insertRow($this->postTransactionCallbackAction);
+ }
+
+ /**
+ * Tests TransactionManager failure.
+ */
+ public function testTransactionManagerFailureOnPendingStackItems(): void {
+ $connectionInfo = Database::getConnectionInfo();
+ Database::addConnectionInfo('default', 'test_fail', $connectionInfo['default']);
+ $testConnection = Database::getConnection('test_fail');
+
+ // Add a fake item to the stack.
+ $manager = $testConnection->transactionManager();
+ $reflectionMethod = new \ReflectionMethod($manager, 'addStackItem');
+ $reflectionMethod->invoke($manager, 'bar', new StackItem('qux', StackItemType::Root));
+ // Ensure transaction state can be determined during object destruction.
+ // This is necessary for the test to pass when xdebug.mode has the 'develop'
+ // option enabled.
+ $reflectionProperty = new \ReflectionProperty(TransactionManagerBase::class, 'connectionTransactionState');
+ $reflectionProperty->setValue($manager, ClientConnectionTransactionState::Active);
+
+ // Ensure that __destruct() results in an assertion error. Note that this
+ // will normally be called by PHP during the object's destruction but Drupal
+ // will commit all transactions when a database is closed thereby making
+ // this impossible to test unless it is called directly.
+ try {
+ $manager->__destruct();
+ $this->fail("Expected AssertionError error not thrown");
+ }
+ catch (\AssertionError $e) {
+ $this->assertStringStartsWith('Transaction $stack was not empty. Active stack: bar\\qux', $e->getMessage());
+ }
+
+ // Clean up.
+ $reflectionProperty = new \ReflectionProperty(TransactionManagerBase::class, 'stack');
+ $reflectionProperty->setValue($manager, []);
+ unset($testConnection);
+ Database::closeConnection('test_fail');
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php
new file mode 100644
index 00000000000..5e708fbbc2f
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php
@@ -0,0 +1,1036 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Entity;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\Core\Database\Database;
+use Drupal\Core\Database\IntegrityConstraintViolationException;
+use Drupal\Core\Entity\ContentEntityType;
+use Drupal\Core\Entity\EntityStorageException;
+use Drupal\Core\Entity\EntityTypeEvents;
+use Drupal\Core\Entity\Exception\FieldStorageDefinitionUpdateForbiddenException;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\Field\FieldException;
+use Drupal\Core\Field\FieldStorageDefinitionEvents;
+use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\entity_test\EntityTestHelper;
+use Drupal\entity_test\FieldStorageDefinition;
+use Drupal\entity_test_update\Entity\EntityTestUpdate;
+use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait;
+
+/**
+ * Tests EntityDefinitionUpdateManager functionality.
+ *
+ * @coversDefaultClass \Drupal\Core\Entity\EntityDefinitionUpdateManager
+ *
+ * @group Entity
+ * @group #slow
+ */
+class EntityDefinitionUpdateMultipleTypesTest extends EntityKernelTestBase {
+
+ use EntityDefinitionTestTrait;
+
+ /**
+ * The entity definition update manager.
+ *
+ * @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface
+ */
+ protected $entityDefinitionUpdateManager;
+
+ /**
+ * The entity field manager.
+ *
+ * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+ */
+ protected $entityFieldManager;
+
+ /**
+ * The database connection.
+ *
+ * @var \Drupal\Core\Database\Connection
+ */
+ protected $database;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = ['entity_test_update', 'language'];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->entityDefinitionUpdateManager = $this->container->get('entity.definition_update_manager');
+ $this->entityFieldManager = $this->container->get('entity_field.manager');
+ $this->database = $this->container->get('database');
+
+ // Install every entity type's schema that wasn't installed in the parent
+ // method.
+ foreach (array_diff_key($this->entityTypeManager->getDefinitions(), array_flip(['user', 'entity_test'])) as $entity_type_id => $entity_type) {
+ $this->installEntitySchema($entity_type_id);
+ }
+ }
+
+ /**
+ * Tests when no definition update is needed.
+ */
+ public function testNoUpdates(): void {
+ // Ensure that the definition update manager reports no updates.
+ $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that no updates are needed.');
+ $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports an empty change summary.');
+ $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeList(), 'EntityDefinitionUpdateManager reports an empty change list.');
+ }
+
+ /**
+ * Tests updating entity schema when there are no existing entities.
+ */
+ public function testEntityTypeUpdateWithoutData(): void {
+ // The 'entity_test_update' entity type starts out non-revisionable, so
+ // ensure the revision table hasn't been created during setUp().
+ $this->assertFalse($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table not created for entity_test_update.');
+
+ // Update it to be revisionable and ensure the definition update manager
+ // reports that an update is needed.
+ $this->updateEntityTypeToRevisionable();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel();
+ $expected = [
+ 'entity_test_update' => [
+ "The $entity_type entity type needs to be updated.",
+ // The revision key is now defined, so the revision field needs to be
+ // created.
+ 'The Revision ID field needs to be installed.',
+ 'The Default revision field needs to be installed.',
+ ],
+ ];
+ $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.');
+
+ // Run the update and ensure the revision table is created.
+ $this->updateEntityTypeToRevisionable(TRUE);
+ $this->assertTrue($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table created for entity_test_update.');
+ }
+
+ /**
+ * Tests updating entity schema when there are entity storage changes.
+ */
+ public function testEntityTypeUpdateWithEntityStorageChange(): void {
+ // Update the entity type to be revisionable and try to apply the update.
+ // It's expected to throw an exception.
+ $entity_type = $this->getUpdatedEntityTypeDefinition(TRUE, FALSE);
+ try {
+ $this->entityDefinitionUpdateManager->updateEntityType($entity_type);
+ $this->fail('EntityStorageException thrown when trying to apply an update that requires shared table schema changes.');
+ }
+ catch (EntityStorageException) {
+ // Expected exception; just continue testing.
+ }
+ }
+
+ /**
+ * Tests creating a fieldable entity type that doesn't exist in code anymore.
+ *
+ * @covers ::installFieldableEntityType
+ */
+ public function testInstallFieldableEntityTypeWithoutInCodeDefinition(): void {
+ $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update');
+ $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update');
+
+ // Remove the entity type definition. This is the same thing as removing the
+ // code that defines it.
+ $this->deleteEntityType();
+
+ // Install the entity type and check that its tables have been created.
+ $this->entityDefinitionUpdateManager->installFieldableEntityType($entity_type, $field_storage_definitions);
+ $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'The base table of the entity type has been created.');
+ }
+
+ /**
+ * Tests updating an entity type that doesn't exist in code anymore.
+ *
+ * @covers ::updateEntityType
+ */
+ public function testUpdateEntityTypeWithoutInCodeDefinition(): void {
+ $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update');
+
+ // Remove the entity type definition. This is the same thing as removing the
+ // code that defines it.
+ $this->deleteEntityType();
+
+ // Add an entity index, update the entity type and check that the index has
+ // been created.
+ $this->addEntityIndex();
+ $this->entityDefinitionUpdateManager->updateEntityType($entity_type);
+
+ $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.');
+ }
+
+ /**
+ * Tests updating a fieldable entity type that doesn't exist in code anymore.
+ *
+ * @covers ::updateFieldableEntityType
+ */
+ public function testUpdateFieldableEntityTypeWithoutInCodeDefinition(): void {
+ $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update');
+ $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update');
+
+ // Remove the entity type definition. This is the same thing as removing the
+ // code that defines it.
+ $this->deleteEntityType();
+
+ // Rename the base table, update the fieldable entity type and check that
+ // the table has been renamed.
+ $entity_type->set('base_table', 'entity_test_update_new');
+ $this->entityDefinitionUpdateManager->updateFieldableEntityType($entity_type, $field_storage_definitions);
+
+ $this->assertTrue($this->database->schema()->tableExists('entity_test_update_new'), 'The base table has been renamed.');
+ $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'The old base table does not exist anymore.');
+ }
+
+ /**
+ * Tests uninstalling an entity type that doesn't exist in code anymore.
+ *
+ * @covers ::uninstallEntityType
+ */
+ public function testUninstallEntityTypeWithoutInCodeDefinition(): void {
+ $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update');
+
+ // Remove the entity type definition. This is the same thing as removing the
+ // code that defines it.
+ $this->deleteEntityType();
+
+ // Now uninstall it and check that the tables have been removed.
+ $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.');
+ $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type);
+ $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.');
+ }
+
+ /**
+ * Tests uninstalling a revisionable entity type that doesn't exist in code.
+ *
+ * @covers ::uninstallEntityType
+ */
+ public function testUninstallRevisionableEntityTypeWithoutInCodeDefinition(): void {
+ $this->updateEntityTypeToRevisionable(TRUE);
+ $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update');
+
+ // Remove the entity type definition. This is the same thing as removing the
+ // code that defines it.
+ $this->deleteEntityType();
+
+ // Now uninstall it and check that the tables have been removed.
+ $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.');
+ $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type);
+ $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.');
+ }
+
+ /**
+ * Tests creating, updating, and deleting a base field if no entities exist.
+ */
+ public function testBaseFieldCreateUpdateDeleteWithoutData(): void {
+ // Add a base field, ensure the update manager reports it, and the update
+ // creates its schema.
+ $this->addBaseField();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The A new base field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+ $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.');
+
+ // Add an index on the base field, ensure the update manager reports it,
+ // and the update creates it.
+ $this->addBaseFieldIndex();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+ $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index created.');
+
+ // Remove the above index, ensure the update manager reports it, and the
+ // update deletes it.
+ $this->removeBaseFieldIndex();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+ $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index deleted.');
+
+ // Update the type of the base field from 'string' to 'text', ensure the
+ // update manager reports it, and the update adjusts the schema
+ // accordingly.
+ $this->modifyBaseField();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+ $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Original column deleted in shared table for new_base_field.');
+ $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__value'), 'Value column created in shared table for new_base_field.');
+ $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__format'), 'Format column created in shared table for new_base_field.');
+
+ // Remove the base field, ensure the update manager reports it, and the
+ // update deletes the schema.
+ $this->removeBaseField();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The A new base field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+ $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_value'), 'Value column deleted from shared table for new_base_field.');
+ $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_format'), 'Format column deleted from shared table for new_base_field.');
+ }
+
+ /**
+ * Tests creating, updating, and deleting a base field with no label set.
+ *
+ * See testBaseFieldCreateUpdateDeleteWithoutData() for more details
+ */
+ public function testBaseFieldWithoutLabelCreateUpdateDelete(): void {
+ // Add a base field, ensure the update manager reports it with the
+ // field id.
+ $this->addBaseField('string', 'entity_test_update', FALSE, FALSE);
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The new_base_field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+
+ // Add an index on the base field, ensure the update manager reports it with
+ // the field id.
+ $this->addBaseFieldIndex();
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The new_base_field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+
+ // Remove the base field, ensure the update manager reports it with the
+ // field id.
+ $this->removeBaseField();
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The new_base_field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0]));
+ }
+
+ /**
+ * Tests creating, updating, and deleting a bundle field if no entities exist.
+ */
+ public function testBundleFieldCreateUpdateDeleteWithoutData(): void {
+ // Add a bundle field, ensure the update manager reports it, and the update
+ // creates its schema.
+ $this->addBundleField();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The A new bundle field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+ $this->assertTrue($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.');
+
+ // Update the type of the base field from 'string' to 'text', ensure the
+ // update manager reports it, and the update adjusts the schema
+ // accordingly.
+ $this->modifyBundleField();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The A new bundle field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+ $this->assertTrue($this->database->schema()->fieldExists('entity_test_update__new_bundle_field', 'new_bundle_field_format'), 'Format column created in dedicated table for new_base_field.');
+
+ // Remove the bundle field, ensure the update manager reports it, and the
+ // update deletes the schema.
+ $this->removeBundleField();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
+ $this->assertCount(1, $changes['entity_test_update']);
+ $this->assertEquals('The A new bundle field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0]));
+ $this->applyEntityUpdates();
+ $this->assertFalse($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.');
+ }
+
+ /**
+ * Tests creating and deleting a base field if entities exist.
+ *
+ * This tests deletion when there are existing entities, but non-existent data
+ * for the field being deleted.
+ *
+ * @see testBaseFieldDeleteWithExistingData()
+ */
+ public function testBaseFieldCreateDeleteWithExistingEntities(): void {
+ // Save an entity.
+ $name = $this->randomString();
+ $storage = $this->entityTypeManager->getStorage('entity_test_update');
+ $entity = $storage->create(['name' => $name]);
+ $entity->save();
+
+ // Add a base field and run the update. Ensure the base field's column is
+ // created and the prior saved entity data is still there.
+ $this->addBaseField();
+ $this->applyEntityUpdates();
+ $schema_handler = $this->database->schema();
+ $this->assertTrue($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.');
+ $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id());
+ $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.');
+
+ // Remove the base field and run the update. Ensure the base field's column
+ // is deleted and the prior saved entity data is still there.
+ $this->removeBaseField();
+ $this->applyEntityUpdates();
+ $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column deleted from shared table for new_base_field.');
+ $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id());
+ $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.');
+
+ // Add a base field with a required property and run the update. Ensure
+ // 'not null' is not applied and thus no exception is thrown.
+ $this->addBaseField('shape_required');
+ $this->applyEntityUpdates();
+ $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color');
+ $this->assertTrue($assert, 'Columns created in shared table for new_base_field.');
+
+ // Recreate the field after emptying the base table and check that its
+ // columns are not 'not null'.
+ // @todo Revisit this test when allowing for required storage field
+ // definitions. See https://www.drupal.org/node/2390495.
+ $entity->delete();
+ $this->removeBaseField();
+ $this->applyEntityUpdates();
+ $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__shape'), 'Shape column should be removed from the shared table for new_base_field.');
+ $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__color'), 'Color column should be removed from the shared table for new_base_field.');
+ $this->addBaseField('shape_required');
+ $this->applyEntityUpdates();
+ $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color');
+ $this->assertTrue($assert, 'Columns created again in shared table for new_base_field.');
+ $entity = $storage->create(['name' => $name]);
+ $entity->save();
+ }
+
+ /**
+ * Tests creating and deleting a bundle field if entities exist.
+ *
+ * This tests deletion when there are existing entities, but non-existent data
+ * for the field being deleted.
+ *
+ * @see testBundleFieldDeleteWithExistingData()
+ */
+ public function testBundleFieldCreateDeleteWithExistingEntities(): void {
+ // Save an entity.
+ $name = $this->randomString();
+ $storage = $this->entityTypeManager->getStorage('entity_test_update');
+ $entity = $storage->create(['name' => $name]);
+ $entity->save();
+
+ // Add a bundle field and run the update. Ensure the bundle field's table
+ // is created and the prior saved entity data is still there.
+ $this->addBundleField();
+ $this->applyEntityUpdates();
+ $schema_handler = $this->database->schema();
+ $this->assertTrue($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.');
+ $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id());
+ $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.');
+
+ // Remove the base field and run the update. Ensure the bundle field's
+ // table is deleted and the prior saved entity data is still there.
+ $this->removeBundleField();
+ $this->applyEntityUpdates();
+ $this->assertFalse($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.');
+ $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id());
+ $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.');
+
+ // Test that required columns are created as 'not null'.
+ $this->addBundleField('shape_required');
+ $this->applyEntityUpdates();
+ $message = 'The new_bundle_field_shape column is not nullable.';
+ $values = [
+ 'bundle' => $entity->bundle(),
+ 'deleted' => 0,
+ 'entity_id' => $entity->id(),
+ 'revision_id' => $entity->id(),
+ 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+ 'delta' => 0,
+ 'new_bundle_field_color' => $this->randomString(),
+ ];
+ try {
+ // Try to insert a record without providing a value for the 'not null'
+ // column. This should fail.
+ $this->database->insert('entity_test_update__new_bundle_field')
+ ->fields($values)
+ ->execute();
+ $this->fail($message);
+ }
+ catch (IntegrityConstraintViolationException) {
+ // Now provide a value for the 'not null' column. This is expected to
+ // succeed.
+ $values['new_bundle_field_shape'] = $this->randomString();
+ $this->database->insert('entity_test_update__new_bundle_field')
+ ->fields($values)
+ ->execute();
+ }
+ }
+
+ /**
+ * Tests deleting a bundle field when it has existing data.
+ */
+ public function testBundleFieldDeleteWithExistingData(): void {
+ /** @var \Drupal\Core\Entity\Sql\SqlEntityStorageInterface $storage */
+ $storage = $this->entityTypeManager->getStorage('entity_test_update');
+ $schema_handler = $this->database->schema();
+
+ // Add the bundle field and run the update.
+ $this->addBundleField();
+ $this->applyEntityUpdates();
+
+ /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
+ $table_mapping = $storage->getTableMapping();
+ $storage_definition = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledFieldStorageDefinitions('entity_test_update')['new_bundle_field'];
+
+ // Check that the bundle field has a dedicated table.
+ $dedicated_table_name = $table_mapping->getDedicatedDataTableName($storage_definition);
+ $this->assertTrue($schema_handler->tableExists($dedicated_table_name), 'The bundle field uses a dedicated table.');
+
+ // Save an entity with the bundle field populated.
+ EntityTestHelper::createBundle('custom');
+ $entity = $storage->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo']);
+ $entity->save();
+
+ // Remove the bundle field and apply updates.
+ $this->removeBundleField();
+ $this->applyEntityUpdates();
+
+ // Check that the table of the bundle field has been renamed to use a
+ // 'deleted' table name.
+ $this->assertFalse($schema_handler->tableExists($dedicated_table_name), 'The dedicated table of the bundle field no longer exists.');
+
+ $dedicated_deleted_table_name = $table_mapping->getDedicatedDataTableName($storage_definition, TRUE);
+ $this->assertTrue($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle fields has been renamed to use the "deleted" name.');
+
+ // Check that the deleted field's data is preserved in the dedicated
+ // 'deleted' table.
+ $result = $this->database->select($dedicated_deleted_table_name, 't')
+ ->fields('t')
+ ->execute()
+ ->fetchAll();
+ $this->assertCount(1, $result);
+
+ $expected = [
+ 'bundle' => $entity->bundle(),
+ 'deleted' => '1',
+ 'entity_id' => $entity->id(),
+ 'revision_id' => $entity->id(),
+ 'langcode' => $entity->language()->getId(),
+ 'delta' => '0',
+ 'new_bundle_field_value' => $entity->new_bundle_field->value,
+ ];
+ // Use assertEquals and not assertSame here to prevent that a different
+ // sequence of the columns in the table will affect the check.
+ $this->assertEquals($expected, (array) $result[0]);
+
+ // Check that the field definition is marked for purging.
+ $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions();
+ $this->assertArrayHasKey($storage_definition->getUniqueIdentifier(), $deleted_field_definitions, 'The bundle field is marked for purging.');
+
+ // Check that the field storage definition is marked for purging.
+ $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions();
+ $this->assertArrayHasKey($storage_definition->getUniqueStorageIdentifier(), $deleted_storage_definitions, 'The bundle field storage is marked for purging.');
+
+ // Purge field data, and check that the storage definition has been
+ // completely removed once the data is purged.
+ field_purge_batch(10);
+ $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions();
+ $this->assertEmpty($deleted_field_definitions, 'The bundle field has been deleted.');
+ $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions();
+ $this->assertEmpty($deleted_storage_definitions, 'The bundle field storage has been deleted.');
+ $this->assertFalse($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle field has been removed.');
+ }
+
+ /**
+ * Tests updating a base field when it has existing data.
+ */
+ public function testBaseFieldUpdateWithExistingData(): void {
+ // Add the base field and run the update.
+ $this->addBaseField();
+ $this->applyEntityUpdates();
+
+ // Save an entity with the base field populated.
+ $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => 'foo'])->save();
+
+ // Change the field's field type and apply updates. It's expected to
+ // throw an exception.
+ $this->modifyBaseField();
+ try {
+ $this->applyEntityUpdates();
+ $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.');
+ }
+ catch (FieldStorageDefinitionUpdateForbiddenException) {
+ // Expected exception; just continue testing.
+ }
+ }
+
+ /**
+ * Tests updating a bundle field when it has existing data.
+ */
+ public function testBundleFieldUpdateWithExistingData(): void {
+ // Add the bundle field and run the update.
+ $this->addBundleField();
+ $this->applyEntityUpdates();
+
+ // Save an entity with the bundle field populated.
+ EntityTestHelper::createBundle('custom');
+ $this->entityTypeManager->getStorage('entity_test_update')->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo'])->save();
+
+ // Change the field's field type and apply updates. It's expected to
+ // throw an exception.
+ $this->modifyBundleField();
+ try {
+ $this->applyEntityUpdates();
+ $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.');
+ }
+ catch (FieldStorageDefinitionUpdateForbiddenException) {
+ // Expected exception; just continue testing.
+ }
+ }
+
+ /**
+ * Tests updating a bundle field when the entity type schema has changed.
+ */
+ public function testBundleFieldUpdateWithEntityTypeSchemaUpdate(): void {
+ // Add the bundle field and run the update.
+ $this->addBundleField();
+ $this->applyEntityUpdates();
+
+ // Update the entity type schema to revisionable but don't run the updates
+ // yet.
+ $this->updateEntityTypeToRevisionable();
+
+ // Perform a no-op update on the bundle field, which should work because
+ // both the storage and the storage schema are using the last installed
+ // entity type definition.
+ $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager();
+ $entity_definition_update_manager->updateFieldStorageDefinition($entity_definition_update_manager->getFieldStorageDefinition('new_bundle_field', 'entity_test_update'));
+ }
+
+ /**
+ * Tests creating and deleting a multi-field index when there are no existing entities.
+ */
+ public function testEntityIndexCreateDeleteWithoutData(): void {
+ // Add an entity index and ensure the update manager reports that as an
+ // update to the entity type.
+ $this->addEntityIndex();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel();
+ $expected = [
+ 'entity_test_update' => [
+ "The $entity_type entity type needs to be updated.",
+ ],
+ ];
+ $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.');
+
+ // Run the update and ensure the new index is created.
+ $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
+ $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
+ \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
+ $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.');
+
+ // Remove the index and ensure the update manager reports that as an
+ // update to the entity type.
+ $this->removeEntityIndex();
+ $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
+ $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel();
+ $expected = [
+ 'entity_test_update' => [
+ "The $entity_type entity type needs to be updated.",
+ ],
+ ];
+ $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.');
+
+ // Run the update and ensure the index is deleted.
+ $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
+ $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
+ \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
+ $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.');
+
+ // Test that composite indexes are handled correctly when dropping and
+ // re-creating one of their columns.
+ $this->addEntityIndex();
+ $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
+ $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
+ \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
+
+ $storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition('name', 'entity_test_update');
+ $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition);
+ $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.');
+ $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition);
+ $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.');
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition('name', 'entity_test_update', 'entity_test', $storage_definition);
+ $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created again.');
+ }
+
+ /**
+ * Tests creating a multi-field index when there are existing entities.
+ */
+ public function testEntityIndexCreateWithData(): void {
+ // Save an entity.
+ $name = $this->randomString();
+ $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(['name' => $name]);
+ $entity->save();
+
+ // Add an entity index, run the update. Ensure that the index is created
+ // despite having data.
+ $this->addEntityIndex();
+ $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
+ $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
+ \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
+ $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index added.');
+ }
+
+ /**
+ * Tests applying single updates.
+ */
+ public function testSingleActionCalls(): void {
+ $db_schema = $this->database->schema();
+
+ // Ensure that a non-existing entity type cannot be installed.
+ $message = 'A non-existing entity type cannot be installed';
+ try {
+ $this->entityDefinitionUpdateManager->installEntityType(new ContentEntityType(['id' => 'foo']));
+ $this->fail($message);
+ }
+ catch (PluginNotFoundException) {
+ // Expected exception; just continue testing.
+ }
+
+ // Ensure that a field cannot be installed on non-existing entity type.
+ $message = 'A field cannot be installed on a non-existing entity type';
+ try {
+ $storage_definition = BaseFieldDefinition::create('string')
+ ->setLabel('A new revisionable base field')
+ ->setRevisionable(TRUE);
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition('bar', 'foo', 'entity_test', $storage_definition);
+ $this->fail($message);
+ }
+ catch (PluginNotFoundException) {
+ // Expected exception; just continue testing.
+ }
+
+ // Ensure that installing an existing entity type is a no-op.
+ $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update');
+ $this->entityDefinitionUpdateManager->installEntityType($entity_type);
+ $this->assertTrue($db_schema->tableExists('entity_test_update'), 'Installing an existing entity type is a no-op');
+
+ // Create a new base field.
+ $this->addRevisionableBaseField();
+ $storage_definition = BaseFieldDefinition::create('string')
+ ->setLabel('A new revisionable base field')
+ ->setRevisionable(TRUE);
+ $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update.");
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
+ $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table.");
+
+ // Ensure that installing an existing field is a no-op.
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
+ $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), 'Installing an existing field is a no-op');
+
+ // Update an existing field schema.
+ $this->modifyBaseField();
+ $storage_definition = BaseFieldDefinition::create('text')
+ ->setName('new_base_field')
+ ->setTargetEntityTypeId('entity_test_update')
+ ->setLabel('A new revisionable base field')
+ ->setRevisionable(TRUE);
+ $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition);
+ $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "Previous schema for 'new_base_field' no longer exists.");
+ $this->assertTrue(
+ $db_schema->fieldExists('entity_test_update', 'new_base_field__value') && $db_schema->fieldExists('entity_test_update', 'new_base_field__format'),
+ "New schema for 'new_base_field' has been created."
+ );
+
+ // Drop an existing field schema.
+ $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition);
+ $this->assertFalse(
+ $db_schema->fieldExists('entity_test_update', 'new_base_field__value') || $db_schema->fieldExists('entity_test_update', 'new_base_field__format'),
+ "The schema for 'new_base_field' has been dropped."
+ );
+
+ // Make the entity type revisionable.
+ $this->updateEntityTypeToRevisionable();
+ $this->assertFalse($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' does not exist before applying the update.");
+
+ $this->updateEntityTypeToRevisionable(TRUE);
+ $this->assertTrue($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' table has been created.");
+ }
+
+ /**
+ * Ensures that a new field and index on a shared table are created.
+ *
+ * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::createSharedTableSchema
+ */
+ public function testCreateFieldAndIndexOnSharedTable(): void {
+ $this->addBaseField();
+ $this->addBaseFieldIndex();
+ $this->applyEntityUpdates();
+ $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table.");
+ $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), "New index 'entity_test_update_field__new_base_field' has been created on the 'entity_test_update' table.");
+ // Check index size in for MySQL.
+ if (Database::getConnection()->driver() == 'mysql') {
+ $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update_field__new_base_field\' and column_name = \'new_base_field\'')->fetchObject();
+ $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.');
+ }
+ }
+
+ /**
+ * Ensures that a new entity level index is created when data exists.
+ *
+ * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::onEntityTypeUpdate
+ */
+ public function testCreateIndexUsingEntityStorageSchemaWithData(): void {
+ // Save an entity.
+ $name = $this->randomString();
+ $storage = $this->entityTypeManager->getStorage('entity_test_update');
+ $entity = $storage->create(['name' => $name]);
+ $entity->save();
+
+ // Create an index.
+ $indexes = [
+ 'entity_test_update__type_index' => ['type'],
+ ];
+ $this->state->set('entity_test_update.additional_entity_indexes', $indexes);
+ $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
+ $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
+ \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
+
+ $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__type_index'), "New index 'entity_test_update__type_index' has been created on the 'entity_test_update' table.");
+ // Check index size in for MySQL.
+ if (Database::getConnection()->driver() == 'mysql') {
+ $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update__type_index\' and column_name = \'type\'')->fetchObject();
+ $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.');
+ }
+ }
+
+ /**
+ * Tests updating a base field when it has existing data.
+ */
+ public function testBaseFieldEntityKeyUpdateWithExistingData(): void {
+ // Add the base field and run the update.
+ $this->addBaseField();
+ $this->applyEntityUpdates();
+
+ // Save an entity with the base field populated.
+ $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => $this->randomString()])->save();
+
+ // Save an entity with the base field not populated.
+ /** @var \Drupal\entity_test\Entity\EntityTestUpdate $entity */
+ $entity = $this->entityTypeManager->getStorage('entity_test_update')->create();
+ $entity->save();
+
+ // Promote the base field to an entity key. This will trigger the addition
+ // of a NOT NULL constraint.
+ $this->makeBaseFieldEntityKey();
+
+ // Field storage CRUD operations use the last installed entity type
+ // definition so we need to update it before doing any other field storage
+ // updates.
+ $this->entityDefinitionUpdateManager->updateEntityType($this->state->get('entity_test_update.entity_type'));
+
+ // Try to apply the update and verify they fail since we have a NULL value.
+ $message = 'An error occurs when trying to enabling NOT NULL constraints with NULL data.';
+ try {
+ $this->applyEntityUpdates();
+ $this->fail($message);
+ }
+ catch (EntityStorageException) {
+ // Expected exception; just continue testing.
+ }
+
+ // Check that the update is correctly applied when no NULL data is left.
+ $entity->set('new_base_field', $this->randomString());
+ $entity->save();
+ $this->applyEntityUpdates();
+
+ // Check that the update actually applied a NOT NULL constraint.
+ $entity->set('new_base_field', NULL);
+ $message = 'The NOT NULL constraint was correctly applied.';
+ try {
+ $entity->save();
+ $this->fail($message);
+ }
+ catch (EntityStorageException) {
+ // Expected exception; just continue testing.
+ }
+ }
+
+ /**
+ * Check that field schema is correctly handled with long-named fields.
+ */
+ public function testLongNameFieldIndexes(): void {
+ $this->addLongNameBaseField();
+ $entity_type_id = 'entity_test_update';
+ $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+ $definitions = EntityTestUpdate::baseFieldDefinitions($entity_type);
+ $name = 'new_long_named_entity_reference_base_field';
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition($name, $entity_type_id, 'entity_test', $definitions[$name]);
+ $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'Entity and field schema data are correctly detected.');
+ }
+
+ /**
+ * Tests adding a base field with initial values.
+ */
+ public function testInitialValue(): void {
+ $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
+ $db_schema = $this->database->schema();
+
+ // Create two entities before adding the base field.
+ $storage->create()->save();
+ $storage->create()->save();
+
+ // Add a base field with an initial value.
+ $this->addBaseField();
+ $storage_definition = BaseFieldDefinition::create('string')
+ ->setLabel('A new base field')
+ ->setInitialValue('test value');
+
+ $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update.");
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
+ $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table.");
+
+ // Check that the initial values have been applied.
+ $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
+ $entities = $storage->loadMultiple();
+ $this->assertEquals('test value', $entities[1]->get('new_base_field')->value);
+ $this->assertEquals('test value', $entities[2]->get('new_base_field')->value);
+ }
+
+ /**
+ * Tests entity type and field storage definition events.
+ */
+ public function testDefinitionEvents(): void {
+ /** @var \Drupal\entity_test\EntityTestDefinitionSubscriber $event_subscriber */
+ $event_subscriber = $this->container->get('entity_test.definition.subscriber');
+ $event_subscriber->enableEventTracking();
+ $event_subscriber->enableLiveDefinitionUpdates();
+
+ // Test field storage definition events.
+ $storage_definition = FieldStorageDefinition::create('string')
+ ->setName('field_storage_test')
+ ->setLabel(new TranslatableMarkup('Field storage test'))
+ ->setTargetEntityTypeId('entity_test_rev');
+
+ $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create was not dispatched yet.');
+ \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionCreate($storage_definition);
+ $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create event successfully dispatched.');
+ $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::CREATE), 'Last installed field storage definition was created before the event was fired.');
+
+ // Check that the newly added field can be retrieved from the live field
+ // storage definitions.
+ $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev');
+ $this->assertArrayHasKey('field_storage_test', $field_storage_definitions);
+
+ $updated_storage_definition = clone $storage_definition;
+ $updated_storage_definition->setLabel(new TranslatableMarkup('Updated field storage test'));
+ $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update was not dispatched yet.');
+ \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionUpdate($updated_storage_definition, $storage_definition);
+ $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update event successfully dispatched.');
+ $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::UPDATE), 'Last installed field storage definition was updated before the event was fired.');
+
+ // Check that the updated field can be retrieved from the live field storage
+ // definitions.
+ $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev');
+ $this->assertEquals(new TranslatableMarkup('Updated field storage test'), $field_storage_definitions['field_storage_test']->getLabel());
+
+ $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete was not dispatched yet.');
+ \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionDelete($storage_definition);
+ $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete event successfully dispatched.');
+ $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::DELETE), 'Last installed field storage definition was deleted before the event was fired.');
+
+ // Check that the deleted field can no longer be retrieved from the live
+ // field storage definitions.
+ $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev');
+ $this->assertArrayNotHasKey('field_storage_test', $field_storage_definitions);
+
+ // Test entity type events.
+ $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev');
+
+ $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create was not dispatched yet.');
+ \Drupal::service('entity_type.listener')->onEntityTypeCreate($entity_type);
+ $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create event successfully dispatched.');
+ $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::CREATE), 'Last installed entity type definition was created before the event was fired.');
+
+ $updated_entity_type = clone $entity_type;
+ $updated_entity_type->set('label', new TranslatableMarkup('Updated entity test rev'));
+ $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update was not dispatched yet.');
+ \Drupal::service('entity_type.listener')->onEntityTypeUpdate($updated_entity_type, $entity_type);
+ $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update event successfully dispatched.');
+ $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::UPDATE), 'Last installed entity type definition was updated before the event was fired.');
+
+ // Check that the updated definition can be retrieved from the live entity
+ // type definitions.
+ $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev');
+ $this->assertEquals(new TranslatableMarkup('Updated entity test rev'), $entity_type->getLabel());
+
+ $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete was not dispatched yet.');
+ \Drupal::service('entity_type.listener')->onEntityTypeDelete($entity_type);
+ $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete event successfully dispatched.');
+ $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::DELETE), 'Last installed entity type definition was deleted before the event was fired.');
+
+ // Check that the deleted entity type can no longer be retrieved from the
+ // live entity type definitions.
+ $this->assertNull($this->entityTypeManager->getDefinition('entity_test_rev', FALSE));
+ }
+
+ /**
+ * Tests the error handling when using initial values from another field.
+ */
+ public function testInitialValueFromFieldErrorHandling(): void {
+ // Check that setting invalid values for 'initial value from field' doesn't
+ // work.
+ try {
+ $this->addBaseField();
+ $storage_definition = BaseFieldDefinition::create('string')
+ ->setLabel('A new base field')
+ ->setInitialValueFromField('field_that_does_not_exist');
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
+ $this->fail('Using a non-existent field as initial value does not work.');
+ }
+ catch (FieldException $e) {
+ $this->assertEquals('Illegal initial value definition on new_base_field: The field field_that_does_not_exist does not exist.', $e->getMessage());
+ }
+
+ try {
+ $this->addBaseField();
+ $storage_definition = BaseFieldDefinition::create('integer')
+ ->setLabel('A new base field')
+ ->setInitialValueFromField('name');
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
+ $this->fail('Using a field of a different type as initial value does not work.');
+ }
+ catch (FieldException $e) {
+ $this->assertEquals('Illegal initial value definition on new_base_field: The field types do not match.', $e->getMessage());
+ }
+
+ try {
+ // Add a base field that will not be stored in the shared tables.
+ $initial_field = BaseFieldDefinition::create('string')
+ ->setName('initial_field')
+ ->setLabel('An initial field')
+ ->setCardinality(2);
+ $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field]);
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition('initial_field', 'entity_test_update', 'entity_test', $initial_field);
+
+ // Now add the base field which will try to use the previously added field
+ // as the source of its initial values.
+ $new_base_field = BaseFieldDefinition::create('string')
+ ->setName('new_base_field')
+ ->setLabel('A new base field')
+ ->setInitialValueFromField('initial_field');
+ $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field, 'new_base_field' => $new_base_field]);
+ $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $new_base_field);
+ $this->fail('Using a field that is not stored in the shared tables as initial value does not work.');
+ }
+ catch (FieldException $e) {
+ $this->assertEquals('Illegal initial value definition on new_base_field: Both fields have to be stored in the shared entity tables.', $e->getMessage());
+ }
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php
index 6437e594ee8..d184892a976 100644
--- a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php
@@ -4,21 +4,6 @@ declare(strict_types=1);
namespace Drupal\KernelTests\Core\Entity;
-use Drupal\Component\Plugin\Exception\PluginNotFoundException;
-use Drupal\Core\Database\Database;
-use Drupal\Core\Database\IntegrityConstraintViolationException;
-use Drupal\Core\Entity\ContentEntityType;
-use Drupal\Core\Entity\EntityStorageException;
-use Drupal\Core\Entity\EntityTypeEvents;
-use Drupal\Core\Entity\Exception\FieldStorageDefinitionUpdateForbiddenException;
-use Drupal\Core\Field\BaseFieldDefinition;
-use Drupal\Core\Field\FieldException;
-use Drupal\Core\Field\FieldStorageDefinitionEvents;
-use Drupal\Core\Language\LanguageInterface;
-use Drupal\Core\StringTranslation\TranslatableMarkup;
-use Drupal\entity_test\EntityTestHelper;
-use Drupal\entity_test\FieldStorageDefinition;
-use Drupal\entity_test_update\Entity\EntityTestUpdate;
use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait;
/**
@@ -27,7 +12,6 @@ use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait;
* @coversDefaultClass \Drupal\Core\Entity\EntityDefinitionUpdateManager
*
* @group Entity
- * @group #slow
*/
class EntityDefinitionUpdateTest extends EntityKernelTestBase {
@@ -67,12 +51,6 @@ class EntityDefinitionUpdateTest extends EntityKernelTestBase {
$this->entityDefinitionUpdateManager = $this->container->get('entity.definition_update_manager');
$this->entityFieldManager = $this->container->get('entity_field.manager');
$this->database = $this->container->get('database');
-
- // Install every entity type's schema that wasn't installed in the parent
- // method.
- foreach (array_diff_key($this->entityTypeManager->getDefinitions(), array_flip(['user', 'entity_test'])) as $entity_type_id => $entity_type) {
- $this->installEntitySchema($entity_type_id);
- }
}
/**
@@ -96,61 +74,6 @@ class EntityDefinitionUpdateTest extends EntityKernelTestBase {
}
/**
- * Tests when no definition update is needed.
- */
- public function testNoUpdates(): void {
- // Ensure that the definition update manager reports no updates.
- $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that no updates are needed.');
- $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports an empty change summary.');
- $this->assertSame([], $this->entityDefinitionUpdateManager->getChangeList(), 'EntityDefinitionUpdateManager reports an empty change list.');
- }
-
- /**
- * Tests updating entity schema when there are no existing entities.
- */
- public function testEntityTypeUpdateWithoutData(): void {
- // The 'entity_test_update' entity type starts out non-revisionable, so
- // ensure the revision table hasn't been created during setUp().
- $this->assertFalse($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table not created for entity_test_update.');
-
- // Update it to be revisionable and ensure the definition update manager
- // reports that an update is needed.
- $this->updateEntityTypeToRevisionable();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel();
- $expected = [
- 'entity_test_update' => [
- "The $entity_type entity type needs to be updated.",
- // The revision key is now defined, so the revision field needs to be
- // created.
- 'The Revision ID field needs to be installed.',
- 'The Default revision field needs to be installed.',
- ],
- ];
- $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.');
-
- // Run the update and ensure the revision table is created.
- $this->updateEntityTypeToRevisionable(TRUE);
- $this->assertTrue($this->database->schema()->tableExists('entity_test_update_revision'), 'Revision table created for entity_test_update.');
- }
-
- /**
- * Tests updating entity schema when there are entity storage changes.
- */
- public function testEntityTypeUpdateWithEntityStorageChange(): void {
- // Update the entity type to be revisionable and try to apply the update.
- // It's expected to throw an exception.
- $entity_type = $this->getUpdatedEntityTypeDefinition(TRUE, FALSE);
- try {
- $this->entityDefinitionUpdateManager->updateEntityType($entity_type);
- $this->fail('EntityStorageException thrown when trying to apply an update that requires shared table schema changes.');
- }
- catch (EntityStorageException) {
- // Expected exception; just continue testing.
- }
- }
-
- /**
* Tests installing an additional base field while installing an entity type.
*
* @covers ::installFieldableEntityType
@@ -173,909 +96,6 @@ class EntityDefinitionUpdateTest extends EntityKernelTestBase {
}
/**
- * Tests creating a fieldable entity type that doesn't exist in code anymore.
- *
- * @covers ::installFieldableEntityType
- */
- public function testInstallFieldableEntityTypeWithoutInCodeDefinition(): void {
- $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update');
- $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update');
-
- // Remove the entity type definition. This is the same thing as removing the
- // code that defines it.
- $this->deleteEntityType();
-
- // Install the entity type and check that its tables have been created.
- $this->entityDefinitionUpdateManager->installFieldableEntityType($entity_type, $field_storage_definitions);
- $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'The base table of the entity type has been created.');
- }
-
- /**
- * Tests updating an entity type that doesn't exist in code anymore.
- *
- * @covers ::updateEntityType
- */
- public function testUpdateEntityTypeWithoutInCodeDefinition(): void {
- $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update');
-
- // Remove the entity type definition. This is the same thing as removing the
- // code that defines it.
- $this->deleteEntityType();
-
- // Add an entity index, update the entity type and check that the index has
- // been created.
- $this->addEntityIndex();
- $this->entityDefinitionUpdateManager->updateEntityType($entity_type);
-
- $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.');
- }
-
- /**
- * Tests updating a fieldable entity type that doesn't exist in code anymore.
- *
- * @covers ::updateFieldableEntityType
- */
- public function testUpdateFieldableEntityTypeWithoutInCodeDefinition(): void {
- $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update');
- $field_storage_definitions = \Drupal::service('entity_field.manager')->getFieldStorageDefinitions('entity_test_update');
-
- // Remove the entity type definition. This is the same thing as removing the
- // code that defines it.
- $this->deleteEntityType();
-
- // Rename the base table, update the fieldable entity type and check that
- // the table has been renamed.
- $entity_type->set('base_table', 'entity_test_update_new');
- $this->entityDefinitionUpdateManager->updateFieldableEntityType($entity_type, $field_storage_definitions);
-
- $this->assertTrue($this->database->schema()->tableExists('entity_test_update_new'), 'The base table has been renamed.');
- $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'The old base table does not exist anymore.');
- }
-
- /**
- * Tests uninstalling an entity type that doesn't exist in code anymore.
- *
- * @covers ::uninstallEntityType
- */
- public function testUninstallEntityTypeWithoutInCodeDefinition(): void {
- $entity_type = clone $this->entityTypeManager->getDefinition('entity_test_update');
-
- // Remove the entity type definition. This is the same thing as removing the
- // code that defines it.
- $this->deleteEntityType();
-
- // Now uninstall it and check that the tables have been removed.
- $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.');
- $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type);
- $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.');
- }
-
- /**
- * Tests uninstalling a revisionable entity type that doesn't exist in code.
- *
- * @covers ::uninstallEntityType
- */
- public function testUninstallRevisionableEntityTypeWithoutInCodeDefinition(): void {
- $this->updateEntityTypeToRevisionable(TRUE);
- $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update');
-
- // Remove the entity type definition. This is the same thing as removing the
- // code that defines it.
- $this->deleteEntityType();
-
- // Now uninstall it and check that the tables have been removed.
- $this->assertTrue($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update exists before uninstalling it.');
- $this->entityDefinitionUpdateManager->uninstallEntityType($entity_type);
- $this->assertFalse($this->database->schema()->tableExists('entity_test_update'), 'Base table for entity_test_update does not exist anymore.');
- }
-
- /**
- * Tests creating, updating, and deleting a base field if no entities exist.
- */
- public function testBaseFieldCreateUpdateDeleteWithoutData(): void {
- // Add a base field, ensure the update manager reports it, and the update
- // creates its schema.
- $this->addBaseField();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The A new base field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
- $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.');
-
- // Add an index on the base field, ensure the update manager reports it,
- // and the update creates it.
- $this->addBaseFieldIndex();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
- $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index created.');
-
- // Remove the above index, ensure the update manager reports it, and the
- // update deletes it.
- $this->removeBaseFieldIndex();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
- $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), 'Index deleted.');
-
- // Update the type of the base field from 'string' to 'text', ensure the
- // update manager reports it, and the update adjusts the schema
- // accordingly.
- $this->modifyBaseField();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The A new base field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
- $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), 'Original column deleted in shared table for new_base_field.');
- $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__value'), 'Value column created in shared table for new_base_field.');
- $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field__format'), 'Format column created in shared table for new_base_field.');
-
- // Remove the base field, ensure the update manager reports it, and the
- // update deletes the schema.
- $this->removeBaseField();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The A new base field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
- $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_value'), 'Value column deleted from shared table for new_base_field.');
- $this->assertFalse($this->database->schema()->fieldExists('entity_test_update', 'new_base_field_format'), 'Format column deleted from shared table for new_base_field.');
- }
-
- /**
- * Tests creating, updating, and deleting a base field with no label set.
- *
- * See testBaseFieldCreateUpdateDeleteWithoutData() for more details
- */
- public function testBaseFieldWithoutLabelCreateUpdateDelete(): void {
- // Add a base field, ensure the update manager reports it with the
- // field id.
- $this->addBaseField('string', 'entity_test_update', FALSE, FALSE);
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The new_base_field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
-
- // Add an index on the base field, ensure the update manager reports it with
- // the field id.
- $this->addBaseFieldIndex();
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The new_base_field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
-
- // Remove the base field, ensure the update manager reports it with the
- // field id.
- $this->removeBaseField();
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The new_base_field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0]));
- }
-
- /**
- * Tests creating, updating, and deleting a bundle field if no entities exist.
- */
- public function testBundleFieldCreateUpdateDeleteWithoutData(): void {
- // Add a bundle field, ensure the update manager reports it, and the update
- // creates its schema.
- $this->addBundleField();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The A new bundle field field needs to be installed.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
- $this->assertTrue($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.');
-
- // Update the type of the base field from 'string' to 'text', ensure the
- // update manager reports it, and the update adjusts the schema
- // accordingly.
- $this->modifyBundleField();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The A new bundle field field needs to be updated.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
- $this->assertTrue($this->database->schema()->fieldExists('entity_test_update__new_bundle_field', 'new_bundle_field_format'), 'Format column created in dedicated table for new_base_field.');
-
- // Remove the bundle field, ensure the update manager reports it, and the
- // update deletes the schema.
- $this->removeBundleField();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $changes = $this->entityDefinitionUpdateManager->getChangeSummary();
- $this->assertCount(1, $changes['entity_test_update']);
- $this->assertEquals('The A new bundle field field needs to be uninstalled.', strip_tags((string) $changes['entity_test_update'][0]));
- $this->applyEntityUpdates();
- $this->assertFalse($this->database->schema()->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.');
- }
-
- /**
- * Tests creating and deleting a base field if entities exist.
- *
- * This tests deletion when there are existing entities, but non-existent data
- * for the field being deleted.
- *
- * @see testBaseFieldDeleteWithExistingData()
- */
- public function testBaseFieldCreateDeleteWithExistingEntities(): void {
- // Save an entity.
- $name = $this->randomString();
- $storage = $this->entityTypeManager->getStorage('entity_test_update');
- $entity = $storage->create(['name' => $name]);
- $entity->save();
-
- // Add a base field and run the update. Ensure the base field's column is
- // created and the prior saved entity data is still there.
- $this->addBaseField();
- $this->applyEntityUpdates();
- $schema_handler = $this->database->schema();
- $this->assertTrue($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column created in shared table for new_base_field.');
- $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id());
- $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.');
-
- // Remove the base field and run the update. Ensure the base field's column
- // is deleted and the prior saved entity data is still there.
- $this->removeBaseField();
- $this->applyEntityUpdates();
- $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field'), 'Column deleted from shared table for new_base_field.');
- $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id());
- $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.');
-
- // Add a base field with a required property and run the update. Ensure
- // 'not null' is not applied and thus no exception is thrown.
- $this->addBaseField('shape_required');
- $this->applyEntityUpdates();
- $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color');
- $this->assertTrue($assert, 'Columns created in shared table for new_base_field.');
-
- // Recreate the field after emptying the base table and check that its
- // columns are not 'not null'.
- // @todo Revisit this test when allowing for required storage field
- // definitions. See https://www.drupal.org/node/2390495.
- $entity->delete();
- $this->removeBaseField();
- $this->applyEntityUpdates();
- $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__shape'), 'Shape column should be removed from the shared table for new_base_field.');
- $this->assertFalse($schema_handler->fieldExists('entity_test_update', 'new_base_field__color'), 'Color column should be removed from the shared table for new_base_field.');
- $this->addBaseField('shape_required');
- $this->applyEntityUpdates();
- $assert = $schema_handler->fieldExists('entity_test_update', 'new_base_field__shape') && $schema_handler->fieldExists('entity_test_update', 'new_base_field__color');
- $this->assertTrue($assert, 'Columns created again in shared table for new_base_field.');
- $entity = $storage->create(['name' => $name]);
- $entity->save();
- }
-
- /**
- * Tests creating and deleting a bundle field if entities exist.
- *
- * This tests deletion when there are existing entities, but non-existent data
- * for the field being deleted.
- *
- * @see testBundleFieldDeleteWithExistingData()
- */
- public function testBundleFieldCreateDeleteWithExistingEntities(): void {
- // Save an entity.
- $name = $this->randomString();
- $storage = $this->entityTypeManager->getStorage('entity_test_update');
- $entity = $storage->create(['name' => $name]);
- $entity->save();
-
- // Add a bundle field and run the update. Ensure the bundle field's table
- // is created and the prior saved entity data is still there.
- $this->addBundleField();
- $this->applyEntityUpdates();
- $schema_handler = $this->database->schema();
- $this->assertTrue($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table created for new_bundle_field.');
- $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id());
- $this->assertSame($name, $entity->name->value, 'Entity data preserved during field creation.');
-
- // Remove the base field and run the update. Ensure the bundle field's
- // table is deleted and the prior saved entity data is still there.
- $this->removeBundleField();
- $this->applyEntityUpdates();
- $this->assertFalse($schema_handler->tableExists('entity_test_update__new_bundle_field'), 'Dedicated table deleted for new_bundle_field.');
- $entity = $this->entityTypeManager->getStorage('entity_test_update')->load($entity->id());
- $this->assertSame($name, $entity->name->value, 'Entity data preserved during field deletion.');
-
- // Test that required columns are created as 'not null'.
- $this->addBundleField('shape_required');
- $this->applyEntityUpdates();
- $message = 'The new_bundle_field_shape column is not nullable.';
- $values = [
- 'bundle' => $entity->bundle(),
- 'deleted' => 0,
- 'entity_id' => $entity->id(),
- 'revision_id' => $entity->id(),
- 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
- 'delta' => 0,
- 'new_bundle_field_color' => $this->randomString(),
- ];
- try {
- // Try to insert a record without providing a value for the 'not null'
- // column. This should fail.
- $this->database->insert('entity_test_update__new_bundle_field')
- ->fields($values)
- ->execute();
- $this->fail($message);
- }
- catch (IntegrityConstraintViolationException) {
- // Now provide a value for the 'not null' column. This is expected to
- // succeed.
- $values['new_bundle_field_shape'] = $this->randomString();
- $this->database->insert('entity_test_update__new_bundle_field')
- ->fields($values)
- ->execute();
- }
- }
-
- /**
- * Tests deleting a bundle field when it has existing data.
- */
- public function testBundleFieldDeleteWithExistingData(): void {
- /** @var \Drupal\Core\Entity\Sql\SqlEntityStorageInterface $storage */
- $storage = $this->entityTypeManager->getStorage('entity_test_update');
- $schema_handler = $this->database->schema();
-
- // Add the bundle field and run the update.
- $this->addBundleField();
- $this->applyEntityUpdates();
-
- /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
- $table_mapping = $storage->getTableMapping();
- $storage_definition = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledFieldStorageDefinitions('entity_test_update')['new_bundle_field'];
-
- // Check that the bundle field has a dedicated table.
- $dedicated_table_name = $table_mapping->getDedicatedDataTableName($storage_definition);
- $this->assertTrue($schema_handler->tableExists($dedicated_table_name), 'The bundle field uses a dedicated table.');
-
- // Save an entity with the bundle field populated.
- EntityTestHelper::createBundle('custom');
- $entity = $storage->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo']);
- $entity->save();
-
- // Remove the bundle field and apply updates.
- $this->removeBundleField();
- $this->applyEntityUpdates();
-
- // Check that the table of the bundle field has been renamed to use a
- // 'deleted' table name.
- $this->assertFalse($schema_handler->tableExists($dedicated_table_name), 'The dedicated table of the bundle field no longer exists.');
-
- $dedicated_deleted_table_name = $table_mapping->getDedicatedDataTableName($storage_definition, TRUE);
- $this->assertTrue($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle fields has been renamed to use the "deleted" name.');
-
- // Check that the deleted field's data is preserved in the dedicated
- // 'deleted' table.
- $result = $this->database->select($dedicated_deleted_table_name, 't')
- ->fields('t')
- ->execute()
- ->fetchAll();
- $this->assertCount(1, $result);
-
- $expected = [
- 'bundle' => $entity->bundle(),
- 'deleted' => '1',
- 'entity_id' => $entity->id(),
- 'revision_id' => $entity->id(),
- 'langcode' => $entity->language()->getId(),
- 'delta' => '0',
- 'new_bundle_field_value' => $entity->new_bundle_field->value,
- ];
- // Use assertEquals and not assertSame here to prevent that a different
- // sequence of the columns in the table will affect the check.
- $this->assertEquals($expected, (array) $result[0]);
-
- // Check that the field definition is marked for purging.
- $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions();
- $this->assertArrayHasKey($storage_definition->getUniqueIdentifier(), $deleted_field_definitions, 'The bundle field is marked for purging.');
-
- // Check that the field storage definition is marked for purging.
- $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions();
- $this->assertArrayHasKey($storage_definition->getUniqueStorageIdentifier(), $deleted_storage_definitions, 'The bundle field storage is marked for purging.');
-
- // Purge field data, and check that the storage definition has been
- // completely removed once the data is purged.
- field_purge_batch(10);
- $deleted_field_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldDefinitions();
- $this->assertEmpty($deleted_field_definitions, 'The bundle field has been deleted.');
- $deleted_storage_definitions = \Drupal::service('entity_field.deleted_fields_repository')->getFieldStorageDefinitions();
- $this->assertEmpty($deleted_storage_definitions, 'The bundle field storage has been deleted.');
- $this->assertFalse($schema_handler->tableExists($dedicated_deleted_table_name), 'The dedicated table of the bundle field has been removed.');
- }
-
- /**
- * Tests updating a base field when it has existing data.
- */
- public function testBaseFieldUpdateWithExistingData(): void {
- // Add the base field and run the update.
- $this->addBaseField();
- $this->applyEntityUpdates();
-
- // Save an entity with the base field populated.
- $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => 'foo'])->save();
-
- // Change the field's field type and apply updates. It's expected to
- // throw an exception.
- $this->modifyBaseField();
- try {
- $this->applyEntityUpdates();
- $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.');
- }
- catch (FieldStorageDefinitionUpdateForbiddenException) {
- // Expected exception; just continue testing.
- }
- }
-
- /**
- * Tests updating a bundle field when it has existing data.
- */
- public function testBundleFieldUpdateWithExistingData(): void {
- // Add the bundle field and run the update.
- $this->addBundleField();
- $this->applyEntityUpdates();
-
- // Save an entity with the bundle field populated.
- EntityTestHelper::createBundle('custom');
- $this->entityTypeManager->getStorage('entity_test_update')->create(['type' => 'test_bundle', 'new_bundle_field' => 'foo'])->save();
-
- // Change the field's field type and apply updates. It's expected to
- // throw an exception.
- $this->modifyBundleField();
- try {
- $this->applyEntityUpdates();
- $this->fail('FieldStorageDefinitionUpdateForbiddenException thrown when trying to update a field schema that has data.');
- }
- catch (FieldStorageDefinitionUpdateForbiddenException) {
- // Expected exception; just continue testing.
- }
- }
-
- /**
- * Tests updating a bundle field when the entity type schema has changed.
- */
- public function testBundleFieldUpdateWithEntityTypeSchemaUpdate(): void {
- // Add the bundle field and run the update.
- $this->addBundleField();
- $this->applyEntityUpdates();
-
- // Update the entity type schema to revisionable but don't run the updates
- // yet.
- $this->updateEntityTypeToRevisionable();
-
- // Perform a no-op update on the bundle field, which should work because
- // both the storage and the storage schema are using the last installed
- // entity type definition.
- $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager();
- $entity_definition_update_manager->updateFieldStorageDefinition($entity_definition_update_manager->getFieldStorageDefinition('new_bundle_field', 'entity_test_update'));
- }
-
- /**
- * Tests creating and deleting a multi-field index when there are no existing entities.
- */
- public function testEntityIndexCreateDeleteWithoutData(): void {
- // Add an entity index and ensure the update manager reports that as an
- // update to the entity type.
- $this->addEntityIndex();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel();
- $expected = [
- 'entity_test_update' => [
- "The $entity_type entity type needs to be updated.",
- ],
- ];
- $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.');
-
- // Run the update and ensure the new index is created.
- $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
- $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
- \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
- $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.');
-
- // Remove the index and ensure the update manager reports that as an
- // update to the entity type.
- $this->removeEntityIndex();
- $this->assertTrue($this->entityDefinitionUpdateManager->needsUpdates(), 'EntityDefinitionUpdateManager reports that updates are needed.');
- $entity_type = $this->entityTypeManager->getDefinition('entity_test_update')->getLabel();
- $expected = [
- 'entity_test_update' => [
- "The $entity_type entity type needs to be updated.",
- ],
- ];
- $this->assertEquals($expected, $this->entityDefinitionUpdateManager->getChangeSummary(), 'EntityDefinitionUpdateManager reports the expected change summary.');
-
- // Run the update and ensure the index is deleted.
- $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
- $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
- \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
- $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.');
-
- // Test that composite indexes are handled correctly when dropping and
- // re-creating one of their columns.
- $this->addEntityIndex();
- $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
- $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
- \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
-
- $storage_definition = $this->entityDefinitionUpdateManager->getFieldStorageDefinition('name', 'entity_test_update');
- $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition);
- $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created.');
- $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition);
- $this->assertFalse($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index deleted.');
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition('name', 'entity_test_update', 'entity_test', $storage_definition);
- $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index created again.');
- }
-
- /**
- * Tests creating a multi-field index when there are existing entities.
- */
- public function testEntityIndexCreateWithData(): void {
- // Save an entity.
- $name = $this->randomString();
- $entity = $this->entityTypeManager->getStorage('entity_test_update')->create(['name' => $name]);
- $entity->save();
-
- // Add an entity index, run the update. Ensure that the index is created
- // despite having data.
- $this->addEntityIndex();
- $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
- $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
- \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
- $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__new_index'), 'Index added.');
- }
-
- /**
- * Tests entity type and field storage definition events.
- */
- public function testDefinitionEvents(): void {
- /** @var \Drupal\entity_test\EntityTestDefinitionSubscriber $event_subscriber */
- $event_subscriber = $this->container->get('entity_test.definition.subscriber');
- $event_subscriber->enableEventTracking();
- $event_subscriber->enableLiveDefinitionUpdates();
-
- // Test field storage definition events.
- $storage_definition = FieldStorageDefinition::create('string')
- ->setName('field_storage_test')
- ->setLabel(new TranslatableMarkup('Field storage test'))
- ->setTargetEntityTypeId('entity_test_rev');
-
- $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create was not dispatched yet.');
- \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionCreate($storage_definition);
- $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::CREATE), 'Entity type create event successfully dispatched.');
- $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::CREATE), 'Last installed field storage definition was created before the event was fired.');
-
- // Check that the newly added field can be retrieved from the live field
- // storage definitions.
- $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev');
- $this->assertArrayHasKey('field_storage_test', $field_storage_definitions);
-
- $updated_storage_definition = clone $storage_definition;
- $updated_storage_definition->setLabel(new TranslatableMarkup('Updated field storage test'));
- $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update was not dispatched yet.');
- \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionUpdate($updated_storage_definition, $storage_definition);
- $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::UPDATE), 'Entity type update event successfully dispatched.');
- $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::UPDATE), 'Last installed field storage definition was updated before the event was fired.');
-
- // Check that the updated field can be retrieved from the live field storage
- // definitions.
- $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev');
- $this->assertEquals(new TranslatableMarkup('Updated field storage test'), $field_storage_definitions['field_storage_test']->getLabel());
-
- $this->assertFalse($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete was not dispatched yet.');
- \Drupal::service('field_storage_definition.listener')->onFieldStorageDefinitionDelete($storage_definition);
- $this->assertTrue($event_subscriber->hasEventFired(FieldStorageDefinitionEvents::DELETE), 'Entity type delete event successfully dispatched.');
- $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(FieldStorageDefinitionEvents::DELETE), 'Last installed field storage definition was deleted before the event was fired.');
-
- // Check that the deleted field can no longer be retrieved from the live
- // field storage definitions.
- $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions('entity_test_rev');
- $this->assertArrayNotHasKey('field_storage_test', $field_storage_definitions);
-
- // Test entity type events.
- $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev');
-
- $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create was not dispatched yet.');
- \Drupal::service('entity_type.listener')->onEntityTypeCreate($entity_type);
- $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::CREATE), 'Entity type create event successfully dispatched.');
- $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::CREATE), 'Last installed entity type definition was created before the event was fired.');
-
- $updated_entity_type = clone $entity_type;
- $updated_entity_type->set('label', new TranslatableMarkup('Updated entity test rev'));
- $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update was not dispatched yet.');
- \Drupal::service('entity_type.listener')->onEntityTypeUpdate($updated_entity_type, $entity_type);
- $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::UPDATE), 'Entity type update event successfully dispatched.');
- $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::UPDATE), 'Last installed entity type definition was updated before the event was fired.');
-
- // Check that the updated definition can be retrieved from the live entity
- // type definitions.
- $entity_type = $this->entityTypeManager->getDefinition('entity_test_rev');
- $this->assertEquals(new TranslatableMarkup('Updated entity test rev'), $entity_type->getLabel());
-
- $this->assertFalse($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete was not dispatched yet.');
- \Drupal::service('entity_type.listener')->onEntityTypeDelete($entity_type);
- $this->assertTrue($event_subscriber->hasEventFired(EntityTypeEvents::DELETE), 'Entity type delete event successfully dispatched.');
- $this->assertTrue($event_subscriber->hasDefinitionBeenUpdated(EntityTypeEvents::DELETE), 'Last installed entity type definition was deleted before the event was fired.');
-
- // Check that the deleted entity type can no longer be retrieved from the
- // live entity type definitions.
- $this->assertNull($this->entityTypeManager->getDefinition('entity_test_rev', FALSE));
- }
-
- /**
- * Tests applying single updates.
- */
- public function testSingleActionCalls(): void {
- $db_schema = $this->database->schema();
-
- // Ensure that a non-existing entity type cannot be installed.
- $message = 'A non-existing entity type cannot be installed';
- try {
- $this->entityDefinitionUpdateManager->installEntityType(new ContentEntityType(['id' => 'foo']));
- $this->fail($message);
- }
- catch (PluginNotFoundException) {
- // Expected exception; just continue testing.
- }
-
- // Ensure that a field cannot be installed on non-existing entity type.
- $message = 'A field cannot be installed on a non-existing entity type';
- try {
- $storage_definition = BaseFieldDefinition::create('string')
- ->setLabel('A new revisionable base field')
- ->setRevisionable(TRUE);
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition('bar', 'foo', 'entity_test', $storage_definition);
- $this->fail($message);
- }
- catch (PluginNotFoundException) {
- // Expected exception; just continue testing.
- }
-
- // Ensure that installing an existing entity type is a no-op.
- $entity_type = $this->entityDefinitionUpdateManager->getEntityType('entity_test_update');
- $this->entityDefinitionUpdateManager->installEntityType($entity_type);
- $this->assertTrue($db_schema->tableExists('entity_test_update'), 'Installing an existing entity type is a no-op');
-
- // Create a new base field.
- $this->addRevisionableBaseField();
- $storage_definition = BaseFieldDefinition::create('string')
- ->setLabel('A new revisionable base field')
- ->setRevisionable(TRUE);
- $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update.");
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
- $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table.");
-
- // Ensure that installing an existing field is a no-op.
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
- $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), 'Installing an existing field is a no-op');
-
- // Update an existing field schema.
- $this->modifyBaseField();
- $storage_definition = BaseFieldDefinition::create('text')
- ->setName('new_base_field')
- ->setTargetEntityTypeId('entity_test_update')
- ->setLabel('A new revisionable base field')
- ->setRevisionable(TRUE);
- $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition);
- $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "Previous schema for 'new_base_field' no longer exists.");
- $this->assertTrue(
- $db_schema->fieldExists('entity_test_update', 'new_base_field__value') && $db_schema->fieldExists('entity_test_update', 'new_base_field__format'),
- "New schema for 'new_base_field' has been created."
- );
-
- // Drop an existing field schema.
- $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($storage_definition);
- $this->assertFalse(
- $db_schema->fieldExists('entity_test_update', 'new_base_field__value') || $db_schema->fieldExists('entity_test_update', 'new_base_field__format'),
- "The schema for 'new_base_field' has been dropped."
- );
-
- // Make the entity type revisionable.
- $this->updateEntityTypeToRevisionable();
- $this->assertFalse($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' does not exist before applying the update.");
-
- $this->updateEntityTypeToRevisionable(TRUE);
- $this->assertTrue($db_schema->tableExists('entity_test_update_revision'), "The 'entity_test_update_revision' table has been created.");
- }
-
- /**
- * Ensures that a new field and index on a shared table are created.
- *
- * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::createSharedTableSchema
- */
- public function testCreateFieldAndIndexOnSharedTable(): void {
- $this->addBaseField();
- $this->addBaseFieldIndex();
- $this->applyEntityUpdates();
- $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table.");
- $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), "New index 'entity_test_update_field__new_base_field' has been created on the 'entity_test_update' table.");
- // Check index size in for MySQL.
- if (Database::getConnection()->driver() == 'mysql') {
- $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update_field__new_base_field\' and column_name = \'new_base_field\'')->fetchObject();
- $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.');
- }
- }
-
- /**
- * Ensures that a new entity level index is created when data exists.
- *
- * @see Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema::onEntityTypeUpdate
- */
- public function testCreateIndexUsingEntityStorageSchemaWithData(): void {
- // Save an entity.
- $name = $this->randomString();
- $storage = $this->entityTypeManager->getStorage('entity_test_update');
- $entity = $storage->create(['name' => $name]);
- $entity->save();
-
- // Create an index.
- $indexes = [
- 'entity_test_update__type_index' => ['type'],
- ];
- $this->state->set('entity_test_update.additional_entity_indexes', $indexes);
- $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_update');
- $original = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledDefinition('entity_test_update');
- \Drupal::service('entity_type.listener')->onEntityTypeUpdate($entity_type, $original);
-
- $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__type_index'), "New index 'entity_test_update__type_index' has been created on the 'entity_test_update' table.");
- // Check index size in for MySQL.
- if (Database::getConnection()->driver() == 'mysql') {
- $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update__type_index\' and column_name = \'type\'')->fetchObject();
- $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.');
- }
- }
-
- /**
- * Tests updating a base field when it has existing data.
- */
- public function testBaseFieldEntityKeyUpdateWithExistingData(): void {
- // Add the base field and run the update.
- $this->addBaseField();
- $this->applyEntityUpdates();
-
- // Save an entity with the base field populated.
- $this->entityTypeManager->getStorage('entity_test_update')->create(['new_base_field' => $this->randomString()])->save();
-
- // Save an entity with the base field not populated.
- /** @var \Drupal\entity_test\Entity\EntityTestUpdate $entity */
- $entity = $this->entityTypeManager->getStorage('entity_test_update')->create();
- $entity->save();
-
- // Promote the base field to an entity key. This will trigger the addition
- // of a NOT NULL constraint.
- $this->makeBaseFieldEntityKey();
-
- // Field storage CRUD operations use the last installed entity type
- // definition so we need to update it before doing any other field storage
- // updates.
- $this->entityDefinitionUpdateManager->updateEntityType($this->state->get('entity_test_update.entity_type'));
-
- // Try to apply the update and verify they fail since we have a NULL value.
- $message = 'An error occurs when trying to enabling NOT NULL constraints with NULL data.';
- try {
- $this->applyEntityUpdates();
- $this->fail($message);
- }
- catch (EntityStorageException) {
- // Expected exception; just continue testing.
- }
-
- // Check that the update is correctly applied when no NULL data is left.
- $entity->set('new_base_field', $this->randomString());
- $entity->save();
- $this->applyEntityUpdates();
-
- // Check that the update actually applied a NOT NULL constraint.
- $entity->set('new_base_field', NULL);
- $message = 'The NOT NULL constraint was correctly applied.';
- try {
- $entity->save();
- $this->fail($message);
- }
- catch (EntityStorageException) {
- // Expected exception; just continue testing.
- }
- }
-
- /**
- * Check that field schema is correctly handled with long-named fields.
- */
- public function testLongNameFieldIndexes(): void {
- $this->addLongNameBaseField();
- $entity_type_id = 'entity_test_update';
- $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
- $definitions = EntityTestUpdate::baseFieldDefinitions($entity_type);
- $name = 'new_long_named_entity_reference_base_field';
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition($name, $entity_type_id, 'entity_test', $definitions[$name]);
- $this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'Entity and field schema data are correctly detected.');
- }
-
- /**
- * Tests adding a base field with initial values.
- */
- public function testInitialValue(): void {
- $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
- $db_schema = $this->database->schema();
-
- // Create two entities before adding the base field.
- $storage->create()->save();
- $storage->create()->save();
-
- // Add a base field with an initial value.
- $this->addBaseField();
- $storage_definition = BaseFieldDefinition::create('string')
- ->setLabel('A new base field')
- ->setInitialValue('test value');
-
- $this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update.");
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
- $this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table.");
-
- // Check that the initial values have been applied.
- $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
- $entities = $storage->loadMultiple();
- $this->assertEquals('test value', $entities[1]->get('new_base_field')->value);
- $this->assertEquals('test value', $entities[2]->get('new_base_field')->value);
- }
-
- /**
- * Tests the error handling when using initial values from another field.
- */
- public function testInitialValueFromFieldErrorHandling(): void {
- // Check that setting invalid values for 'initial value from field' doesn't
- // work.
- try {
- $this->addBaseField();
- $storage_definition = BaseFieldDefinition::create('string')
- ->setLabel('A new base field')
- ->setInitialValueFromField('field_that_does_not_exist');
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
- $this->fail('Using a non-existent field as initial value does not work.');
- }
- catch (FieldException $e) {
- $this->assertEquals('Illegal initial value definition on new_base_field: The field field_that_does_not_exist does not exist.', $e->getMessage());
- }
-
- try {
- $this->addBaseField();
- $storage_definition = BaseFieldDefinition::create('integer')
- ->setLabel('A new base field')
- ->setInitialValueFromField('name');
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
- $this->fail('Using a field of a different type as initial value does not work.');
- }
- catch (FieldException $e) {
- $this->assertEquals('Illegal initial value definition on new_base_field: The field types do not match.', $e->getMessage());
- }
-
- try {
- // Add a base field that will not be stored in the shared tables.
- $initial_field = BaseFieldDefinition::create('string')
- ->setName('initial_field')
- ->setLabel('An initial field')
- ->setCardinality(2);
- $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field]);
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition('initial_field', 'entity_test_update', 'entity_test', $initial_field);
-
- // Now add the base field which will try to use the previously added field
- // as the source of its initial values.
- $new_base_field = BaseFieldDefinition::create('string')
- ->setName('new_base_field')
- ->setLabel('A new base field')
- ->setInitialValueFromField('initial_field');
- $this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field, 'new_base_field' => $new_base_field]);
- $this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $new_base_field);
- $this->fail('Using a field that is not stored in the shared tables as initial value does not work.');
- }
- catch (FieldException $e) {
- $this->assertEquals('Illegal initial value definition on new_base_field: Both fields have to be stored in the shared entity tables.', $e->getMessage());
- }
- }
-
- /**
* @covers ::getEntityTypes
*/
public function testGetEntityTypes(): void {
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php
index ffc5a85fdb0..fdfa189b880 100644
--- a/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/InputTest.php
@@ -17,7 +17,9 @@ use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\NodeType;
use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\StyleInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Validator\Exception\ValidationFailedException;
/**
@@ -289,4 +291,32 @@ YAML
RecipeRunner::processRecipe($recipe);
}
+ /**
+ * Tests that the askHidden prompt forwards arguments correctly.
+ */
+ public function testAskHiddenPromptArgumentsForwarded(): void {
+ $input = $this->createMock(InputInterface::class);
+ $output = $this->createMock(OutputInterface::class);
+ $io = new SymfonyStyle($input, $output);
+
+ $recipe = $this->createRecipe(<<<YAML
+name: 'Prompt askHidden Test'
+input:
+ foo:
+ data_type: string
+ description: Foo
+ prompt:
+ method: askHidden
+ default:
+ source: value
+ value: bar
+YAML
+ );
+ $collector = new ConsoleInputCollector($input, $io);
+ // askHidden prompt should have an ArgumentCountError rather than a general
+ // error.
+ $this->expectException(\ArgumentCountError::class);
+ $recipe->input->collectAll($collector);
+ }
+
}
diff --git a/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php b/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php
index f9b25e8e707..16fde578125 100644
--- a/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php
+++ b/core/tests/Drupal/Tests/Core/Database/ConnectionTest.php
@@ -910,7 +910,7 @@ class ConnectionTest extends UnitTestCase {
#[IgnoreDeprecations]
#[DataProvider('providerSupportedLegacyFetchModes')]
public function testSupportedLegacyFetchModes(int $mode): void {
- $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() 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");
+ $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() 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");
$mockPdo = $this->createMock(StubPDO::class);
$mockConnection = new StubConnection($mockPdo, []);
$statement = new StatementPrefetchIterator($mockPdo, $mockConnection, '');
@@ -921,7 +921,7 @@ class ConnectionTest extends UnitTestCase {
/**
* Provides data for testSupportedFetchModes.
*
- * @return array<string,array<\Drupal\Core\Database\FetchAs>>
+ * @return array<string,array<\Drupal\Core\Database\Statement\FetchAs>>
* The FetchAs cases.
*/
public static function providerSupportedFetchModes(): array {
@@ -975,7 +975,7 @@ class ConnectionTest extends UnitTestCase {
#[IgnoreDeprecations]
#[DataProvider('providerUnsupportedFetchModes')]
public function testUnsupportedFetchModes(int $mode): void {
- $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() 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");
+ $this->expectDeprecation("Passing the \$mode argument as an integer to setFetchMode() 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");
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches("/^Fetch mode FETCH_.* is not supported\\. Use supported modes only/");
$mockPdo = $this->createMock(StubPDO::class);
diff --git a/core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php b/core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php
deleted file mode 100644
index 6ab42a6c41a..00000000000
--- a/core/tests/Drupal/Tests/Core/Template/StubTwigTemplate.php
+++ /dev/null
@@ -1,43 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\Tests\Core\Template;
-
-use Twig\Source;
-use Twig\Template;
-
-/**
- * A stub of the Twig Template class for testing.
- */
-class StubTwigTemplate extends Template {
-
- /**
- * {@inheritdoc}
- */
- public function getTemplateName(): string {
- return '';
- }
-
- /**
- * {@inheritdoc}
- */
- public function getDebugInfo(): array {
- return [];
- }
-
- /**
- * {@inheritdoc}
- */
- public function getSourceContext(): Source {
- throw new \LogicException(__METHOD__ . '() not implemented.');
- }
-
- /**
- * {@inheritdoc}
- */
- protected function doDisplay(array $context, array $blocks = []): iterable {
- throw new \LogicException(__METHOD__ . '() not implemented.');
- }
-
-}
diff --git a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php
new file mode 100644
index 00000000000..c39827ca709
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentLoaderTest.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Theme\Component;
+
+use Drupal\Core\Plugin\Component;
+use Drupal\Core\Template\Loader\ComponentLoader;
+use Drupal\Core\Theme\ComponentPluginManager;
+use Drupal\Tests\UnitTestCaseTest;
+use org\bovigo\vfs\vfsStream;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Unit tests for the component loader class.
+ *
+ * @coversDefaultClass \Drupal\Core\Template\Loader\ComponentLoader
+ * @group sdc
+ */
+class ComponentLoaderTest extends UnitTestCaseTest {
+
+ /**
+ * Tests the is fresh function for component loader.
+ */
+ public function testIsFresh(): void {
+ $vfs_root = vfsStream::setup();
+ $component_directory = vfsStream::newDirectory('loader-test')->at($vfs_root);
+ $current_time = time();
+ $component_twig_file = vfsStream::newFile('loader-test.twig')
+ ->at($component_directory)
+ ->setContent('twig')
+ // Mark files as changed before the current time.
+ ->lastModified($current_time - 1000);
+ $component_yml_file = vfsStream::newFile('loader-test.component.yml')
+ ->at($component_directory)
+ ->setContent('')
+ // Mark file as changed before the current time.
+ ->lastModified($current_time - 1000);
+
+ $component = new Component(
+ ['app_root' => '/fake/root'],
+ 'sdc_test:loader-test',
+ [
+ 'machineName' => 'loader-test',
+ 'extension_type' => 'module',
+ 'id' => 'sdc_test:loader-test',
+ 'path' => 'vfs://' . $component_directory->path(),
+ 'provider' => 'sdc_test',
+ 'template' => 'loader-test.twig',
+ 'group' => 'my-group',
+ 'description' => 'My description',
+ '_discovered_file_path' => 'vfs://' . $component_yml_file->path(),
+ ]
+ );
+
+ $component_manager = $this->prophesize(ComponentPluginManager::class);
+ $component_manager->find('sdc_test:loader-test')->willReturn($component);
+ $component_loader = new ComponentLoader(
+ $component_manager->reveal(),
+ $this->createMock(LoggerInterface::class),
+ );
+
+ // Assert the component is fresh, as it changed before the current time.
+ $this->assertTrue($component_loader->isFresh('sdc_test:loader-test', $current_time), 'Twig and YAML files were supposed to be fresh');
+ // Pretend that we changed the twig file.
+ // It shouldn't matter that the time is in "future".
+ $component_twig_file->lastModified($current_time + 1000);
+ // Clear stat cache, to make sure component loader gets updated time.
+ clearstatcache();
+ // Component shouldn't be "fresh" anymore.
+ $this->assertFalse($component_loader->isFresh('sdc_test:loader-test', $current_time), 'Twig file was supposed to be outdated');
+
+ // Pretend that we changed the YAML file.
+ // It shouldn't matter that the time is in "future".
+ $component_twig_file->lastModified($current_time);
+ $component_yml_file->lastModified($current_time + 1000);
+ // Clear stat cache, to make sure component loader gets updated time.
+ clearstatcache();
+ // Component shouldn't be "fresh" anymore.
+ $this->assertFalse($component_loader->isFresh('sdc_test:loader-test', $current_time), 'YAML file was supposed to be outdated');
+
+ // Pretend that we changed both files.
+ // It shouldn't matter that the time is in "future".
+ $component_twig_file->lastModified($current_time + 1000);
+ $component_yml_file->lastModified($current_time + 1000);
+ // Clear stat cache, to make sure component loader gets updated time.
+ clearstatcache();
+ // Component shouldn't be "fresh" anymore.
+ $this->assertFalse($component_loader->isFresh('sdc_test:loader-test', $current_time), 'Twig and YAML files were supposed to be outdated');
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php
index 7e706edcfe0..46d77602c39 100644
--- a/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php
+++ b/core/tests/Drupal/Tests/Core/Theme/Component/ComponentValidatorTest.php
@@ -9,6 +9,7 @@ use Drupal\Core\Template\Attribute;
use Drupal\Core\Theme\Component\ComponentValidator;
use Drupal\Core\Render\Component\Exception\InvalidComponentException;
use Drupal\Core\Plugin\Component;
+use JsonSchema\ConstraintError;
use JsonSchema\Constraints\Factory;
use JsonSchema\Constraints\FormatConstraint;
use JsonSchema\Entity\JsonPointer;
@@ -359,7 +360,13 @@ class UrlHelperFormatConstraint extends FormatConstraint {
}
if ($schema->format === 'uri') {
if (\is_string($element) && !UrlHelper::isValid($element)) {
- $this->addError($path, 'Invalid URL format', 'format', ['format' => $schema->format]);
+ if (class_exists(ConstraintError::class)) {
+ $this->addError(ConstraintError::FORMAT_URL(), $path, ['format' => $schema->format]);
+ }
+ else {
+ // @todo Remove when we no longer support justinrainbow/json-schema v5.
+ $this->addError($path, 'Invalid URL format', 'format', ['format' => $schema->format]);
+ }
}
return;
}
diff --git a/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php b/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php
index 0f1146f84d8..59bce779111 100644
--- a/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php
+++ b/core/tests/Drupal/Tests/Core/Theme/Icon/Plugin/SvgExtractorTest.php
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Drupal\Tests\Core\Theme\Icon\Plugin;
// cspell:ignore corge
-use Drupal\Component\Render\FormattableMarkup;
+
use Drupal\Core\Template\Attribute;
use Drupal\Core\Theme\Icon\IconDefinition;
use Drupal\Core\Theme\Icon\IconDefinitionInterface;
@@ -288,7 +288,6 @@ class SvgExtractorTest extends UnitTestCase {
$this->assertInstanceOf(IconDefinitionInterface::class, $icon_loaded);
$data_loaded = $icon_loaded->getAllData();
- $expected_content[$index] = new FormattableMarkup($expected_content[$index], []);
$this->assertEquals($expected_content[$index], $data_loaded['content']);
$expected_attributes[$index] = new Attribute($expected_attributes[$index] ?? []);
diff --git a/core/themes/claro/css/classy/components/tablesort.css b/core/themes/claro/css/classy/components/tablesort.css
index 44e5349404d..f2a3c4ad60a 100644
--- a/core/themes/claro/css/classy/components/tablesort.css
+++ b/core/themes/claro/css/classy/components/tablesort.css
@@ -6,6 +6,3 @@
th.is-active img {
display: inline;
}
-td.is-active {
- background-color: #ddd;
-}
diff --git a/core/themes/claro/css/components/breadcrumb.pcss.css b/core/themes/claro/css/components/breadcrumb.pcss.css
index 2f9790a5a14..320fc2c67d1 100644
--- a/core/themes/claro/css/components/breadcrumb.pcss.css
+++ b/core/themes/claro/css/components/breadcrumb.pcss.css
@@ -29,7 +29,7 @@
padding: 0 0.75rem;
content: url(../../images/icons/currentColor/arrow-breadcrumb.svg);
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
transform: scaleX(-1);
}
diff --git a/core/themes/claro/css/components/card.pcss.css b/core/themes/claro/css/components/card.pcss.css
index b7de858f6c7..2f3db1770f9 100644
--- a/core/themes/claro/css/components/card.pcss.css
+++ b/core/themes/claro/css/components/card.pcss.css
@@ -143,7 +143,7 @@
margin-block: 0;
text-align: right; /* LTR */
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
text-align: left;
}
}
diff --git a/core/themes/claro/css/components/details.css b/core/themes/claro/css/components/details.css
index d5a34caee31..9f7f852c19e 100644
--- a/core/themes/claro/css/components/details.css
+++ b/core/themes/claro/css/components/details.css
@@ -176,6 +176,7 @@ td .claro-details {
border-inline-end: 0.125rem solid;
background: none;
}
+
[dir="rtl"] .claro-details__summary::before {
transform: rotate(-225deg);
}
diff --git a/core/themes/claro/css/components/details.pcss.css b/core/themes/claro/css/components/details.pcss.css
index a83aa729425..274386bded0 100644
--- a/core/themes/claro/css/components/details.pcss.css
+++ b/core/themes/claro/css/components/details.pcss.css
@@ -49,7 +49,7 @@
* element constrains the width. This can happen when toggling the
* "lazy-load" option within an image field.
*/
- @nest td & {
+ td & {
width: min-content;
min-width: 100%;
}
@@ -142,7 +142,7 @@
background-image: url(../../images/icons/545560/chevron-right.svg);
background-size: contain;
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
transform: rotate(-270deg);
}
}
@@ -165,7 +165,7 @@
border-inline-end: 0.125rem solid;
background: none;
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
transform: rotate(-225deg);
}
}
@@ -310,7 +310,7 @@
border-width: 0 0 0 var(--details-summary-focus-border-size); /* LTR */
box-shadow: none;
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
border-width: 0 var(--details-summary-focus-border-size) 0 0;
}
}
diff --git a/core/themes/claro/css/components/dialog.css b/core/themes/claro/css/components/dialog.css
index e1d0b18f3bc..ecaf17d2daa 100644
--- a/core/themes/claro/css/components/dialog.css
+++ b/core/themes/claro/css/components/dialog.css
@@ -96,7 +96,8 @@
@media (forced-colors: active) {
.ui-dialog .ui-dialog-titlebar .ui-dialog-titlebar-close .ui-icon.ui-icon-closethick {
- background: url("data:image/svg+xml,%3csvg width='12' height='12' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M11 1.318l-10 10M11 11.318l-10-10' stroke='buttonText' stroke-width='1.5'/%3e%3c/svg%3e") no-repeat 50%;
+ background: buttontext;
+ mask: url("data:image/svg+xml,%3csvg width='12' height='12' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M11 1.318l-10 10M11 11.318l-10-10' stroke='%23D3D4D9' stroke-width='1.5'/%3e%3c/svg%3e") no-repeat 50%;
}
}
diff --git a/core/themes/claro/css/components/dialog.pcss.css b/core/themes/claro/css/components/dialog.pcss.css
index ffec289fbc4..a965222cf7c 100644
--- a/core/themes/claro/css/components/dialog.pcss.css
+++ b/core/themes/claro/css/components/dialog.pcss.css
@@ -86,7 +86,8 @@
background: url(../../images/icons/d3d4d9/ex.svg) no-repeat 50%;
@media (forced-colors: active) {
- background: url(../../images/icons/buttonText/ex.svg) no-repeat 50%;
+ background: buttontext;
+ mask: url(../../images/icons/d3d4d9/ex.svg) no-repeat 50%;
}
}
}
diff --git a/core/themes/claro/css/components/form--checkbox-radio.pcss.css b/core/themes/claro/css/components/form--checkbox-radio.pcss.css
index 94bcd446294..27144dbea20 100644
--- a/core/themes/claro/css/components/form--checkbox-radio.pcss.css
+++ b/core/themes/claro/css/components/form--checkbox-radio.pcss.css
@@ -21,7 +21,7 @@
margin-inline-start: calc(var(--input--label-spacing) * -1);
transform: translateY(-50%);
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
float: right;
}
}
diff --git a/core/themes/claro/css/components/form--managed-file.pcss.css b/core/themes/claro/css/components/form--managed-file.pcss.css
index ecfcf4e9c6c..da1e2a27df9 100644
--- a/core/themes/claro/css/components/form--managed-file.pcss.css
+++ b/core/themes/claro/css/components/form--managed-file.pcss.css
@@ -79,7 +79,7 @@
}
}
- @nest .draggable .form-managed-file.has-value & {
+ .draggable .form-managed-file.has-value & {
/**
* In tables, this should be inline-flex. This is needed to make this element be
* pushed to a new line, to the bottom of the drag handle.
@@ -98,7 +98,7 @@
max-width: 100%;
margin-block-end: var(--space-m);
- @nest .form-managed-file.has-meta & {
+ .form-managed-file.has-meta & {
/* Add some 'end' margin if there are other meta inputs. */
margin-inline-end: var(--space-m);
}
@@ -107,7 +107,7 @@
* If this is rendered inside a file multiple table and there are no alt or
* title, we have to reduce the amount of the bottom margin.
*/
- @nest td .form-managed-file.no-meta & {
+ td .form-managed-file.no-meta & {
margin-block-end: var(--space-xs);
}
}
@@ -164,11 +164,11 @@
}
/* Add some bottom margin for single widgets if no meta is present. */
- @nest .form-managed-file.is-single.has-value &:last-child {
+ .form-managed-file.is-single.has-value &:last-child {
margin-block-end: var(--space-m);
}
- @nest .draggable .form-managed-file.has-value & {
+ .draggable .form-managed-file.has-value & {
/**
* Inside (draggable) tables, this should be flex-displayed. This keeps even
* long file names in the same visual line where the drag handle is.
diff --git a/core/themes/claro/css/components/form--password-confirm.pcss.css b/core/themes/claro/css/components/form--password-confirm.pcss.css
index a013469cbc7..1d7d856e907 100644
--- a/core/themes/claro/css/components/form--password-confirm.pcss.css
+++ b/core/themes/claro/css/components/form--password-confirm.pcss.css
@@ -21,7 +21,7 @@
.password-confirm__confirm {
margin-block-end: 0;
- @nest .js & {
+ .js & {
max-height: 10rem;
transition:
max-height var(--speed-transition) ease-in-out,
@@ -125,7 +125,7 @@
font-size: var(--progress-bar-description-font-size);
line-height: var(--space-m);
- @nest .is-initial.is-password-empty & {
+ .is-initial.is-password-empty & {
margin: 0;
line-height: 0;
}
@@ -163,7 +163,7 @@
color: var(--progress-bar-description-color);
font-size: var(--progress-bar-description-font-size);
- @nest .is-confirm-empty & {
+ .is-confirm-empty & {
visibility: hidden;
}
}
diff --git a/core/themes/claro/css/components/form--select.pcss.css b/core/themes/claro/css/components/form--select.pcss.css
index 6b4c992955c..ce3fd572a13 100644
--- a/core/themes/claro/css/components/form--select.pcss.css
+++ b/core/themes/claro/css/components/form--select.pcss.css
@@ -20,12 +20,13 @@
background-image: url(../../images/icons/8e929c/chevron-down.svg);
}
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
background-position: 0 50%;
}
- @nest .no-touchevents & {
- &.form-element--extrasmall, &[name$="][_weight]"] {
+ .no-touchevents & {
+ &.form-element--extrasmall,
+ &[name$="][_weight]"] {
padding-inline-end: calc(1.5rem - var(--input-border-size));
background-size: 1.75rem 0.4375rem; /* w: 14px + (2 * 7px), h: 7px */
}
diff --git a/core/themes/claro/css/components/messages.pcss.css b/core/themes/claro/css/components/messages.pcss.css
index 69d75c63528..1bff37f060b 100644
--- a/core/themes/claro/css/components/messages.pcss.css
+++ b/core/themes/claro/css/components/messages.pcss.css
@@ -56,7 +56,7 @@
margin: 0;
}
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
border-right-width: var(--messages-border-width);
border-left-width: 0;
}
@@ -97,7 +97,7 @@
align-items: center;
margin-block-end: var(--space-m);
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
background-position: center right;
}
}
diff --git a/core/themes/claro/css/components/page-title.pcss.css b/core/themes/claro/css/components/page-title.pcss.css
index 0de14eb2276..11364373446 100644
--- a/core/themes/claro/css/components/page-title.pcss.css
+++ b/core/themes/claro/css/components/page-title.pcss.css
@@ -17,7 +17,7 @@
font-size: var(--font-size-h1);
-webkit-font-smoothing: antialiased;
- @nest .region-header > & {
+ .region-header > & {
/**
* In this case page title is not rendered as a block ¯\_(ツ)_/¯.
*
diff --git a/core/themes/claro/css/components/shortcut.pcss.css b/core/themes/claro/css/components/shortcut.pcss.css
index 72e157db6fc..87644ecea2f 100644
--- a/core/themes/claro/css/components/shortcut.pcss.css
+++ b/core/themes/claro/css/components/shortcut.pcss.css
@@ -48,19 +48,21 @@
vertical-align: -0.0625rem;
background: transparent url(../../images/shortcut/favstar.svg) left top / calc(var(--shortcut-icon-size) * 4) var(--shortcut-icon-size) no-repeat;
- @nest .shortcut-action--add:hover &, .shortcut-action--add:focus & {
+ .shortcut-action--add:hover &,
+ .shortcut-action--add:focus & {
background-position: calc(-1 * var(--shortcut-icon-size)) top;
}
- @nest .shortcut-action--remove & {
+ .shortcut-action--remove & {
background-position: calc(-2 * var(--shortcut-icon-size)) top;
}
- @nest .shortcut-action--remove:focus &, .shortcut-action--remove:hover & {
+ .shortcut-action--remove:focus &,
+ .shortcut-action--remove:hover & {
background-position: calc(-3 * var(--shortcut-icon-size)) top;
}
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
background-image: url(../../images/shortcut/favstar-rtl.svg);
}
}
diff --git a/core/themes/claro/css/components/system-admin--admin-list.pcss.css b/core/themes/claro/css/components/system-admin--admin-list.pcss.css
index 92f32d49b94..2db336bfc36 100644
--- a/core/themes/claro/css/components/system-admin--admin-list.pcss.css
+++ b/core/themes/claro/css/components/system-admin--admin-list.pcss.css
@@ -51,7 +51,7 @@
background: transparent no-repeat 50% 50%;
background-image: url(../../images/icons/003ecc/arrow-right.svg);
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
transform: scaleX(-1);
}
diff --git a/core/themes/claro/css/components/system-status-counter.css b/core/themes/claro/css/components/system-status-counter.css
index a8d21be0cf0..764a9176a6f 100644
--- a/core/themes/claro/css/components/system-status-counter.css
+++ b/core/themes/claro/css/components/system-status-counter.css
@@ -35,6 +35,10 @@
background-position: right center;
background-size: 2.5rem;
}
+[dir="rtl"] .system-status-counter__status-icon::before {
+ border-inline-end: 1px solid #e6e4df;
+ border-inline-start: 0;
+}
@media (forced-colors: active) {
.system-status-counter__status-icon::before {
background-color: canvastext;
@@ -44,9 +48,6 @@
mask-size: 2.5rem;
}
}
-[dir="rtl"] .system-status-counter__status-icon::before {
- background-position: left center;
-}
.system-status-counter__status-icon--error::before {
background-image: var(--system-status-counter-status-icon-error);
}
diff --git a/core/themes/claro/css/components/system-status-counter.pcss.css b/core/themes/claro/css/components/system-status-counter.pcss.css
index a5b139eabe4..f61ca3a374a 100644
--- a/core/themes/claro/css/components/system-status-counter.pcss.css
+++ b/core/themes/claro/css/components/system-status-counter.pcss.css
@@ -30,6 +30,11 @@
background-position: right center;
background-size: 40px;
+ [dir="rtl"] & {
+ border-inline-end: 1px solid #e6e4df;
+ border-inline-start: 0;
+ }
+
@media (forced-colors: active) {
background-color: canvastext;
background-image: none;
@@ -37,10 +42,6 @@
mask-position: right center;
mask-size: 40px;
}
-
- @nest [dir="rtl"] & {
- background-position: left center;
- }
}
}
diff --git a/core/themes/claro/css/components/tabledrag.pcss.css b/core/themes/claro/css/components/tabledrag.pcss.css
index 2484ef2a4c3..4a90d40a10b 100644
--- a/core/themes/claro/css/components/tabledrag.pcss.css
+++ b/core/themes/claro/css/components/tabledrag.pcss.css
@@ -53,16 +53,16 @@ body.drag-y {
.tabledrag-changed {
/* Don't display the abbreviation of 'add-new' table rows. */
- @nest .add-new & {
+ .add-new & {
display: none;
}
- @nest .draggable & {
+ .draggable & {
position: relative;
inset-inline-start: calc(var(--space-xs) * -1);
}
- @nest .tabledrag-cell--only-drag & {
+ .tabledrag-cell--only-drag & {
width: var(--space-l);
min-width: var(--space-l);
}
@@ -189,7 +189,7 @@ body.drag-y {
text-align: end;
/* Hide nested weight toggles as they are redundant. */
- @nest .draggable-table & {
+ .draggable-table & {
display: none;
}
}
@@ -251,7 +251,7 @@ body.drag-y {
background: none !important;
line-height: 0;
- @nest .tabledrag-cell-content & {
+ .tabledrag-cell-content & {
/* Fixes Safari bug (16.1 at least) where table rows are overly large when
using indentation (e.g. re-ordering menu items. */
display: inline-flex;
@@ -261,7 +261,7 @@ body.drag-y {
height: 100%;
}
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
float: right;
}
}
diff --git a/core/themes/claro/css/components/tabs.css b/core/themes/claro/css/components/tabs.css
index 523d3c60408..438e796d775 100644
--- a/core/themes/claro/css/components/tabs.css
+++ b/core/themes/claro/css/components/tabs.css
@@ -172,6 +172,10 @@
box-shadow: none;
}
+.position-container {
+ position: relative;
+}
+
@media screen and (min-width: 48em) {
.tabs-wrapper {
display: flex;
diff --git a/core/themes/claro/css/components/tabs.pcss.css b/core/themes/claro/css/components/tabs.pcss.css
index 6e89d174412..25a46ffec5f 100644
--- a/core/themes/claro/css/components/tabs.pcss.css
+++ b/core/themes/claro/css/components/tabs.pcss.css
@@ -152,6 +152,10 @@
}
}
+.position-container {
+ position: relative;
+}
+
@media screen and (min-width: 48em) {
.tabs-wrapper {
display: flex;
diff --git a/core/themes/claro/css/components/vertical-tabs.pcss.css b/core/themes/claro/css/components/vertical-tabs.pcss.css
index ef09e440db8..bdac67e2369 100644
--- a/core/themes/claro/css/components/vertical-tabs.pcss.css
+++ b/core/themes/claro/css/components/vertical-tabs.pcss.css
@@ -27,7 +27,7 @@
list-style: none;
color: var(--color-text);
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
float: right;
}
}
@@ -224,7 +224,7 @@
margin-inline-start: var(--vertical-tabs-menu-width);
border-top-left-radius: 0;
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
border-top-left-radius: var(--vertical-tabs-border-radius);
}
}
diff --git a/core/themes/claro/css/components/views_ui.admin.pcss.css b/core/themes/claro/css/components/views_ui.admin.pcss.css
index b4f1dd72e0b..38ead2a8e6f 100644
--- a/core/themes/claro/css/components/views_ui.admin.pcss.css
+++ b/core/themes/claro/css/components/views_ui.admin.pcss.css
@@ -50,7 +50,7 @@
margin-inline-start: 0;
}
- @nest [dir="rtl"] & {
+ [dir="rtl"] & {
& > * {
float: right;
}
diff --git a/core/themes/claro/images/icons/buttonText/ex.svg b/core/themes/claro/images/icons/buttonText/ex.svg
deleted file mode 100644
index 635ac1c6b38..00000000000
--- a/core/themes/claro/images/icons/buttonText/ex.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="12" height="12" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11 1.318l-10 10M11 11.318l-10-10" stroke="buttonText" stroke-width="1.5"/></svg>
diff --git a/core/themes/olivero/config/optional/block.block.olivero_syndicate.yml b/core/themes/olivero/config/optional/block.block.olivero_syndicate.yml
deleted file mode 100644
index 343b8d3256a..00000000000
--- a/core/themes/olivero/config/optional/block.block.olivero_syndicate.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-langcode: en
-status: true
-dependencies:
- module:
- - node
- theme:
- - olivero
-id: olivero_syndicate
-theme: olivero
-region: social
-weight: 0
-provider: null
-plugin: node_syndicate_block
-settings:
- id: node_syndicate_block
- label: 'RSS feed'
- label_display: '0'
- provider: node
- block_count: 10
-visibility: { }
diff --git a/core/themes/olivero/olivero.libraries.yml b/core/themes/olivero/olivero.libraries.yml
index c699ebc72c8..137a4296eb6 100644
--- a/core/themes/olivero/olivero.libraries.yml
+++ b/core/themes/olivero/olivero.libraries.yml
@@ -45,7 +45,6 @@ global-styling:
css/components/site-header.css: {}
css/components/skip-link.css: {}
css/components/pager.css: {}
- css/components/table.css: {}
css/components/text-content.css: {}
css/components/wide-content.css: {}
@@ -291,3 +290,18 @@ tags:
css:
theme:
css/components/tags.css: {}
+
+olivero.table:
+ version: VERSION
+ css:
+ component:
+ css/components/table.css: {}
+ moved_files:
+ olivero/global-styling:
+ deprecation_version: 11.2.0
+ removed_version: 12.0.0
+ deprecation_link: https://www.drupal.org/node/3517675
+ css:
+ component:
+ css/components/table.css:
+ base: css/components/table.css
diff --git a/core/themes/olivero/olivero.theme b/core/themes/olivero/olivero.theme
index d10ee7d155c..b2f3bff2684 100644
--- a/core/themes/olivero/olivero.theme
+++ b/core/themes/olivero/olivero.theme
@@ -617,6 +617,15 @@ function olivero_preprocess_table(&$variables): void {
}
}
}
+
+ $variables['#attached']['library'][] = 'olivero/olivero.table';
+}
+
+/**
+ * Implements hook_preprocess_HOOK() for views-view-table templates.
+ */
+function olivero_preprocess_views_view_table(&$variables): void {
+ $variables['#attached']['library'][] = 'olivero/olivero.table';
}
/**
diff --git a/core/themes/stable9/stable9.info.yml b/core/themes/stable9/stable9.info.yml
index 71dc76f30b6..c7c23976444 100644
--- a/core/themes/stable9/stable9.info.yml
+++ b/core/themes/stable9/stable9.info.yml
@@ -240,7 +240,6 @@ libraries-override:
css/components/hidden.module.css: css/system/components/hidden.module.css
css/components/item-list.module.css: css/system/components/item-list.module.css
css/components/js.module.css: css/system/components/js.module.css
- css/components/position-container.module.css: css/system/components/position-container.module.css
css/components/reset-appearance.module.css: css/system/components/reset-appearance.module.css
system/admin:
css: