diff --git a/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php b/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php index 7f10d6f51f..fd4d85d7f3 100644 --- a/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php +++ b/core/modules/layout_builder/src/Entity/LayoutBuilderEntityViewDisplay.php @@ -2,9 +2,12 @@ namespace Drupal\layout_builder\Entity; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Entity\Entity\EntityViewDisplay as BaseEntityViewDisplay; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Plugin\Context\Context; +use Drupal\Core\Plugin\Context\ContextDefinition; use Drupal\Core\Plugin\Context\EntityContext; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\field\Entity\FieldConfig; @@ -240,7 +243,13 @@ public function buildMultiple(array $entities) { /** @var \Drupal\Core\Entity\EntityInterface $entity */ foreach ($entities as $id => $entity) { - $sections = $this->getRuntimeSections($entity); + $cacheability = new CacheableMetadata(); + $sections = $this->getRuntimeSections($entity, $cacheability); + + // Apply cacheability metadata to the build array. + $build_list[$id]['_layout_builder'] = []; + $cacheability->applyTo($build_list[$id]['_layout_builder']); + if ($sections) { foreach ($build_list[$id] as $name => $build_part) { $field_definition = $this->getFieldDefinition($name); @@ -249,13 +258,13 @@ public function buildMultiple(array $entities) { } } - // Bypass ::getContexts() in order to use the runtime entity, not a - // sample entity. - $contexts = $this->contextRepository()->getAvailableContexts(); + $contexts = $this->getContextsForEntity($entity); + // @todo Remove in https://www.drupal.org/project/drupal/issues/3018782. $label = new TranslatableMarkup('@entity being viewed', [ '@entity' => $entity->getEntityType()->getSingularLabel(), ]); $contexts['layout_builder.entity'] = EntityContext::fromEntity($entity, $label); + foreach ($sections as $delta => $section) { $build_list[$id]['_layout_builder'][$delta] = $section->toRenderArray($contexts); } @@ -265,21 +274,38 @@ public function buildMultiple(array $entities) { return $build_list; } + /** + * Gets the available contexts for a given entity. + * + * @param \Drupal\Core\Entity\FieldableEntityInterface $entity + * The entity. + * + * @return \Drupal\Core\Plugin\Context\ContextInterface[] + * An array of context objects for a given entity. + */ + protected function getContextsForEntity(FieldableEntityInterface $entity) { + return [ + 'view_mode' => new Context(ContextDefinition::create('string'), $this->getMode()), + 'entity' => EntityContext::fromEntity($entity), + 'display' => EntityContext::fromEntity($this), + ] + $this->contextRepository()->getAvailableContexts(); + } + /** * Gets the runtime sections for a given entity. * * @param \Drupal\Core\Entity\FieldableEntityInterface $entity * The entity. + * @param \Drupal\Core\Cache\CacheableMetadata|null $cacheability + * (optional) Cacheability metadata object, which will be populated based on + * the cacheability of each section storage candidate. * * @return \Drupal\layout_builder\Section[] * The sections. */ - protected function getRuntimeSections(FieldableEntityInterface $entity) { - if ($this->isOverridable() && !$entity->get(OverridesSectionStorage::FIELD_NAME)->isEmpty()) { - return $entity->get(OverridesSectionStorage::FIELD_NAME)->getSections(); - } - - return $this->getSections(); + protected function getRuntimeSections(FieldableEntityInterface $entity, CacheableMetadata &$cacheability = NULL) { + $storage = $this->sectionStorageManager()->findByContext($this->getContextsForEntity($entity), $cacheability); + return $storage ? $storage->getSections() : []; } /** @@ -399,4 +425,14 @@ protected function getDefaultSection() { return $this->getSection(0); } + /** + * Gets the section storage manager. + * + * @return \Drupal\layout_builder\SectionStorage\SectionStorageManagerInterface + * The section storage manager. + */ + private function sectionStorageManager() { + return \Drupal::service('plugin.manager.layout_builder.section_storage'); + } + } diff --git a/core/modules/layout_builder/src/Plugin/SectionStorage/DefaultsSectionStorage.php b/core/modules/layout_builder/src/Plugin/SectionStorage/DefaultsSectionStorage.php index f51a4653c2..32ea0edb8a 100644 --- a/core/modules/layout_builder/src/Plugin/SectionStorage/DefaultsSectionStorage.php +++ b/core/modules/layout_builder/src/Plugin/SectionStorage/DefaultsSectionStorage.php @@ -21,8 +21,15 @@ /** * Defines the 'defaults' section storage type. * + * DefaultsSectionStorage uses a positive weight because: + * - It must be picked after + * \Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage. + * - The default weight is 0, so other custom implementations will also take + * precedence unless otherwise specified. + * * @SectionStorage( * id = "defaults", + * weight = 20, * context_definitions = { * "display" = @ContextDefinition("entity:entity_view_display"), * }, diff --git a/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php b/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php index af2aca668d..4154880f84 100644 --- a/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php +++ b/core/modules/layout_builder/src/Plugin/SectionStorage/OverridesSectionStorage.php @@ -3,6 +3,7 @@ namespace Drupal\layout_builder\Plugin\SectionStorage; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -19,8 +20,15 @@ /** * Defines the 'overrides' section storage type. * + * OverridesSectionStorage uses a negative weight because: + * - It must be picked before + * \Drupal\layout_builder\Plugin\SectionStorage\DefaultsSectionStorage. + * - The default weight is 0, so custom implementations will not take + * precedence unless otherwise specified. + * * @SectionStorage( * id = "overrides", + * weight = -20, * context_definitions = { * "entity" = @ContextDefinition("entity"), * "view_mode" = @ContextDefinition("string", required = FALSE), @@ -335,4 +343,14 @@ public function access($operation, AccountInterface $account = NULL, $return_as_ return $return_as_object ? $result : $result->isAllowed(); } + /** + * {@inheritdoc} + */ + public function isApplicable(CacheableMetadata $cacheability) { + $default_section_storage = $this->getDefaultSectionStorage(); + $cacheability->addCacheableDependency($default_section_storage)->addCacheableDependency($this); + // Check that overrides are enabled and have at least one section. + return $default_section_storage->isOverridable() && count($this); + } + } diff --git a/core/modules/layout_builder/src/Plugin/SectionStorage/SectionStorageBase.php b/core/modules/layout_builder/src/Plugin/SectionStorage/SectionStorageBase.php index 05a2de11e3..14b6b0bbb8 100644 --- a/core/modules/layout_builder/src/Plugin/SectionStorage/SectionStorageBase.php +++ b/core/modules/layout_builder/src/Plugin/SectionStorage/SectionStorageBase.php @@ -2,6 +2,7 @@ namespace Drupal\layout_builder\Plugin\SectionStorage; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Plugin\ContextAwarePluginBase; use Drupal\layout_builder\Routing\LayoutBuilderRoutesTrait; use Drupal\layout_builder\Section; @@ -108,4 +109,11 @@ public function getContextsDuringPreview() { return $this->getContexts(); } + /** + * {@inheritdoc} + */ + public function isApplicable(CacheableMetadata $cacheability) { + return TRUE; + } + } diff --git a/core/modules/layout_builder/src/SectionStorage/SectionStorageManager.php b/core/modules/layout_builder/src/SectionStorage/SectionStorageManager.php index 0bf52bf43d..b9b13dcda8 100644 --- a/core/modules/layout_builder/src/SectionStorage/SectionStorageManager.php +++ b/core/modules/layout_builder/src/SectionStorage/SectionStorageManager.php @@ -3,6 +3,7 @@ namespace Drupal\layout_builder\SectionStorage; use Drupal\Component\Plugin\Exception\ContextException; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Plugin\Context\ContextHandlerInterface; @@ -92,12 +93,12 @@ public function load($type, array $contexts = []) { /** * {@inheritdoc} */ - public function findByContext($operation, array $contexts) { + public function findByContext(array $contexts, CacheableMetadata $cacheability) { $storage_types = array_keys($this->contextHandler->filterPluginDefinitionsByContexts($contexts, $this->getDefinitions())); foreach ($storage_types as $type) { $plugin = $this->load($type, $contexts); - if ($plugin && $plugin->access($operation)) { + if ($plugin && $plugin->isApplicable($cacheability)) { return $plugin; } } diff --git a/core/modules/layout_builder/src/SectionStorage/SectionStorageManagerInterface.php b/core/modules/layout_builder/src/SectionStorage/SectionStorageManagerInterface.php index 4e09e2721a..1c9dd409ec 100644 --- a/core/modules/layout_builder/src/SectionStorage/SectionStorageManagerInterface.php +++ b/core/modules/layout_builder/src/SectionStorage/SectionStorageManagerInterface.php @@ -3,6 +3,7 @@ namespace Drupal\layout_builder\SectionStorage; use Drupal\Component\Plugin\Discovery\DiscoveryInterface; +use Drupal\Core\Cache\CacheableMetadata; /** * Provides the interface for a plugin manager of section storage types. @@ -30,15 +31,24 @@ public function load($type, array $contexts = []); /** * Finds the section storage to load based on available contexts. * - * @param string $operation - * The access operation. See \Drupal\Core\Access\AccessibleInterface. + * After calling this method the $cacheability parameter will reflect the + * cacheability metadata used to determine the correct section storage. This + * must be applied to any output that results from the calling of this method. + * * @param \Drupal\Component\Plugin\Context\ContextInterface[] $contexts * The contexts which should be used to determine which storage to return. + * @param \Drupal\Core\Cache\CacheableMetadata $cacheability + * Cacheability metadata object, which will be populated based on the + * cacheability of each section storage candidate. This is typically created + * directly before this method call and must be applied to a render array + * after this method call. * * @return \Drupal\layout_builder\SectionStorageInterface|null * The section storage if one matched all contexts, or NULL otherwise. + * + * @see \Drupal\Core\Cache\CacheableMetadata */ - public function findByContext($operation, array $contexts); + public function findByContext(array $contexts, CacheableMetadata $cacheability); /** * Loads a section storage with no associated section list. diff --git a/core/modules/layout_builder/src/SectionStorageInterface.php b/core/modules/layout_builder/src/SectionStorageInterface.php index 6dfd4793a3..43345035b1 100644 --- a/core/modules/layout_builder/src/SectionStorageInterface.php +++ b/core/modules/layout_builder/src/SectionStorageInterface.php @@ -4,6 +4,7 @@ use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\Core\Access\AccessibleInterface; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Plugin\ContextAwarePluginInterface; use Symfony\Component\Routing\RouteCollection; @@ -164,4 +165,23 @@ public function label(); */ public function save(); + /** + * Returns if the section storage should be selected during plugin mapping. + * + * @param \Drupal\Core\Cache\CacheableMetadata $cacheability + * Cacheability metadata object, typically provided by the section storage + * manager. When implementing this method, populate $cacheability with any + * information that affects whether the storage is applicable. + * + * @return bool + * TRUE if the section storage can be used, FALSE otherwise. + * + * @internal + * This method is intended to be called by + * \Drupal\layout_builder\SectionStorage\SectionStorageManagerInterface::findByContext(). + * + * @see \Drupal\Core\Cache\CacheableMetadata + */ + public function isApplicable(CacheableMetadata $cacheability); + } diff --git a/core/modules/layout_builder/tests/modules/layout_builder_overrides_test/layout_builder_overrides_test.info.yml b/core/modules/layout_builder/tests/modules/layout_builder_overrides_test/layout_builder_overrides_test.info.yml new file mode 100644 index 0000000000..f9ed711707 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_overrides_test/layout_builder_overrides_test.info.yml @@ -0,0 +1,6 @@ +name: 'Layout Builder overrides test' +type: module +description: 'Support module for testing overriding layout building.' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Plugin/SectionStorage/SimpleConfigSectionStorage.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Plugin/SectionStorage/SimpleConfigSectionStorage.php index c92bb2d1ed..cc18cea12f 100644 --- a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Plugin/SectionStorage/SimpleConfigSectionStorage.php +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Plugin/SectionStorage/SimpleConfigSectionStorage.php @@ -3,6 +3,7 @@ namespace Drupal\layout_builder_test\Plugin\SectionStorage; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Plugin\Context\Context; @@ -213,4 +214,11 @@ public function extractIdFromRoute($value, $definition, $name, array $defaults) return $value ?: $defaults['id']; } + /** + * {@inheritdoc} + */ + public function isApplicable(CacheableMetadata $cacheability) { + return TRUE; + } + } diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Plugin/SectionStorage/TestStateBasedSectionStorage.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Plugin/SectionStorage/TestStateBasedSectionStorage.php new file mode 100644 index 0000000000..eedd3e8eb6 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Plugin/SectionStorage/TestStateBasedSectionStorage.php @@ -0,0 +1,98 @@ +appendComponent(new SectionComponent('fake-uuid', 'content', [ + 'id' => 'system_powered_by_block', + 'label' => 'Test block title', + 'label_display' => 'visible', + ])); + return [$section]; + } + + /** + * {@inheritdoc} + */ + public function isApplicable(CacheableMetadata $cacheability) { + $cacheability->setCacheMaxAge(0); + return \Drupal::state()->get('layout_builder_test_state', FALSE); + } + + /** + * {@inheritdoc} + */ + public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) {} + + /** + * {@inheritdoc} + */ + protected function getSectionList() {} + + /** + * {@inheritdoc} + */ + public function getStorageId() {} + + /** + * {@inheritdoc} + */ + public function getSectionListFromId($id) {} + + /** + * {@inheritdoc} + */ + public function buildRoutes(RouteCollection $collection) {} + + /** + * {@inheritdoc} + */ + public function getRedirectUrl() {} + + /** + * {@inheritdoc} + */ + public function getLayoutBuilderUrl($rel = 'view') {} + + /** + * {@inheritdoc} + */ + public function extractIdFromRoute($value, $definition, $name, array $defaults) {} + + /** + * {@inheritdoc} + */ + public function deriveContextsFromRoute($value, $definition, $name, array $defaults) {} + + /** + * {@inheritdoc} + */ + public function label() {} + + /** + * {@inheritdoc} + */ + public function save() {} + +} diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderSectionStorageTest.php b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderSectionStorageTest.php new file mode 100644 index 0000000000..3d0d00d131 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderSectionStorageTest.php @@ -0,0 +1,88 @@ +drupalPlaceBlock('local_tasks_block'); + + // Create two nodes. + $this->createContentType(['type' => 'bundle_with_section_field']); + $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The first node title', + 'body' => [ + [ + 'value' => 'The first node body', + ], + ], + ]); + } + + /** + * Tests that section loading is delegated to plugins during rendering. + * + * @see \Drupal\layout_builder_test\Plugin\SectionStorage\TestStateBasedSectionStorage + */ + public function testRenderByContextAwarePluginDelegate() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalLogin($this->drupalCreateUser([ + 'configure any layout', + 'administer node display', + ])); + + // No blocks exist on the node by default. + $this->drupalGet('node/1'); + $assert_session->pageTextNotContains('Defaults block title'); + $assert_session->pageTextNotContains('Test block title'); + + // Enable Layout Builder. + $this->drupalPostForm('admin/structure/types/manage/bundle_with_section_field/display/default', ['layout[enabled]' => TRUE], 'Save'); + + // Add a block to the defaults. + $page->clickLink('Manage layout'); + $page->clickLink('Add Block'); + $page->clickLink('Powered by Drupal'); + $page->fillField('settings[label]', 'Defaults block title'); + $page->checkField('settings[label_display]'); + $page->pressButton('Add Block'); + $page->clickLink('Save Layout'); + + $this->drupalGet('node/1'); + $assert_session->pageTextContains('Defaults block title'); + $assert_session->pageTextNotContains('Test block title'); + + // Enable the test section storage. + $this->container->get('state')->set('layout_builder_test_state', TRUE); + $this->drupalGet('node/1'); + $assert_session->pageTextNotContains('Defaults block title'); + $assert_session->pageTextContains('Test block title'); + } + +} diff --git a/core/modules/layout_builder/tests/src/Unit/SectionStorageManagerTest.php b/core/modules/layout_builder/tests/src/Unit/SectionStorageManagerTest.php index 155799c9e0..aacbdad011 100644 --- a/core/modules/layout_builder/tests/src/Unit/SectionStorageManagerTest.php +++ b/core/modules/layout_builder/tests/src/Unit/SectionStorageManagerTest.php @@ -6,6 +6,8 @@ use Drupal\Component\Plugin\Discovery\DiscoveryInterface; use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Component\Plugin\Factory\FactoryInterface; +use Drupal\Core\Cache\CacheableDependencyInterface; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Plugin\Context\Context; @@ -203,10 +205,11 @@ public function testFindDefinitions() { * * @dataProvider providerTestFindByContext * - * @param bool $plugin_access - * The result for the plugin's access method to return. + * @param bool $plugin_is_applicable + * The result for the plugin's isApplicable() method to return. */ - public function testFindByContext($plugin_access) { + public function testFindByContext($plugin_is_applicable) { + $cacheability = new CacheableMetadata(); $contexts = [ 'foo' => new Context(new ContextDefinition('foo')), ]; @@ -218,10 +221,10 @@ public function testFindByContext($plugin_access) { $this->discovery->getDefinitions()->willReturn($definitions); $provider_access = $this->prophesize(SectionStorageInterface::class); - $provider_access->access('test_operation')->willReturn($plugin_access); + $provider_access->isApplicable($cacheability)->willReturn($plugin_is_applicable); $no_access = $this->prophesize(SectionStorageInterface::class); - $no_access->access('test_operation')->willReturn(FALSE); + $no_access->isApplicable($cacheability)->willReturn(FALSE); $missing_contexts = $this->prophesize(SectionStorageInterface::class); @@ -235,8 +238,8 @@ public function testFindByContext($plugin_access) { $this->factory->createInstance('missing_contexts', [])->willReturn($missing_contexts->reveal()); $this->factory->createInstance('provider_access', [])->willReturn($provider_access->reveal()); - $result = $this->manager->findByContext('test_operation', $contexts); - if ($plugin_access) { + $result = $this->manager->findByContext($contexts, $cacheability); + if ($plugin_is_applicable) { $this->assertSame($provider_access->reveal(), $result); } else { @@ -249,11 +252,59 @@ public function testFindByContext($plugin_access) { */ public function providerTestFindByContext() { // Data provider values are: - // - the result for the plugin's access method to return. + // - the result for the plugin's isApplicable() method to return. $data = []; $data['plugin access: true'] = [TRUE]; $data['plugin access: false'] = [FALSE]; return $data; } + /** + * @covers ::findByContext + */ + public function testFindByContextCacheableSectionStorage() { + $cacheability = new CacheableMetadata(); + $contexts = [ + 'foo' => new Context(new ContextDefinition('foo')), + ]; + + $definitions = [ + 'first' => new SectionStorageDefinition(), + 'second' => new SectionStorageDefinition(), + ]; + $this->discovery->getDefinitions()->willReturn($definitions); + + // Create a plugin that has cacheability info itself as a cacheable object + // and from within ::isApplicable() but is not applicable. + $first_plugin = $this->prophesize(SectionStorageInterface::class); + $first_plugin->willImplement(CacheableDependencyInterface::class); + $first_plugin->getCacheContexts()->willReturn([]); + $first_plugin->getCacheTags()->willReturn(['first_plugin_cacheable_dependency']); + $first_plugin->getCacheMaxAge()->willReturn(-1); + $first_plugin->isApplicable($cacheability)->will(function ($arguments) { + $arguments[0]->addCacheTags(['first_plugin']); + return FALSE; + }); + + // Create a plugin that adds cacheability info from within ::isApplicable() + // and is applicable. + $second_plugin = $this->prophesize(SectionStorageInterface::class); + $second_plugin->isApplicable($cacheability)->will(function ($arguments) { + $arguments[0]->addCacheTags(['second_plugin']); + return TRUE; + }); + + $this->factory->createInstance('first', [])->willReturn($first_plugin->reveal()); + $this->factory->createInstance('second', [])->willReturn($second_plugin->reveal()); + + // Do not do any filtering based on context. + $this->contextHandler->filterPluginDefinitionsByContexts($contexts, $definitions)->willReturnArgument(1); + $this->contextHandler->applyContextMapping($first_plugin, $contexts)->shouldBeCalled(); + $this->contextHandler->applyContextMapping($second_plugin, $contexts)->shouldBeCalled(); + + $result = $this->manager->findByContext($contexts, $cacheability); + $this->assertSame($second_plugin->reveal(), $result); + $this->assertSame(['first_plugin', 'second_plugin'], $cacheability->getCacheTags()); + } + }