diff --git a/core/modules/layout_builder/config/schema/layout_builder.schema.yml b/core/modules/layout_builder/config/schema/layout_builder.schema.yml index 682caa78c4..287215c8e9 100644 --- a/core/modules/layout_builder/config/schema/layout_builder.schema.yml +++ b/core/modules/layout_builder/config/schema/layout_builder.schema.yml @@ -44,3 +44,20 @@ layout_builder.component: additional: type: ignore label: 'Additional data' + +inline_block_contents: + type: block_settings + label: 'Inline content block' + mapping: + view_mode: + type: string + lable: 'View mode' + block_revision_id: + type: integer + label: 'Block revision ID' + block_serialized: + type: string + label: 'Serialized block' + +block.settings.inline_block_content:*: + type: inline_block_contents diff --git a/core/modules/layout_builder/layout_builder.install b/core/modules/layout_builder/layout_builder.install index acb1e4fdf3..42cf53f675 100644 --- a/core/modules/layout_builder/layout_builder.install +++ b/core/modules/layout_builder/layout_builder.install @@ -6,6 +6,8 @@ */ use Drupal\Core\Cache\Cache; +use Drupal\Core\Database\Database; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\layout_builder\Section; @@ -38,3 +40,75 @@ function layout_builder_install() { // prepare for future changes. Cache::invalidateTags(['rendered']); } + +/** + * Implements hook_schema(). + */ +function layout_builder_schema() { + $schema['inline_block_content_usage'] = [ + 'description' => 'Track where a block_content entity is used.', + 'fields' => [ + 'block_content_id' => [ + 'description' => 'The block_content entity ID.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'layout_entity_type' => [ + 'description' => 'The entity type of the parent entity.', + 'type' => 'varchar_ascii', + 'length' => EntityTypeInterface::ID_MAX_LENGTH, + 'not null' => FALSE, + 'default' => '', + ], + 'layout_entity_id' => [ + 'description' => 'The ID of the parent entity.', + 'type' => 'varchar_ascii', + 'length' => 128, + 'not null' => FALSE, + 'default' => 0, + ], + ], + 'primary key' => ['block_content_id'], + 'indexes' => [ + 'type_id' => ['layout_entity_type', 'layout_entity_id'], + ], + ]; + return $schema; +} + +/** + * Create the 'inline_block_content_usage' table. + */ +function layout_builder_update_8001() { + $inline_block_content_usage = [ + 'description' => 'Track where a entity is used.', + 'fields' => [ + 'block_content_id' => [ + 'description' => 'The block_content entity ID.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'layout_entity_type' => [ + 'description' => 'The entity type of the parent entity.', + 'type' => 'varchar_ascii', + 'length' => EntityTypeInterface::ID_MAX_LENGTH, + 'not null' => FALSE, + 'default' => '', + ], + 'layout_entity_id' => [ + 'description' => 'The ID of the parent entity.', + 'type' => 'varchar_ascii', + 'length' => 128, + 'not null' => FALSE, + 'default' => 0, + ], + ], + 'primary key' => ['block_content_id'], + 'indexes' => [ + 'type_id' => ['layout_entity_type', 'layout_entity_id'], + ], + ]; + Database::getConnection()->schema()->createTable('inline_block_content_usage', $inline_block_content_usage); +} diff --git a/core/modules/layout_builder/layout_builder.module b/core/modules/layout_builder/layout_builder.module index 2138a045f3..c913c7e4a1 100644 --- a/core/modules/layout_builder/layout_builder.module +++ b/core/modules/layout_builder/layout_builder.module @@ -5,6 +5,7 @@ * Provides hook implementations for Layout Builder. */ +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Url; @@ -12,6 +13,7 @@ use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplayStorage; use Drupal\layout_builder\Form\LayoutBuilderEntityViewDisplayForm; +use Drupal\layout_builder\EntityOperations; /** * Implements hook_help(). @@ -81,3 +83,51 @@ function layout_builder_field_config_delete(FieldConfigInterface $field_config) $sample_entity_generator->delete($field_config->getTargetEntityTypeId(), $field_config->getTargetBundle()); \Drupal::service('plugin.manager.block')->clearCachedDefinitions(); } + +/** + * Implements hook_entity_presave(). + */ +function layout_builder_entity_presave(EntityInterface $entity) { + if (\Drupal::moduleHandler()->moduleExists('block_content')) { + /** @var \Drupal\layout_builder\EntityOperations $entity_operations */ + $entity_operations = \Drupal::classResolver(EntityOperations::class); + $entity_operations->handlePreSave($entity); + } +} + +/** + * Implements hook_entity_delete(). + */ +function layout_builder_entity_delete(EntityInterface $entity) { + if (\Drupal::moduleHandler()->moduleExists('block_content')) { + /** @var \Drupal\layout_builder\EntityOperations $entity_operations */ + $entity_operations = \Drupal::classResolver(EntityOperations::class); + $entity_operations->handleEntityDelete($entity); + } +} + +/** + * Implements hook_cron(). + */ +function layout_builder_cron() { + if (\Drupal::moduleHandler()->moduleExists('block_content')) { + /** @var \Drupal\layout_builder\EntityOperations $entity_operations */ + $entity_operations = \Drupal::classResolver(EntityOperations::class); + $entity_operations->removeUnused(); + } +} + +/** + * Implements hook_plugin_filter_TYPE_alter(). + */ +function layout_builder_plugin_filter_block_alter(array &$definitions, array $extra, $consumer) { + // @todo Determine the 'inline_block_content' blocks should be allowe outside + // of layout_builder https://www.drupal.org/node/2979142. + if ($consumer !== 'layout_builder') { + foreach ($definitions as $id => $definition) { + if ($definition['id'] === 'inline_block_content') { + unset($definitions[$id]); + } + } + } +} diff --git a/core/modules/layout_builder/layout_builder.services.yml b/core/modules/layout_builder/layout_builder.services.yml index 4fe50929bd..1f3a70e44e 100644 --- a/core/modules/layout_builder/layout_builder.services.yml +++ b/core/modules/layout_builder/layout_builder.services.yml @@ -39,3 +39,6 @@ services: logger.channel.layout_builder: parent: logger.channel_base arguments: ['layout_builder'] + inline_block_content.usage: + class: Drupal\layout_builder\InlineBlockContentUsage + arguments: ['@database'] diff --git a/core/modules/layout_builder/src/EntityOperations.php b/core/modules/layout_builder/src/EntityOperations.php new file mode 100644 index 0000000000..49f9885d1a --- /dev/null +++ b/core/modules/layout_builder/src/EntityOperations.php @@ -0,0 +1,264 @@ +entityTypeManager = $entityTypeManager; + $this->storage = $entityTypeManager->getStorage('block_content'); + $this->usage = $usage; + $this->database = $database; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('inline_block_content.usage'), + $container->get('database') + ); + } + + /** + * Remove all unused entities on save. + * + * Entities that were used in prevision revisions will be removed if not + * saving a new revision. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The parent entity. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + protected function removeUnusedForEntityOnSave(EntityInterface $entity) { + // If the entity is new or '$entity->original' is not set then there will + // not be any unused inline blocks to remove. + if ($entity->isNew() || !isset($entity->original)) { + return; + } + $sections = $this->getEntitySections($entity); + // If this is a layout override and there are no sections then it is a new + // override. + if ($entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout') && empty($sections)) { + return; + } + // If this is a revisionable entity then do not remove block_content + // entities. They could be referenced in previous revisions even if this is + // not a new revision. + if ($entity instanceof RevisionableInterface) { + return; + } + $original_sections = $this->getEntitySections($entity->original); + $current_revision_ids = $this->getInBlockRevisionIdsInSection($sections); + // If there are any revisions in the original that aren't current there may + // some blocks that need to be removed. + if ($original_revision_ids = array_diff($this->getInBlockRevisionIdsInSection($original_sections), $current_revision_ids)) { + if ($removed_ids = array_diff($this->getBlockIdsForRevisionIds($original_revision_ids), $this->getBlockIdsForRevisionIds($current_revision_ids))) { + foreach ($removed_ids as $block_content_id) { + if ($block = $this->storage->load($block_content_id)) { + $block->delete(); + } + } + } + } + } + + /** + * Handles entity tracking on deleting a parent entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The parent entity. + */ + public function handleEntityDelete(EntityInterface $entity) { + if ($this->isLayoutCompatibleEntity($entity)) { + $this->usage->removeByLayoutEntity($entity); + } + } + + /** + * Handles saving a parent entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The parent entity. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\Core\Entity\EntityStorageException + * @throws \Exception + */ + public function handlePreSave(EntityInterface $entity) { + if (!$this->isLayoutCompatibleEntity($entity)) { + return; + } + $duplicate_blocks = FALSE; + + if ($sections = $this->getEntitySections($entity)) { + if ($entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout')) { + if (!$entity->isNew() && isset($entity->original)) { + /** @var \Drupal\layout_builder\Field\LayoutSectionItemList $original_sections_field */ + $original_sections_field = $entity->original->get('layout_builder__layout'); + if ($original_sections_field->isEmpty()) { + // @todo Is there a better way to tell if Layout Override is new? + // what if is overridden and all sections removed. Currently if you + // remove all sections from an override it reverts to the default. + // Is that a feature or a bug? + $duplicate_blocks = TRUE; + } + } + } + $new_revision = FALSE; + if ($entity instanceof RevisionableInterface) { + // If the parent entity will have a new revision create a new revision + // of the block. + // @todo Currently revisions are not actually created. + // @see https://www.drupal.org/node/2937199 + // To bypass this always make a revision because the parent entity is + // instance of RevisionableInterface. After the issue is fixed only + // create a new revision if '$entity->isNewRevision()'. + $new_revision = TRUE; + } + + foreach ($this->getInlineBlockComponents($sections) as $component) { + /** @var \Drupal\layout_builder\Plugin\Block\InlineBlockContentBlock $plugin */ + $plugin = $component->getPlugin(); + $pre_save_configuration = $plugin->getConfiguration(); + $plugin->saveBlockContent($new_revision, $duplicate_blocks); + $post_save_configuration = $plugin->getConfiguration(); + if ($duplicate_blocks || (empty($pre_save_configuration['block_revision_id']) && !empty($post_save_configuration['block_revision_id']))) { + $this->usage->addUsage($this->getPluginBlockId($plugin), $entity->getEntityTypeId(), $entity->id()); + } + $component->setConfiguration($plugin->getConfiguration()); + } + } + $this->removeUnusedForEntityOnSave($entity); + } + + /** + * Gets a block ID for a inline block content plugin. + * + * @param \Drupal\Component\Plugin\PluginInspectionInterface $plugin + * The inline block content plugin. + * + * @return int + * The block content ID or null none available. + */ + protected function getPluginBlockId(PluginInspectionInterface $plugin) { + /** @var \Drupal\Component\Plugin\ConfigurablePluginInterface $plugin */ + $configuration = $plugin->getConfiguration(); + if (!empty($configuration['block_revision_id'])) { + $query = $this->storage->getQuery(); + $query->condition('revision_id', $configuration['block_revision_id']); + return array_values($query->execute())[0]; + } + return NULL; + } + + /** + * Delete the content blocks and delete the usage records. + * + * @param int[] $block_content_ids + * The block content entity IDs. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + protected function deleteBlocksAndUsage(array $block_content_ids) { + foreach ($block_content_ids as $block_content_id) { + if ($block = $this->storage->load($block_content_id)) { + $block->delete(); + } + } + $this->usage->deleteUsage($block_content_ids); + } + + /** + * Removes unused block content entities. + * + * @param int $limit + * The maximum number of block content entities to remove. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function removeUnused($limit = 100) { + $this->deleteBlocksAndUsage($this->usage->getUnused($limit)); + } + + /** + * Gets blocks IDs for an array of revision IDs. + * + * @param int[] $revision_ids + * The revision IDs. + * + * @return int[] + * The block IDs. + */ + protected function getBlockIdsForRevisionIds(array $revision_ids) { + if ($revision_ids) { + $query = $this->storage->getQuery(); + $query->condition('revision_id', $revision_ids, 'IN'); + $block_ids = $query->execute(); + return $block_ids; + } + return []; + } + +} diff --git a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php index 181ed8229b..fc3818bf31 100644 --- a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php +++ b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php @@ -2,6 +2,7 @@ namespace Drupal\layout_builder\EventSubscriber; +use Drupal\Core\Access\AccessDependentInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Block\BlockPluginInterface; use Drupal\Core\Session\AccountInterface; @@ -56,6 +57,18 @@ public function onBuildRender(SectionComponentBuildRenderArrayEvent $event) { return; } + // Set block access dependency even if we are not checking access on + // this level. The block itself may render another AccessDependentInterface + // object and need to pass on this value. + if ($block instanceof AccessDependentInterface) { + $contexts = $event->getContexts(); + if (isset($contexts['layout_builder.entity'])) { + if ($entity = $contexts['layout_builder.entity']->getContextValue()) { + $block->setAccessDependency($entity); + } + } + } + // Only check access if the component is not being previewed. if ($event->inPreview()) { $access = AccessResult::allowed()->setCacheMaxAge(0); diff --git a/core/modules/layout_builder/src/EventSubscriber/SetInlineBlockDependency.php b/core/modules/layout_builder/src/EventSubscriber/SetInlineBlockDependency.php new file mode 100644 index 0000000000..62e5983fff --- /dev/null +++ b/core/modules/layout_builder/src/EventSubscriber/SetInlineBlockDependency.php @@ -0,0 +1,174 @@ +entityTypeManager = $entity_type_manager; + $this->database = $database; + $this->usage = $usage; + + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('database'), + $container->get('inline_block_content.usage') + + ); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + BlockContentEvents::INLINE_BLOCK_GET_DEPENDENCY => 'onGetDependency', + ]; + } + + /** + * Handles the BlockContentEvents::INLINE_BLOCK_GET_DEPENDENCY event. + * + * @param \Drupal\block_content\Event\BlockContentGetDependencyEvent $event + * The event. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public function onGetDependency(BlockContentGetDependencyEvent $event) { + $this->setLayoutDependency($event->getBlockContent()); + } + + /** + * Sets the access dependency on a non-reusable block content entity. + * + * If the content block is used in a layout for a non-revisionable entity the + * entity will be set. + * + * If the content block is used in a layout for a revisionable entity the + * first revision that uses the block will be set as the access dependency. A + * module can override this behavior by creating an event subscriber that sets + * a different revision as the access dependency. + * + * @param \Drupal\block_content\BlockContentInterface $block_content + * The block content entity. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public function setLayoutDependency(BlockContentInterface $block_content) { + if (!$block_content->isReusable() && empty($block_content->getAccessDependency())) { + /** @var \Drupal\layout_builder\InlineBlockContentUsage $usage */ + if ($layout_entity_info = $this->usage->getUsage($block_content->id())) { + $layout_entity_storage = $this->entityTypeManager->getStorage($layout_entity_info->layout_entity_type); + $layout_entity = $layout_entity_storage->load($layout_entity_info->layout_entity_id); + if ($this->isLayoutCompatibleEntity($layout_entity)) { + if (!$layout_entity->getEntityType()->isRevisionable()) { + $block_content->setAccessDependency($layout_entity); + return; + } + else { + foreach ($this->getEntityRevisionIds($layout_entity) as $revision_id) { + $revision = $layout_entity_storage->loadRevision($revision_id); + $block_revision_ids = $this->getInBlockRevisionIdsInSection($this->getEntitySections($revision)); + if (in_array($block_content->getRevisionId(), $block_revision_ids)) { + $block_content->setAccessDependency($revision); + return; + } + } + } + } + } + } + } + + /** + * Gets the revision IDs for an entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return int[] + * The revision IDs. + * + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected function getEntityRevisionIds(EntityInterface $entity) { + $entity_type = $this->entityTypeManager->getDefinition($entity->getEntityTypeId()); + if ($revision_table = $entity_type->getRevisionTable()) { + $query = $this->database->select($revision_table); + $query->condition($entity_type->getKey('id'), $entity->id()); + $query->fields($revision_table, [$entity_type->getKey('revision')]); + $query->orderBy($entity_type->getKey('revision'), 'DESC'); + return $query->execute()->fetchCol(); + } + return []; + } + +} diff --git a/core/modules/layout_builder/src/Form/RevertOverridesForm.php b/core/modules/layout_builder/src/Form/RevertOverridesForm.php index b6d07d9089..cf7d91844d 100644 --- a/core/modules/layout_builder/src/Form/RevertOverridesForm.php +++ b/core/modules/layout_builder/src/Form/RevertOverridesForm.php @@ -103,6 +103,9 @@ public function buildForm(array $form, FormStateInterface $form_state, SectionSt * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { + // @todo Remove this quick fix after https://www.drupal.org/node/2970801 + $this->sectionStorage = \Drupal::service('plugin.manager.layout_builder.section_storage')->loadFromStorageId($this->sectionStorage->getStorageType(), $this->sectionStorage->getStorageId()); + // Remove all sections. while ($this->sectionStorage->count()) { $this->sectionStorage->removeSection(0); diff --git a/core/modules/layout_builder/src/InlineBlockContentUsage.php b/core/modules/layout_builder/src/InlineBlockContentUsage.php new file mode 100644 index 0000000000..352942a6e5 --- /dev/null +++ b/core/modules/layout_builder/src/InlineBlockContentUsage.php @@ -0,0 +1,113 @@ +connection = $connection; + } + + /** + * Add a usage record. + * + * @param int $block_content_id + * The block content id. + * @param string $layout_entity_type + * The layout entity type. + * @param string $layout_entity_id + * The layout entity id. + * + * @throws \Exception + */ + public function addUsage($block_content_id, $layout_entity_type, $layout_entity_id) { + $this->connection->merge('inline_block_content_usage') + ->keys([ + 'block_content_id' => $block_content_id, + 'layout_entity_id' => $layout_entity_id, + 'layout_entity_type' => $layout_entity_type, + ])->execute(); + } + + /** + * Gets unused inline block content IDs. + * + * @param int $limit + * The maximum number of block content entity IDs to return. + * + * @return int[] + * The entity IDs. + */ + public function getUnused($limit = 100) { + $query = $this->connection->select('inline_block_content_usage', 't'); + $query->fields('t', ['block_content_id']); + $query->isNull('layout_entity_id'); + $query->isNull('layout_entity_type'); + return $query->range(0, $limit)->execute()->fetchCol(); + } + + /** + * Remove usage record by layout entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The layout entity. + */ + public function removeByLayoutEntity(EntityInterface $entity) { + $query = $this->connection->update('inline_block_content_usage') + ->fields([ + 'layout_entity_type' => NULL, + 'layout_entity_id' => NULL, + ]); + $query->condition('layout_entity_type', $entity->getEntityTypeId()); + $query->condition('layout_entity_id', $entity->id()); + $query->execute(); + } + + /** + * Delete the content blocks and delete the usage records. + * + * @param int[] $block_content_ids + * The block content entity IDs. + */ + public function deleteUsage(array $block_content_ids) { + $query = $this->connection->delete('inline_block_content_usage')->condition('block_content_id', $block_content_ids, 'IN'); + $query->execute(); + } + + /** + * Gets usage record for block content by ID. + * + * @param int $block_content_id + * The block content entity ID. + * + * @return object + * The usage record with properties layout_entity_id and layout_entity_type. + */ + public function getUsage($block_content_id) { + $query = $this->connection->select('inline_block_content_usage'); + $query->condition('block_content_id', $block_content_id); + $query->fields('inline_block_content_usage', ['layout_entity_id', 'layout_entity_type']); + $query->range(0, 1); + return $query->execute()->fetch(); + } + +} diff --git a/core/modules/layout_builder/src/LayoutBuilderServiceProvider.php b/core/modules/layout_builder/src/LayoutBuilderServiceProvider.php new file mode 100644 index 0000000000..a8179da4c6 --- /dev/null +++ b/core/modules/layout_builder/src/LayoutBuilderServiceProvider.php @@ -0,0 +1,35 @@ +getParameter('container.modules'); + if (isset($modules['block_content'])) { + $definition = new Definition(SetInlineBlockDependency::class); + $definition->setArguments([ + new Reference('entity_type.manager'), + new Reference('database'), + new Reference('inline_block_content.usage'), + ]); + $definition->addTag('event_subscriber'); + $container->setDefinition('layout_builder.get_block_dependency_subscriber', $definition); + } + } + +} diff --git a/core/modules/layout_builder/src/LayoutEntityHelperTrait.php b/core/modules/layout_builder/src/LayoutEntityHelperTrait.php new file mode 100644 index 0000000000..c288cfbbae --- /dev/null +++ b/core/modules/layout_builder/src/LayoutEntityHelperTrait.php @@ -0,0 +1,92 @@ +getEntityTypeId() === 'entity_view_display' && $entity instanceof LayoutBuilderEntityViewDisplay) || + ($entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout')); + } + + /** + * Gets revision IDs for layout sections. + * + * @param \Drupal\layout_builder\Section[] $sections + * The layout sections. + * + * @return int[] + * The revision IDs. + */ + protected function getInBlockRevisionIdsInSection(array $sections) { + $revision_ids = []; + foreach ($this->getInlineBlockComponents($sections) as $component) { + $configuration = $component->getPlugin()->getConfiguration(); + if (!empty($configuration['block_revision_id'])) { + $revision_ids[] = $configuration['block_revision_id']; + } + } + return $revision_ids; + } + + /** + * Gets the sections for an entity if any. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return \Drupal\layout_builder\Section[]|null + * The entity layout sections if available. + */ + protected function getEntitySections(EntityInterface $entity) { + if ($entity->getEntityTypeId() === 'entity_view_display' && $entity instanceof LayoutBuilderEntityViewDisplay) { + return $entity->getSections(); + } + elseif ($entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout')) { + return $entity->get('layout_builder__layout')->getSections(); + } + return NULL; + } + + /** + * Gets components that have Inline Block plugins. + * + * @param \Drupal\layout_builder\Section[] $sections + * The layout sections. + * + * @return \Drupal\layout_builder\SectionComponent[] + * The components that contain Inline Block plugins. + */ + protected function getInlineBlockComponents(array $sections) { + $inline_components = []; + foreach ($sections as $section) { + $components = $section->getComponents(); + + foreach ($components as $component) { + $plugin = $component->getPlugin(); + if ($plugin instanceof InlineBlockContentBlock) { + $inline_components[] = $component; + } + } + } + return $inline_components; + } + +} diff --git a/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php b/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php new file mode 100644 index 0000000000..0b65a08caa --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php @@ -0,0 +1,287 @@ +entityTypeManager = $entity_type_manager; + $this->entityDisplayRepository = $entity_display_repository; + if (!empty($this->configuration['block_revision_id']) || !empty($this->configuration['block_serialized'])) { + $this->isNew = FALSE; + } + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_type.manager'), + $container->get('entity_display.repository') + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'view_mode' => 'full', + 'block_revision_id' => NULL, + 'block_serialized' => NULL, + ]; + } + + /** + * {@inheritdoc} + */ + public function blockForm($form, FormStateInterface $form_state) { + $block = $this->getEntity(); + + // Add the entity form display in a process callback so that #parents can + // be successfully propagated to field widgets. + $form['block_form'] = [ + '#type' => 'container', + '#process' => [[static::class, 'processBlockForm']], + '#block' => $block, + ]; + + $options = $this->entityDisplayRepository->getViewModeOptionsByBundle('block_content', $block->bundle()); + + $form['view_mode'] = [ + '#type' => 'select', + '#options' => $options, + '#title' => $this->t('View mode'), + '#description' => $this->t('The view mode in which to render the block.'), + '#default_value' => $this->configuration['view_mode'], + '#access' => count($options) > 1, + ]; + return $form; + } + + /** + * Process callback to insert a Custom Block form. + * + * @param array $element + * The containing element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The containing element, with the Custom Block form inserted. + */ + public static function processBlockForm(array $element, FormStateInterface $form_state) { + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $element['#block']; + EntityFormDisplay::collectRenderDisplay($block, 'edit')->buildForm($block, $element, $form_state); + $element['revision_log']['#access'] = FALSE; + $element['info']['#access'] = FALSE; + return $element; + } + + /** + * {@inheritdoc} + */ + public function blockValidate($form, FormStateInterface $form_state) { + $block_form = $form['block_form']; + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $block_form['#block']; + $form_display = EntityFormDisplay::collectRenderDisplay($block, 'edit'); + $complete_form_state = $form_state instanceof SubformStateInterface ? $form_state->getCompleteFormState() : $form_state; + $form_display->extractFormValues($block, $block_form, $complete_form_state); + $form_display->validateFormValues($block, $block_form, $complete_form_state); + // @todo Remove when https://www.drupal.org/project/drupal/issues/2948549 is closed. + $form_state->setTemporaryValue('block_form_parents', $block_form['#parents']); + } + + /** + * {@inheritdoc} + */ + public function blockSubmit($form, FormStateInterface $form_state) { + $this->configuration['view_mode'] = $form_state->getValue('view_mode'); + + // @todo Remove when https://www.drupal.org/project/drupal/issues/2948549 is closed. + $block_form = NestedArray::getValue($form, $form_state->getTemporaryValue('block_form_parents')); + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $block_form['#block']; + $form_display = EntityFormDisplay::collectRenderDisplay($block, 'edit'); + $complete_form_state = ($form_state instanceof SubformStateInterface) ? $form_state->getCompleteFormState() : $form_state; + $form_display->extractFormValues($block, $block_form, $complete_form_state); + $block->setInfo($this->configuration['label']); + $this->configuration['block_serialized'] = serialize($block); + } + + /** + * {@inheritdoc} + */ + protected function blockAccess(AccountInterface $account) { + if ($this->getEntity()) { + return $this->getEntity()->access('view', $account, TRUE); + } + return AccessResult::forbidden(); + } + + /** + * {@inheritdoc} + */ + public function build() { + $block = $this->getEntity(); + return $this->entityTypeManager->getViewBuilder($block->getEntityTypeId())->view($block, $this->configuration['view_mode']); + } + + /** + * Loads or creates the block content entity of the block. + * + * @return \Drupal\block_content\BlockContentInterface + * The block content entity. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected function getEntity() { + if (!isset($this->blockContent)) { + if (!empty($this->configuration['block_serialized'])) { + $this->blockContent = unserialize($this->configuration['block_serialized']); + } + elseif (!empty($this->configuration['block_revision_id'])) { + $entity = $this->entityTypeManager->getStorage('block_content')->loadRevision($this->configuration['block_revision_id']); + $this->blockContent = $entity; + } + else { + $this->blockContent = $this->entityTypeManager->getStorage('block_content')->create([ + 'type' => $this->getDerivativeId(), + 'reusable' => FALSE, + ]); + } + } + if ($this->blockContent instanceof AccessDependentInterface && $dependee = $this->getAccessDependency()) { + $this->blockContent->setAccessDependency($dependee); + } + return $this->blockContent; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + if ($this->isNew) { + // If the Content Block is new then don't provide a default label. + // @todo Blocks may be serialized before the layout is saved so we + // can't check $this->getEntity()->isNew(). + unset($form['label']['#default_value']); + } + $form['label']['#description'] = $this->t('The title of the block as shown to the user.'); + return $form; + } + + /** + * Saves the block_content entity for this plugin. + * + * @param bool $new_revision + * Whether to create new revision. + * @param bool $duplicate_block + * Whether to duplicate the "block_content" entity. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function saveBlockContent($new_revision = FALSE, $duplicate_block = FALSE) { + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = NULL; + if ($duplicate_block && !empty($this->configuration['block_revision_id']) && empty($this->configuration['block_serialized'])) { + $entity = $this->entityTypeManager->getStorage('block_content')->loadRevision($this->configuration['block_revision_id']); + $block = $entity->createDuplicate(); + } + elseif (isset($this->configuration['block_serialized'])) { + $block = unserialize($this->configuration['block_serialized']); + if (!empty($this->configuration['block_revision_id']) && $duplicate_block) { + $block = $block->createDuplicate(); + } + } + if ($block) { + if ($new_revision) { + $block->setNewRevision(); + } + $block->save(); + $this->configuration['block_revision_id'] = $block->getRevisionId(); + $this->configuration['block_serialized'] = NULL; + } + } + +} diff --git a/core/modules/layout_builder/src/Plugin/Derivative/InlineBlockContentDeriver.php b/core/modules/layout_builder/src/Plugin/Derivative/InlineBlockContentDeriver.php new file mode 100644 index 0000000000..3a8148be08 --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Derivative/InlineBlockContentDeriver.php @@ -0,0 +1,57 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + $this->derivatives = []; + if ($this->entityTypeManager->hasDefinition('block_content_type')) { + $block_content_types = $this->entityTypeManager->getStorage('block_content_type')->loadMultiple(); + foreach ($block_content_types as $id => $type) { + $this->derivatives[$id] = $base_plugin_definition; + $this->derivatives[$id]['admin_label'] = $type->label(); + $this->derivatives[$id]['config_dependencies'][$type->getConfigDependencyKey()][] = $type->getConfigDependencyName(); + } + } + return parent::getDerivativeDefinitions($base_plugin_definition); + } + +} diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php index 40c0503cea..4fbf86f56f 100644 --- a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php +++ b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php @@ -101,6 +101,7 @@ public function testLayoutBuilderUi() { // Save the defaults. $assert_session->linkExists('Save Layout'); $this->clickLink('Save Layout'); + $assert_session->pageTextContains('The layout has been saved.'); $assert_session->addressEquals("$field_ui_prefix/display/default"); // The node uses the defaults, no overrides available. diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php new file mode 100644 index 0000000000..5cc0875a93 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php @@ -0,0 +1,361 @@ +assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + ])); + + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + $this->drupalGet($field_ui_prefix . '/display/default'); + $this->clickLink('Manage layout'); + $assert_session->addressEquals("$field_ui_prefix/display-layout/default"); + // Add a basic block with the body field set. + $this->addInlineBlockToLayout('Block title', 'The DEFAULT block body'); + $this->assertSaveLayout(); + + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The DEFAULT block body'); + $this->drupalGet('node/2'); + $assert_session->pageTextContains('The DEFAULT block body'); + + // Enable overrides. + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + $this->drupalGet('node/1/layout'); + + // Confirm the block can be edited. + $this->drupalGet('node/1/layout'); + $this->configureInlineBlock('The DEFAULT block body', 'The NEW block body!'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The NEW block body'); + $assert_session->pageTextNotContains('The DEFAULT block body'); + $this->drupalGet('node/2'); + // Node 2 should use default layout. + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body'); + + // Add a basic block with the body field set. + $this->drupalGet('node/1/layout'); + $this->addInlineBlockToLayout('2nd Block title', 'The 2nd block body'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The NEW block body!'); + $assert_session->pageTextContains('The 2nd block body'); + $this->drupalGet('node/2'); + // Node 2 should use default layout. + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body'); + $assert_session->pageTextNotContains('The 2nd block body'); + + // Confirm the block can be edited. + $this->drupalGet('node/1/layout'); + /* @var \Behat\Mink\Element\NodeElement $inline_block_2 */ + $inline_block_2 = $page->findAll('css', static::INLINE_BLOCK_LOCATOR)[1]; + $uuid = $inline_block_2->getAttribute('data-layout-block-uuid'); + $block_css_locator = static::INLINE_BLOCK_LOCATOR . "[data-layout-block-uuid=\"$uuid\"]"; + $this->configureInlineBlock('The 2nd block body', 'The 2nd NEW block body!', $block_css_locator); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The NEW block body!'); + $assert_session->pageTextContains('The 2nd NEW block body!'); + $this->drupalGet('node/2'); + // Node 2 should use default layout. + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body!'); + $assert_session->pageTextNotContains('The 2nd NEW block body!'); + + // The default layout entity block should be changed. + $this->drupalGet("$field_ui_prefix/display-layout/default"); + $assert_session->pageTextContains('The DEFAULT block body'); + // Confirm default layout still only has 1 entity block. + $assert_session->elementsCount('css', static::INLINE_BLOCK_LOCATOR, 1); + } + + /** + * Tests adding a new entity block and then not saving the layout. + * + * @dataProvider layoutNoSaveProvider + */ + public function testNoLayoutSave($operation, $no_save_link_text, $confirm_button_text) { + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + ])); + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->assertEmpty($this->blockStorage->loadMultiple(), 'No entity blocks exist'); + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + // Enable overrides. + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + + $this->drupalGet('node/1/layout'); + $this->addInlineBlockToLayout('Block title', 'The block body'); + $this->clickLink($no_save_link_text); + if ($confirm_button_text) { + $page->pressButton($confirm_button_text); + } + $this->drupalGet('node/1'); + $this->assertEmpty($this->blockStorage->loadMultiple(), 'No entity blocks were created when layout is canceled.'); + $assert_session->pageTextNotContains('The block body'); + + $this->drupalGet('node/1/layout'); + + $this->addInlineBlockToLayout('Block title', 'The block body'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The block body'); + $blocks = $this->blockStorage->loadMultiple(); + $this->assertEquals(count($blocks), 1); + /* @var \Drupal\Core\Entity\ContentEntityBase $block */ + $block = array_pop($blocks); + $revision_id = $block->getRevisionId(); + + // Confirm the block can be edited. + $this->drupalGet('node/1/layout'); + $this->configureInlineBlock('The block body', 'The block updated body'); + + $this->clickLink($no_save_link_text); + if ($confirm_button_text) { + $page->pressButton($confirm_button_text); + } + $this->drupalGet('node/1'); + + $blocks = $this->blockStorage->loadMultiple(); + // When reverting or canceling the update block should not be on the page. + $assert_session->pageTextNotContains('The block updated body'); + if ($operation === 'cancel') { + // When canceling the original block body should appear. + $assert_session->pageTextContains('The block body'); + + $this->assertEquals(count($blocks), 1); + $block = array_pop($blocks); + $this->assertEquals($block->getRevisionId(), $revision_id); + $this->assertEquals($block->get('body')->getValue()[0]['value'], 'The block body'); + } + else { + // The block should not be visible. + // Blocks are currently only deleted when the parent entity is deleted. + $assert_session->pageTextNotContains('The block body'); + } + } + + /** + * Provides test data for ::testNoLayoutSave(). + */ + public function layoutNoSaveProvider() { + return [ + 'cancel' => [ + 'cancel', + 'Cancel Layout', + NULL, + ], + 'revert' => [ + 'revert', + 'Revert to defaults', + 'Revert', + ], + ]; + } + + /** + * Tests entity blocks revisioning. + */ + public function testInlineBlocksRevisioning() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + 'administer nodes', + 'bypass node access', + ])); + + // Enable override. + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + $this->drupalGet('node/1/layout'); + + // Add a entity block. + $this->addInlineBlockToLayout('Block title', 'The DEFAULT block body'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + + $assert_session->pageTextContains('The DEFAULT block body'); + + /** @var \Drupal\node\NodeStorageInterface $node_storage */ + $node_storage = $this->container->get('entity_type.manager')->getStorage('node'); + $original_revision_id = $node_storage->getLatestRevisionId(1); + + // Create a new revision. + $this->drupalGet('node/1/edit'); + $page->findField('title[0][value]')->setValue('Node updated'); + $page->pressButton('Save'); + + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The DEFAULT block body'); + + $assert_session->linkExists('Revisions'); + + // Update the block. + $this->drupalGet('node/1/layout'); + $this->configureInlineBlock('The DEFAULT block body', 'The NEW block body'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The NEW block body'); + $assert_session->pageTextNotContains('The DEFAULT block body'); + + $revision_url = "node/1/revisions/$original_revision_id"; + + // Ensure viewing the previous revision shows the previous block revision. + $this->drupalGet("$revision_url/view"); + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body'); + + // Revert to first revision. + $revision_url = "$revision_url/revert"; + $this->drupalGet($revision_url); + $page->pressButton('Revert'); + + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body'); + } + + /** + * Tests that entity blocks deleted correctly. + * + * @throws \Behat\Mink\Exception\ExpectationException + * @throws \Behat\Mink\Exception\ResponseTextException + */ + public function testDeletion() { + /** @var \Drupal\Core\Cron $cron */ + $cron = \Drupal::service('cron'); + $this->drupalLogin($this->drupalCreateUser([ + 'administer content types', + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + 'administer nodes', + 'bypass node access', + ])); + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Add a block to default layout. + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + $this->drupalGet($field_ui_prefix . '/display/default'); + $this->clickLink('Manage layout'); + $assert_session->addressEquals("$field_ui_prefix/display-layout/default"); + $this->addInlineBlockToLayout('Block title', 'The DEFAULT block body'); + $this->assertSaveLayout(); + + $this->assertCount(1, $this->blockStorage->loadMultiple()); + $default_block_id = $this->getLatestBlockEntityId(); + + // Ensure the block shows up on node pages. + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The DEFAULT block body'); + $this->drupalGet('node/2'); + $assert_session->pageTextContains('The DEFAULT block body'); + + // Enable overrides. + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + + // Ensure we have 2 copies of the block in node overrides. + $this->drupalGet('node/1/layout'); + $this->assertSaveLayout(); + $node_1_block_id = $this->getLatestBlockEntityId(); + + $this->drupalGet('node/2/layout'); + $this->assertSaveLayout(); + $node_2_block_id = $this->getLatestBlockEntityId(); + $this->assertCount(3, $this->blockStorage->loadMultiple()); + + $this->drupalGet($field_ui_prefix . '/display/default'); + $this->clickLink('Manage layout'); + $assert_session->addressEquals("$field_ui_prefix/display-layout/default"); + + $this->assertNotEmpty($this->blockStorage->load($default_block_id)); + // Remove block from default. + $this->removeInlineBlockFromLayout(); + $this->assertSaveLayout(); + // Ensure the block in the default was deleted. + $this->blockStorage->resetCache([$default_block_id]); + $this->assertEmpty($this->blockStorage->load($default_block_id)); + // Ensure other blocks still exist. + $this->assertCount(2, $this->blockStorage->loadMultiple()); + + $this->drupalGet('node/1/layout'); + $assert_session->pageTextContains('The DEFAULT block body'); + + $this->removeInlineBlockFromLayout(); + $this->assertSaveLayout(); + $cron->run(); + // Ensure entity block is not deleted because it is needed in revision. + $this->assertNotEmpty($this->blockStorage->load($node_1_block_id)); + $this->assertCount(2, $this->blockStorage->loadMultiple()); + + // Ensure entity block is deleted when node is deleted. + $this->drupalGet('node/1/delete'); + $page->pressButton('Delete'); + $this->assertEmpty(Node::load(1)); + $cron->run(); + $this->assertEmpty($this->blockStorage->load($node_1_block_id)); + $this->assertCount(1, $this->blockStorage->loadMultiple()); + + // Add another block to the default. + $this->drupalGet($field_ui_prefix . '/display/default'); + $this->clickLink('Manage layout'); + $assert_session->addressEquals("$field_ui_prefix/display-layout/default"); + $this->addInlineBlockToLayout('Title 2', 'Body 2'); + $this->assertSaveLayout(); + $cron->run(); + $default_block2_id = $this->getLatestBlockEntityId(); + $this->assertCount(2, $this->blockStorage->loadMultiple()); + + // Delete the other node so bundle can be deleted. + $this->drupalGet('node/2/delete'); + $page->pressButton('Delete'); + $this->assertEmpty(Node::load(2)); + $cron->run(); + // Ensure entity block was deleted. + $this->assertEmpty($this->blockStorage->load($node_2_block_id)); + $this->assertCount(1, $this->blockStorage->loadMultiple()); + + // Delete the bundle which has the default layout. + $this->drupalGet("$field_ui_prefix/delete"); + $page->pressButton('Delete'); + $cron->run(); + + // Ensure the entity block in default is deleted when bundle is deleted. + $this->assertEmpty($this->blockStorage->load($default_block2_id)); + $this->assertCount(0, $this->blockStorage->loadMultiple()); + } + +} diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php new file mode 100644 index 0000000000..b4d1343f4a --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockPrivateFilesTest.php @@ -0,0 +1,243 @@ + 'txt', + 'uri_scheme' => 'private', + ]; + $this->createFileField('field_file', 'block_content', 'basic', $field_settings); + $this->fileSystem = $this->container->get('file_system'); + } + + /** + * Test access to private files added via inline blocks in the layout builder. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + * @throws \Behat\Mink\Exception\ExpectationException + * @throws \Behat\Mink\Exception\ResponseTextException + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function testPrivateFiles() { + $assert_session = $this->assertSession(); + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + ])); + + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + + // Enable overrides. + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + $this->drupalLogout(); + + // Log in as user you can only configure layouts and access content. + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'access content', + ])); + $this->drupalGet('node/1/layout'); + $file = $this->createPrivateFile('drupal.txt'); + + $file_real_path = $this->fileSystem->realpath($file->getFileUri()); + $this->assertFileExists($file_real_path); + $this->addInlineFileBlockToLayout('The file', $file); + $this->assertSaveLayout(); + + $this->drupalGet('node/1'); + $private_href1 = $this->assertFileAccessibleOnNode($file); + + $this->drupalGet('node/1/layout'); + $this->removeInlineBlockFromLayout(); + $this->assertSaveLayout(); + + $this->drupalGet('node/1'); + $assert_session->pageTextNotContains($file->label()); + // Try to access file directly after it has been removed. + $this->drupalGet($private_href1); + $assert_session->statusCodeEquals(403); + $assert_session->pageTextNotContains($this->getFileSecret($file)); + $this->assertFileExists($file_real_path); + + $file2 = $this->createPrivateFile('2ndFile.txt'); + + $this->drupalGet('node/1/layout'); + $this->addInlineFileBlockToLayout('Number2', $file2); + $this->assertSaveLayout(); + + $this->drupalGet('node/1'); + $private_href2 = $this->assertFileAccessibleOnNode($file2); + + $node = Node::load(1); + $node->setTitle('Update node'); + $node->setNewRevision(); + $node->save(); + + $file3 = $this->createPrivateFile('3rdFile.txt'); + $this->drupalGet('node/1/layout'); + $this->replaceFileInBlock($file3); + $this->assertSaveLayout(); + + $this->drupalGet('node/1'); + $private_href3 = $this->assertFileAccessibleOnNode($file3); + + $this->drupalGet($private_href2); + $assert_session->statusCodeEquals(403); + + $node->setUnpublished(); + $node->save(); + $this->drupalGet('node/1'); + $assert_session->statusCodeEquals('403'); + $this->drupalGet($private_href3); + $assert_session->pageTextNotContains($this->getFileSecret($file3)); + $assert_session->statusCodeEquals(403); + } + + /** + * Replaces the file in the block with another one. + * + * @param \Drupal\file\FileInterface $file + * The file entity. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + */ + protected function replaceFileInBlock(FileInterface $file) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->clickContextualLink(static::INLINE_BLOCK_LOCATOR, 'Configure'); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Remove'); + $assert_session->assertWaitOnAjaxRequest(); + $page->attachFileToField("files[settings_block_form_field_file_0]", $this->fileSystem->realpath($file->getFileUri())); + $page->pressButton('Update'); + $this->assertDialogClosedAndTextVisible($file->label(), static::INLINE_BLOCK_LOCATOR); + } + + /** + * Adds an entity block with a file. + * + * @param string $title + * The title field value. + * @param \Drupal\file\Entity\File $file + * The file entity. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + * @throws \Behat\Mink\Exception\ExpectationException + */ + protected function addInlineFileBlockToLayout($title, File $file) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $page->clickLink('Add Block'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.block-categories details:contains(Create new block)')); + $this->clickLink('Basic block'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->fieldValueEquals('Title', ''); + $page->findField('Title')->setValue($title); + $page->attachFileToField("files[settings_block_form_field_file_0]", $this->fileSystem->realpath($file->getFileUri())); + $page->pressButton('Add Block'); + $this->assertDialogClosedAndTextVisible($file->label(), static::INLINE_BLOCK_LOCATOR); + } + + /** + * Creates a private file. + * + * @param string $file_name + * The file name. + * + * @return \Drupal\Core\Entity\EntityInterface|\Drupal\file\Entity\File + * The file entity. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + protected function createPrivateFile($file_name) { + // Create a new file entity. + $file = File::create([ + 'uid' => 1, + 'filename' => $file_name, + 'uri' => "private://$file_name", + 'filemime' => 'text/plain', + 'status' => FILE_STATUS_PERMANENT, + ]); + file_put_contents($file->getFileUri(), $this->getFileSecret($file)); + $file->save(); + return $file; + } + + /** + * Asserts a file is accessible on the page. + * + * @param \Drupal\file\FileInterface $file + * The file entity. + * + * @return string + * The file href. + * + * @throws \Behat\Mink\Exception\ExpectationException + */ + protected function assertFileAccessibleOnNode(FileInterface $file) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $assert_session->linkExists($file->label()); + $private_href = $page->findLink($file->label())->getAttribute('href'); + $page->clickLink($file->label()); + $assert_session->pageTextContains($this->getFileSecret($file)); + + // Access file directly. + $this->drupalGet($private_href); + $assert_session->pageTextContains($this->getFileSecret($file)); + return $private_href; + } + + /** + * Gets the text secret for a file. + * + * @param \Drupal\file\FileInterface $file + * The file entity. + * + * @return string + * The text secret. + */ + protected function getFileSecret(FileInterface $file) { + return "The secret in {$file->label()}"; + } + +} diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php new file mode 100644 index 0000000000..28714ed98c --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockTestBase.php @@ -0,0 +1,218 @@ +drupalPlaceBlock('local_tasks_block'); + + $this->createContentType(['type' => 'bundle_with_section_field', 'new_revision' => TRUE]); + $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The node title', + 'body' => [ + [ + 'value' => 'The node body', + ], + ], + ]); + $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The node2 title', + 'body' => [ + [ + 'value' => 'The node2 body', + ], + ], + ]); + $bundle = BlockContentType::create([ + 'id' => 'basic', + 'label' => 'Basic block', + 'revision' => 1, + ]); + $bundle->save(); + block_content_add_body_field($bundle->id()); + + $this->blockStorage = $this->container->get('entity_type.manager')->getStorage('block_content'); + } + + /** + * Saves a layout and asserts the message is correct. + * + * @throws \Behat\Mink\Exception\ExpectationException + * @throws \Behat\Mink\Exception\ResponseTextException + */ + protected function assertSaveLayout() { + $assert_session = $this->assertSession(); + $assert_session->linkExists('Save Layout'); + $this->clickLink('Save Layout'); + $this->assertNotEmpty($assert_session->waitForElement('css', '.messages--status')); + + if (stristr($this->getUrl(), 'admin/structure') === FALSE) { + $assert_session->pageTextContains('The layout override has been saved.'); + } + else { + $assert_session->pageTextContains('The layout has been saved.'); + } + } + + /** + * Gets the latest block entity id. + */ + protected function getLatestBlockEntityId() { + $block_ids = \Drupal::entityQuery('block_content')->sort('id', 'DESC')->range(0, 1)->execute(); + $block_id = array_pop($block_ids); + $this->assertNotEmpty($this->blockStorage->load($block_id)); + return $block_id; + } + + /** + * Removes an entity block from the layout but does not save the layout. + */ + protected function removeInlineBlockFromLayout() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $block_text = $page->find('css', static::INLINE_BLOCK_LOCATOR)->getText(); + $this->assertNotEmpty($block_text); + $assert_session->pageTextContains($block_text); + $this->clickContextualLink(static::INLINE_BLOCK_LOCATOR, 'Remove block'); + $assert_session->waitForElement('css', "#drupal-off-canvas input[value='Remove']"); + $assert_session->assertWaitOnAjaxRequest(); + $page->find('css', '#drupal-off-canvas')->pressButton('Remove'); + $this->waitForNoElement('#drupal-off-canvas'); + $this->waitForNoElement(static::INLINE_BLOCK_LOCATOR); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextNotContains($block_text); + } + + /** + * Adds an entity block to the layout. + * + * @param string $title + * The title field value. + * @param string $body + * The body field value. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + * @throws \Behat\Mink\Exception\ExpectationException + */ + protected function addInlineBlockToLayout($title, $body) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $page->clickLink('Add Block'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.block-categories details:contains(Create new block)')); + $this->clickLink('Basic block'); + $assert_session->assertWaitOnAjaxRequest(); + $textarea = $assert_session->waitForElement('css', '[name="settings[block_form][body][0][value]"]'); + $this->assertNotEmpty($textarea); + $assert_session->fieldValueEquals('Title', ''); + $page->findField('Title')->setValue($title); + $textarea->setValue($body); + $page->pressButton('Add Block'); + $this->assertDialogClosedAndTextVisible($body, static::INLINE_BLOCK_LOCATOR); + } + + /** + * Configures an inline block in the Layout Builder. + * + * @param string $old_body + * The old body field value. + * @param string $new_body + * The new body field value. + * @param string $block_css_locator + * The CSS locator to use to select the contextual link. + */ + protected function configureInlineBlock($old_body, $new_body, $block_css_locator = NULL) { + $block_css_locator = $block_css_locator ?: static::INLINE_BLOCK_LOCATOR; + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->clickContextualLink($block_css_locator, 'Configure'); + $textarea = $assert_session->waitForElementVisible('css', '[name="settings[block_form][body][0][value]"]'); + $this->assertNotEmpty($textarea); + $this->assertSame($old_body, $textarea->getValue()); + $textarea->setValue($new_body); + $page->pressButton('Update'); + $this->waitForNoElement('#drupal-off-canvas'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertDialogClosedAndTextVisible($new_body); + } + + /** + * Waits for an element to be removed from the page. + * + * @param string $selector + * CSS selector. + * @param int $timeout + * (optional) Timeout in milliseconds, defaults to 10000. + * + * @todo Remove in https://www.drupal.org/node/2892440. + */ + protected function waitForNoElement($selector, $timeout = 10000) { + $condition = "(typeof jQuery !== 'undefined' && jQuery('$selector').length === 0)"; + $this->assertJsCondition($condition, $timeout); + } + + /** + * Asserts that the dialog closes and the new text appears on the main canvas. + * + * @param string $text + * The text. + * @param string|null $css_locator + * The css locator to use inside the main canvas if any. + * + * @throws \Behat\Mink\Exception\ExpectationException + */ + protected function assertDialogClosedAndTextVisible($text, $css_locator = NULL) { + $assert_session = $this->assertSession(); + $this->waitForNoElement('#drupal-off-canvas'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementNotExists('css', '#drupal-off-canvas'); + if ($css_locator) { + $this->assertNotEmpty($assert_session->waitForElementVisible('css', ".dialog-off-canvas-main-canvas $css_locator:contains('$text')")); + } + else { + $this->assertNotEmpty($assert_session->waitForElementVisible('css', ".dialog-off-canvas-main-canvas:contains('$text')")); + } + } + +}