diff --git a/core/composer.json b/core/composer.json index 48e66198804679502702aeaecdf84408e2676fbd..31bc51a0fa84d9acb958b84a220911377098cb59 100644 --- a/core/composer.json +++ b/core/composer.json @@ -112,6 +112,7 @@ "drupal/image": "self.version", "drupal/inline_form_errors": "self.version", "drupal/language": "self.version", + "drupal/layout_builder": "self.version", "drupal/layout_discovery": "self.version", "drupal/link": "self.version", "drupal/locale": "self.version", diff --git a/core/modules/layout_builder/config/schema/layout_builder.schema.yml b/core/modules/layout_builder/config/schema/layout_builder.schema.yml new file mode 100644 index 0000000000000000000000000000000000000000..b870007e33588738af1959f0fca25e93debf0ad6 --- /dev/null +++ b/core/modules/layout_builder/config/schema/layout_builder.schema.yml @@ -0,0 +1,7 @@ +core.entity_view_display.*.*.*.third_party.layout_builder: + type: mapping + label: 'Per-view-mode Layout Builder settings' + mapping: + allow_custom: + type: boolean + label: 'Allow a customized layout' diff --git a/core/modules/layout_builder/css/layout-builder.css b/core/modules/layout_builder/css/layout-builder.css new file mode 100644 index 0000000000000000000000000000000000000000..11d8c5508f739650187e02617ccf8af9250b54d5 --- /dev/null +++ b/core/modules/layout_builder/css/layout-builder.css @@ -0,0 +1,51 @@ +.add-section { + width: 100%; + outline: 2px dashed #979797; + padding: 1.5em 0; + text-align: center; + margin-bottom: 1.5em; + transition: visually-hidden 2s ease-out, height 2s ease-in; +} + +.layout-section { + margin-bottom: 1.5em; +} + +.layout-section .layout-builder--layout__region { + outline: 2px dashed #2f91da; + padding: 1.5em 0; +} + +.layout-section .layout-builder--layout__region .add-block { + text-align: center; +} + +.layout-section .remove-section { + position: relative; + background: url(../../../misc/icons/bebebe/ex.svg) #ffffff center center / 16px 16px no-repeat; + border: 1px solid #cccccc; + box-sizing: border-box; + font-size: 1rem; + padding: 0; + height: 26px; + width: 26px; + white-space: nowrap; + text-indent: -9999px; + display: inline-block; + border-radius: 26px; + margin-left: -10px; +} + +.layout-section .remove-section:hover { + background-image: url(../../../misc/icons/787878/ex.svg); +} + +#drupal-off-canvas .layout-selection li { + display: block; + padding-bottom: 1em; +} + +#drupal-off-canvas .layout-selection li a { + display: block; + padding-top: 0.55em; +} diff --git a/core/modules/layout_builder/js/layout-builder.es6.js b/core/modules/layout_builder/js/layout-builder.es6.js new file mode 100644 index 0000000000000000000000000000000000000000..391e462cc677542be9d0258cee1fced3abd9558c --- /dev/null +++ b/core/modules/layout_builder/js/layout-builder.es6.js @@ -0,0 +1,37 @@ +(($, { ajax, behaviors }) => { + behaviors.layoutBuilder = { + attach(context) { + $(context).find('.layout-builder--layout__region').sortable({ + items: '> .draggable', + connectWith: '.layout-builder--layout__region', + + /** + * Updates the layout with the new position of the block. + * + * @param {jQuery.Event} event + * The jQuery Event object. + * @param {Object} ui + * An object containing information about the item being sorted. + */ + update(event, ui) { + // Only process if the item was moved from one region to another. + if (ui.sender) { + ajax({ + url: [ + ui.item.closest('[data-layout-update-url]').data('layout-update-url'), + ui.sender.closest('[data-layout-delta]').data('layout-delta'), + ui.item.closest('[data-layout-delta]').data('layout-delta'), + ui.sender.data('region'), + $(this).data('region'), + ui.item.data('layout-block-uuid'), + ui.item.prev('[data-layout-block-uuid]').data('layout-block-uuid'), + ] + .filter(element => element !== undefined) + .join('/'), + }).execute(); + } + }, + }); + }, + }; +})(jQuery, Drupal); diff --git a/core/modules/layout_builder/js/layout-builder.js b/core/modules/layout_builder/js/layout-builder.js new file mode 100644 index 0000000000000000000000000000000000000000..d4dadc89344ca9c9e125db695444ca65a7314df1 --- /dev/null +++ b/core/modules/layout_builder/js/layout-builder.js @@ -0,0 +1,30 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(function ($, _ref) { + var ajax = _ref.ajax, + behaviors = _ref.behaviors; + + behaviors.layoutBuilder = { + attach: function attach(context) { + $(context).find('.layout-builder--layout__region').sortable({ + items: '> .draggable', + connectWith: '.layout-builder--layout__region', + + update: function update(event, ui) { + if (ui.sender) { + ajax({ + url: [ui.item.closest('[data-layout-update-url]').data('layout-update-url'), ui.sender.closest('[data-layout-delta]').data('layout-delta'), ui.item.closest('[data-layout-delta]').data('layout-delta'), ui.sender.data('region'), $(this).data('region'), ui.item.data('layout-block-uuid'), ui.item.prev('[data-layout-block-uuid]').data('layout-block-uuid')].filter(function (element) { + return element !== undefined; + }).join('/') + }).execute(); + } + } + }); + } + }; +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/layout_builder/layout_builder.info.yml b/core/modules/layout_builder/layout_builder.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..e9859114643d4d4e361dacfc53f75ab35cb983da --- /dev/null +++ b/core/modules/layout_builder/layout_builder.info.yml @@ -0,0 +1,9 @@ +name: 'Layout Builder' +type: module +description: 'Provides layout building utility.' +package: Core (Experimental) +version: VERSION +core: 8.x +dependencies: + - layout_discovery + - contextual diff --git a/core/modules/layout_builder/layout_builder.libraries.yml b/core/modules/layout_builder/layout_builder.libraries.yml new file mode 100644 index 0000000000000000000000000000000000000000..84727756365b2ee349ece0872bd059cb6fc7b1f3 --- /dev/null +++ b/core/modules/layout_builder/layout_builder.libraries.yml @@ -0,0 +1,10 @@ +drupal.layout_builder: + version: VERSION + css: + theme: + css/layout-builder.css: {} + js: + js/layout-builder.js: {} + dependencies: + - core/jquery.ui.sortable + - core/drupal.dialog.off_canvas diff --git a/core/modules/layout_builder/layout_builder.links.contextual.yml b/core/modules/layout_builder/layout_builder.links.contextual.yml new file mode 100644 index 0000000000000000000000000000000000000000..bcf2a9cf06938805d5f60574af1376cda30698a3 --- /dev/null +++ b/core/modules/layout_builder/layout_builder.links.contextual.yml @@ -0,0 +1,19 @@ +layout_builder_block_update: + title: 'Configure' + route_name: 'layout_builder.update_block' + group: 'layout_builder_block' + options: + attributes: + class: ['use-ajax'] + data-dialog-type: dialog + data-dialog-renderer: off_canvas + +layout_builder_block_remove: + title: 'Remove block' + route_name: 'layout_builder.remove_block' + group: 'layout_builder_block' + options: + attributes: + class: ['use-ajax'] + data-dialog-type: dialog + data-dialog-renderer: off_canvas diff --git a/core/modules/layout_builder/layout_builder.links.task.yml b/core/modules/layout_builder/layout_builder.links.task.yml new file mode 100644 index 0000000000000000000000000000000000000000..b003d7737c83a2ed4513d269d32a9a76fbf3207c --- /dev/null +++ b/core/modules/layout_builder/layout_builder.links.task.yml @@ -0,0 +1,2 @@ +layout_builder_ui: + deriver: '\Drupal\layout_builder\Plugin\Derivative\LayoutBuilderLocalTaskDeriver' diff --git a/core/modules/layout_builder/layout_builder.module b/core/modules/layout_builder/layout_builder.module new file mode 100644 index 0000000000000000000000000000000000000000..2192168cb0cdfd93a99efdf1b329c3a3cb94fe9c --- /dev/null +++ b/core/modules/layout_builder/layout_builder.module @@ -0,0 +1,200 @@ +<?php + +/** + * @file + * Provides hook implementations for Layout Builder. + */ + +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; + +/** + * Implements hook_help(). + */ +function layout_builder_help($route_name) { + switch ($route_name) { + case 'help.page.layout_builder': + $output = '<h3>' . t('About') . '</h3>'; + $output .= '<p>' . t('Layout Builder provides layout building utility.') . '</p>'; + $output .= '<p>' . t('For more information, see the <a href=":layout-builder-documentation">online documentation for the Layout Builder module</a>.', [':layout-builder-documentation' => 'https://www.drupal.org/docs/8/core/modules/layout_builder']) . '</p>'; + return $output; + } +} + +/** + * Implements hook_entity_type_alter(). + */ +function layout_builder_entity_type_alter(array &$entity_types) { + /** @var \Drupal\Core\Entity\EntityTypeInterface[] $entity_types */ + foreach ($entity_types as $entity_type) { + if ($entity_type->entityClassImplements(FieldableEntityInterface::class) && $entity_type->hasLinkTemplate('canonical') && $entity_type->hasViewBuilderClass()) { + $entity_type->setLinkTemplate('layout-builder', $entity_type->getLinkTemplate('canonical') . '/layout'); + } + } +} + +/** + * Removes the Layout Builder field both visually and from the #fields handling. + * + * This prevents any interaction with this field. It is rendered directly + * in layout_builder_entity_view_display_alter(). + * + * @internal + */ +function _layout_builder_hide_layout_field(array &$form) { + unset($form['fields']['layout_builder__layout']); + $key = array_search('layout_builder__layout', $form['#fields']); + if ($key !== FALSE) { + unset($form['#fields'][$key]); + } +} + +/** + * Implements hook_form_FORM_ID_alter() for \Drupal\field_ui\Form\EntityFormDisplayEditForm. + */ +function layout_builder_form_entity_form_display_edit_form_alter(&$form, FormStateInterface $form_state) { + _layout_builder_hide_layout_field($form); +} + +/** + * Implements hook_form_FORM_ID_alter() for \Drupal\field_ui\Form\EntityViewDisplayEditForm. + */ +function layout_builder_form_entity_view_display_edit_form_alter(&$form, FormStateInterface $form_state) { + /** @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display */ + $display = $form_state->getFormObject()->getEntity(); + $entity_type = \Drupal::entityTypeManager()->getDefinition($display->getTargetEntityTypeId()); + + _layout_builder_hide_layout_field($form); + + // @todo Expand to work for all view modes in + // https://www.drupal.org/node/2907413. + if (!in_array($display->getMode(), ['full', 'default'], TRUE)) { + return; + } + + $form['layout'] = [ + '#type' => 'details', + '#open' => TRUE, + '#title' => t('Layout options'), + '#tree' => TRUE, + ]; + // @todo Unchecking this box is a destructive action, this should be made + // clear to the user in https://www.drupal.org/node/2914484. + $form['layout']['allow_custom'] = [ + '#type' => 'checkbox', + '#title' => t('Allow each @entity to have its layout customized.', [ + '@entity' => $entity_type->getSingularLabel(), + ]), + '#default_value' => $display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE), + ]; + + $form['#entity_builders'][] = 'layout_builder_form_entity_view_display_edit_entity_builder'; +} + +/** + * Entity builder for layout options on the entity view display form. + * + * @see layout_builder_form_entity_view_display_edit_form_alter() + */ +function layout_builder_form_entity_view_display_edit_entity_builder($entity_type_id, EntityViewDisplayInterface $display, &$form, FormStateInterface &$form_state) { + $new_value = (bool) $form_state->getValue(['layout', 'allow_custom'], FALSE); + $display->setThirdPartySetting('layout_builder', 'allow_custom', $new_value); +} + +/** + * Implements hook_ENTITY_TYPE_presave(). + */ +function layout_builder_entity_view_display_presave(EntityViewDisplayInterface $display) { + $original_value = isset($display->original) ? $display->original->getThirdPartySetting('layout_builder', 'allow_custom', FALSE) : FALSE; + $new_value = $display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE); + if ($original_value !== $new_value) { + $entity_type_id = $display->getTargetEntityTypeId(); + $bundle = $display->getTargetBundle(); + + if ($new_value) { + layout_builder_add_layout_section_field($entity_type_id, $bundle); + } + elseif ($field = FieldConfig::loadByName($entity_type_id, $bundle, 'layout_builder__layout')) { + $field->delete(); + } + } +} + +/** + * Adds a layout section field to a given bundle. + * + * @param string $entity_type_id + * The entity type ID. + * @param string $bundle + * The bundle. + * @param string $field_name + * (optional) The name for the layout section field. Defaults to + * 'layout_builder__layout'. + * + * @return \Drupal\field\FieldConfigInterface + * A layout section field. + */ +function layout_builder_add_layout_section_field($entity_type_id, $bundle, $field_name = 'layout_builder__layout') { + $field = FieldConfig::loadByName($entity_type_id, $bundle, $field_name); + if (!$field) { + $field_storage = FieldStorageConfig::loadByName($entity_type_id, $field_name); + if (!$field_storage) { + $field_storage = FieldStorageConfig::create([ + 'entity_type' => $entity_type_id, + 'field_name' => $field_name, + 'type' => 'layout_section', + ]); + $field_storage->save(); + } + + $field = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => $bundle, + 'label' => t('Layout'), + ]); + $field->save(); + } + return $field; +} + +/** + * Implements hook_entity_view_display_alter(). + */ +function layout_builder_entity_view_display_alter(EntityViewDisplayInterface $display, array $context) { + if ($display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE)) { + // Force the layout to render with no label. + $display->setComponent('layout_builder__layout', [ + 'label' => 'hidden', + 'region' => '__layout_builder', + ]); + } + else { + $display->removeComponent('layout_builder__layout'); + } +} + +/** + * Implements hook_entity_view_alter(). + */ +function layout_builder_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) { + if ($display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE) && !$entity->layout_builder__layout->isEmpty()) { + // If field layout is active, that is all that needs to be removed. + if (\Drupal::moduleHandler()->moduleExists('field_layout') && isset($build['_field_layout'])) { + unset($build['_field_layout']); + return; + } + + /** @var \Drupal\Core\Field\FieldDefinitionInterface[] $field_definitions */ + $field_definitions = \Drupal::service('entity_field.manager')->getFieldDefinitions($display->getTargetEntityTypeId(), $display->getTargetBundle()); + // Remove all display-configurable fields. + foreach (array_keys($display->getComponents()) as $name) { + if ($name !== 'layout_builder__layout' && isset($field_definitions[$name]) && $field_definitions[$name]->isDisplayConfigurable('view')) { + unset($build[$name]); + } + } + } +} diff --git a/core/modules/layout_builder/layout_builder.permissions.yml b/core/modules/layout_builder/layout_builder.permissions.yml new file mode 100644 index 0000000000000000000000000000000000000000..00aac639244f9d6b2f219c0f3bcc8f98d1374e43 --- /dev/null +++ b/core/modules/layout_builder/layout_builder.permissions.yml @@ -0,0 +1,5 @@ +# @todo Expand permissions to be more granular in +# https://www.drupal.org/node/2914486. +configure any layout: + title: 'Configure any layout' + restrict access: true diff --git a/core/modules/layout_builder/layout_builder.routing.yml b/core/modules/layout_builder/layout_builder.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..8fe952afbead7ea9c65d4095331d3a7d9eb887d2 --- /dev/null +++ b/core/modules/layout_builder/layout_builder.routing.yml @@ -0,0 +1,129 @@ +layout_builder.choose_section: + path: '/layout_builder/choose/section/{entity_type_id}/{entity}/{delta}' + defaults: + _controller: '\Drupal\layout_builder\Controller\ChooseSectionController::build' + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + entity: + type: entity:{entity_type_id} + layout_builder_tempstore: TRUE + +layout_builder.add_section: + path: '/layout_builder/add/section/{entity_type_id}/{entity}/{delta}/{plugin_id}' + defaults: + _controller: '\Drupal\layout_builder\Controller\AddSectionController::build' + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + entity: + type: entity:{entity_type_id} + layout_builder_tempstore: TRUE + +layout_builder.configure_section: + path: '/layout_builder/configure/section/{entity_type_id}/{entity}/{delta}/{plugin_id}' + defaults: + _title: 'Configure section' + _form: '\Drupal\layout_builder\Form\ConfigureSectionForm' + # Adding a new section requires a plugin_id, while configuring an existing + # section does not. + plugin_id: null + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + entity: + type: entity:{entity_type_id} + layout_builder_tempstore: TRUE + +layout_builder.remove_section: + path: '/layout_builder/remove/section/{entity_type_id}/{entity}/{delta}' + defaults: + _form: '\Drupal\layout_builder\Form\RemoveSectionForm' + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + entity: + type: entity:{entity_type_id} + layout_builder_tempstore: TRUE + +layout_builder.choose_block: + path: '/layout_builder/choose/block/{entity_type_id}/{entity}/{delta}/{region}' + defaults: + _controller: '\Drupal\layout_builder\Controller\ChooseBlockController::build' + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + entity: + type: entity:{entity_type_id} + layout_builder_tempstore: TRUE + +layout_builder.add_block: + path: '/layout_builder/add/block/{entity_type_id}/{entity}/{delta}/{region}/{plugin_id}' + defaults: + _form: '\Drupal\layout_builder\Form\AddBlockForm' + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + entity: + type: entity:{entity_type_id} + layout_builder_tempstore: TRUE + +layout_builder.update_block: + path: '/layout_builder/update/block/{entity_type_id}/{entity}/{delta}/{region}/{uuid}' + defaults: + _form: '\Drupal\layout_builder\Form\UpdateBlockForm' + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + entity: + type: entity:{entity_type_id} + layout_builder_tempstore: TRUE + +layout_builder.remove_block: + path: '/layout_builder/remove/block/{entity_type_id}/{entity}/{delta}/{region}/{uuid}' + defaults: + _form: '\Drupal\layout_builder\Form\RemoveBlockForm' + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + entity: + type: entity:{entity_type_id} + layout_builder_tempstore: TRUE + +layout_builder.move_block: + path: '/layout_builder/move/block/{entity_type_id}/{entity}/{delta_from}/{delta_to}/{region_from}/{region_to}/{block_uuid}/{preceding_block_uuid}' + defaults: + _controller: '\Drupal\layout_builder\Controller\MoveBlockController::build' + delta_from: null + delta_to: null + region_from: null + region_to: null + block_uuid: null + preceding_block_uuid: null + requirements: + _permission: 'configure any layout' + options: + _admin_route: TRUE + parameters: + entity: + type: entity:{entity_type_id} + layout_builder_tempstore: TRUE + +route_callbacks: + - 'layout_builder.routes:getRoutes' diff --git a/core/modules/layout_builder/layout_builder.services.yml b/core/modules/layout_builder/layout_builder.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..518d9ee9421f272b364f0decf155b969b0c90116 --- /dev/null +++ b/core/modules/layout_builder/layout_builder.services.yml @@ -0,0 +1,30 @@ +services: + layout_builder.builder: + class: Drupal\layout_builder\LayoutSectionBuilder + arguments: ['@current_user', '@plugin.manager.core.layout', '@plugin.manager.block', '@context.handler', '@context.repository'] + layout_builder.tempstore_repository: + class: Drupal\layout_builder\LayoutTempstoreRepository + arguments: ['@user.shared_tempstore', '@entity_type.manager'] + access_check.entity.layout: + class: Drupal\layout_builder\Access\LayoutSectionAccessCheck + arguments: ['@entity_type.manager'] + tags: + - { name: access_check, applies_to: _has_layout_section } + layout_builder.routes: + class: Drupal\layout_builder\Routing\LayoutBuilderRoutes + arguments: ['@entity_type.manager', '@entity_field.manager'] + layout_builder.route_enhancer: + class: Drupal\layout_builder\Routing\LayoutBuilderRouteEnhancer + arguments: ['@entity_type.manager'] + tags: + - { name: route_enhancer } + layout_builder.param_converter: + class: Drupal\layout_builder\Routing\LayoutTempstoreParamConverter + arguments: ['@entity.manager', '@layout_builder.tempstore_repository'] + tags: + - { name: paramconverter, priority: 10 } + cache_context.layout_builder_is_active: + class: Drupal\layout_builder\Cache\LayoutBuilderIsActiveCacheContext + arguments: ['@current_route_match'] + tags: + - { name: cache.context} diff --git a/core/modules/layout_builder/src/Access/LayoutSectionAccessCheck.php b/core/modules/layout_builder/src/Access/LayoutSectionAccessCheck.php new file mode 100644 index 0000000000000000000000000000000000000000..e13a1492386a08af1d3424ed640299cd6634cecc --- /dev/null +++ b/core/modules/layout_builder/src/Access/LayoutSectionAccessCheck.php @@ -0,0 +1,68 @@ +<?php + +namespace Drupal\layout_builder\Access; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Routing\Access\AccessInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountInterface; + +/** + * Provides an access check for the Layout Builder UI. + * + * @internal + */ +class LayoutSectionAccessCheck implements AccessInterface { + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * Constructs a new LayoutSectionAccessCheck. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + */ + public function __construct(EntityTypeManagerInterface $entity_type_manager) { + $this->entityTypeManager = $entity_type_manager; + } + + /** + * Checks routing access to layout for the entity. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + * @param \Drupal\Core\Session\AccountInterface $account + * The currently logged in account. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function access(RouteMatchInterface $route_match, AccountInterface $account) { + // Attempt to retrieve the generic 'entity' parameter, otherwise look up the + // specific entity via the entity type ID. + $entity = $route_match->getParameter('entity') ?: $route_match->getParameter($route_match->getParameter('entity_type_id')); + + // If we don't have an entity, forbid access. + if (empty($entity)) { + return AccessResult::forbidden()->addCacheContexts(['route']); + } + + // If the entity isn't fieldable, forbid access. + if (!$entity instanceof FieldableEntityInterface || !$entity->hasField('layout_builder__layout')) { + $access = AccessResult::forbidden(); + } + else { + $access = AccessResult::allowedIfHasPermission($account, 'configure any layout'); + } + + return $access->addCacheableDependency($entity); + } + +} diff --git a/core/modules/layout_builder/src/Cache/LayoutBuilderIsActiveCacheContext.php b/core/modules/layout_builder/src/Cache/LayoutBuilderIsActiveCacheContext.php new file mode 100644 index 0000000000000000000000000000000000000000..c632f4b33a0d439afbefe092428de54facf09bef --- /dev/null +++ b/core/modules/layout_builder/src/Cache/LayoutBuilderIsActiveCacheContext.php @@ -0,0 +1,87 @@ +<?php + +namespace Drupal\layout_builder\Cache; + +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\Context\CalculatedCacheContextInterface; +use Drupal\Core\Entity\Entity\EntityViewDisplay; +use Drupal\Core\Routing\RouteMatchInterface; + +/** + * Determines whether Layout Builder is active for a given entity type or not. + * + * Cache context ID: 'layout_builder_is_active:%entity_type_id', e.g. + * 'layout_builder_is_active:node' (to vary by whether the Node entity type has + * Layout Builder enabled). + */ +class LayoutBuilderIsActiveCacheContext implements CalculatedCacheContextInterface { + + /** + * The current route match. + * + * @var \Drupal\Core\Routing\RouteMatchInterface + */ + protected $routeMatch; + + /** + * LayoutBuilderCacheContext constructor. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + */ + public function __construct(RouteMatchInterface $route_match) { + $this->routeMatch = $route_match; + } + + /** + * {@inheritdoc} + */ + public static function getLabel() { + return t('Layout Builder'); + } + + /** + * {@inheritdoc} + */ + public function getContext($entity_type_id = NULL) { + if (!$entity_type_id) { + throw new \LogicException('Missing entity type ID'); + } + + $display = $this->getDisplay($entity_type_id); + return ($display && $display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE)) ? '1' : '0'; + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata($entity_type_id = NULL) { + if (!$entity_type_id) { + throw new \LogicException('Missing entity type ID'); + } + + $cacheable_metadata = new CacheableMetadata(); + if ($display = $this->getDisplay($entity_type_id)) { + $cacheable_metadata->addCacheableDependency($display); + } + return $cacheable_metadata; + } + + /** + * Returns the entity view display for a given entity type and view mode. + * + * @param string $entity_type_id + * The entity type ID. + * @param string $view_mode + * (optional) The view mode that should be used to render the entity. + * + * @return \Drupal\Core\Entity\Display\EntityViewDisplayInterface|null + * The entity view display, if it exists. + */ + protected function getDisplay($entity_type_id, $view_mode = 'full') { + if ($entity = $this->routeMatch->getParameter($entity_type_id)) { + return EntityViewDisplay::collectRenderDisplay($entity, $view_mode); + } + } + +} diff --git a/core/modules/layout_builder/src/Controller/AddSectionController.php b/core/modules/layout_builder/src/Controller/AddSectionController.php new file mode 100644 index 0000000000000000000000000000000000000000..d6771082382bfae78569af177548887e8fa7d28c --- /dev/null +++ b/core/modules/layout_builder/src/Controller/AddSectionController.php @@ -0,0 +1,85 @@ +<?php + +namespace Drupal\layout_builder\Controller; + +use Drupal\Core\DependencyInjection\ClassResolverInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; + +/** + * Defines a controller to add a new section. + * + * @internal + */ +class AddSectionController implements ContainerInjectionInterface { + + use AjaxHelperTrait; + use LayoutRebuildTrait; + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * AddSectionController constructor. + * + * @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository + * The layout tempstore repository. + * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver + * The class resolver. + */ + public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, ClassResolverInterface $class_resolver) { + $this->layoutTempstoreRepository = $layout_tempstore_repository; + $this->classResolver = $class_resolver; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.tempstore_repository'), + $container->get('class_resolver') + ); + } + + /** + * Add the layout to the entity field in a tempstore. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * @param int $delta + * The delta of the section to splice. + * @param string $plugin_id + * The plugin ID of the layout to add. + * + * @return \Symfony\Component\HttpFoundation\Response + * The controller response. + */ + public function build(EntityInterface $entity, $delta, $plugin_id) { + /** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */ + $field_list = $entity->layout_builder__layout; + $field_list->addItem($delta, [ + 'layout' => $plugin_id, + 'layout_settings' => [], + 'section' => [], + ]); + + $this->layoutTempstoreRepository->set($entity); + + if ($this->isAjax()) { + return $this->rebuildAndClose($entity); + } + else { + $url = $entity->toUrl('layout-builder'); + return new RedirectResponse($url->setAbsolute()->toString()); + } + } + +} diff --git a/core/modules/layout_builder/src/Controller/AjaxHelperTrait.php b/core/modules/layout_builder/src/Controller/AjaxHelperTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..072eccab352e43899101d59eac6f7dc1982f9382 --- /dev/null +++ b/core/modules/layout_builder/src/Controller/AjaxHelperTrait.php @@ -0,0 +1,41 @@ +<?php + +namespace Drupal\layout_builder\Controller; + +use Drupal\Core\EventSubscriber\MainContentViewSubscriber; + +/** + * Provides a helper to determine if the current request is via AJAX. + * + * @internal + * + * @todo Move to \Drupal\Core in https://www.drupal.org/node/2896535. + */ +trait AjaxHelperTrait { + + /** + * Determines if the current request is via AJAX. + * + * @return bool + * TRUE if the current request is via AJAX, FALSE otherwise. + */ + protected function isAjax() { + return in_array($this->getRequestWrapperFormat(), [ + 'drupal_ajax', + 'drupal_dialog', + 'drupal_dialog.off_canvas', + 'drupal_modal', + ]); + } + + /** + * Gets the wrapper format of the current request. + * + * @string + * The wrapper format. + */ + protected function getRequestWrapperFormat() { + return \Drupal::request()->get(MainContentViewSubscriber::WRAPPER_FORMAT); + } + +} diff --git a/core/modules/layout_builder/src/Controller/ChooseBlockController.php b/core/modules/layout_builder/src/Controller/ChooseBlockController.php new file mode 100644 index 0000000000000000000000000000000000000000..9eddb79fc34b8141c5c23f387b43a7065cc4e6d9 --- /dev/null +++ b/core/modules/layout_builder/src/Controller/ChooseBlockController.php @@ -0,0 +1,94 @@ +<?php + +namespace Drupal\layout_builder\Controller; + +use Drupal\Core\Block\BlockManagerInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Url; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Defines a controller to choose a new block. + * + * @internal + */ +class ChooseBlockController implements ContainerInjectionInterface { + + use AjaxHelperTrait; + + /** + * The block manager. + * + * @var \Drupal\Core\Block\BlockManagerInterface + */ + protected $blockManager; + + /** + * ChooseBlockController constructor. + * + * @param \Drupal\Core\Block\BlockManagerInterface $block_manager + * The block manager. + */ + public function __construct(BlockManagerInterface $block_manager) { + $this->blockManager = $block_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('plugin.manager.block') + ); + } + + /** + * Provides the UI for choosing a new block. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * @param int $delta + * The delta of the section to splice. + * @param string $region + * The region the block is going in. + * + * @return array + * A render array. + */ + public function build(EntityInterface $entity, $delta, $region) { + $build['#type'] = 'container'; + $build['#attributes']['class'][] = 'block-categories'; + + foreach ($this->blockManager->getGroupedDefinitions() as $category => $blocks) { + $build[$category]['#type'] = 'details'; + $build[$category]['#open'] = TRUE; + $build[$category]['#title'] = $category; + $build[$category]['links'] = [ + '#theme' => 'links', + ]; + foreach ($blocks as $block_id => $block) { + $link = [ + 'title' => $block['admin_label'], + 'url' => Url::fromRoute('layout_builder.add_block', + [ + 'entity_type_id' => $entity->getEntityTypeId(), + 'entity' => $entity->id(), + 'delta' => $delta, + 'region' => $region, + 'plugin_id' => $block_id, + ] + ), + ]; + if ($this->isAjax()) { + $link['attributes']['class'][] = 'use-ajax'; + $link['attributes']['data-dialog-type'][] = 'dialog'; + $link['attributes']['data-dialog-renderer'][] = 'off_canvas'; + } + $build[$category]['links']['#links'][] = $link; + } + } + return $build; + } + +} diff --git a/core/modules/layout_builder/src/Controller/ChooseSectionController.php b/core/modules/layout_builder/src/Controller/ChooseSectionController.php new file mode 100644 index 0000000000000000000000000000000000000000..0414d2abf1d538d3e1068e7d8c99ad85e8d23042 --- /dev/null +++ b/core/modules/layout_builder/src/Controller/ChooseSectionController.php @@ -0,0 +1,105 @@ +<?php + +namespace Drupal\layout_builder\Controller; + +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Layout\LayoutPluginManagerInterface; +use Drupal\Core\Plugin\PluginFormInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Defines a controller to choose a new section. + * + * @internal + */ +class ChooseSectionController implements ContainerInjectionInterface { + + use AjaxHelperTrait; + use StringTranslationTrait; + + /** + * The layout manager. + * + * @var \Drupal\Core\Layout\LayoutPluginManagerInterface + */ + protected $layoutManager; + + /** + * ChooseSectionController constructor. + * + * @param \Drupal\Core\Layout\LayoutPluginManagerInterface $layout_manager + * The layout manager. + */ + public function __construct(LayoutPluginManagerInterface $layout_manager) { + $this->layoutManager = $layout_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('plugin.manager.core.layout') + ); + } + + /** + * Choose a layout plugin to add as a section. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * @param int $delta + * The delta of the section to splice. + * + * @return array + * The render array. + */ + public function build(EntityInterface $entity, $delta) { + $output['#title'] = $this->t('Choose a layout'); + + $items = []; + foreach ($this->layoutManager->getDefinitions() as $plugin_id => $definition) { + $layout = $this->layoutManager->createInstance($plugin_id); + $item = [ + '#type' => 'link', + '#title' => [ + $definition->getIcon(60, 80, 1, 3), + [ + '#type' => 'container', + '#children' => $definition->getLabel(), + ], + ], + '#url' => Url::fromRoute( + $layout instanceof PluginFormInterface ? 'layout_builder.configure_section' : 'layout_builder.add_section', + [ + 'entity_type_id' => $entity->getEntityTypeId(), + 'entity' => $entity->id(), + 'delta' => $delta, + 'plugin_id' => $plugin_id, + ] + ), + ]; + if ($this->isAjax()) { + $item['#attributes']['class'][] = 'use-ajax'; + $item['#attributes']['data-dialog-type'][] = 'dialog'; + $item['#attributes']['data-dialog-renderer'][] = 'off_canvas'; + } + $items[] = $item; + } + $output['layouts'] = [ + '#theme' => 'item_list', + '#items' => $items, + '#attributes' => [ + 'class' => [ + 'layout-selection', + ], + ], + ]; + + return $output; + } + +} diff --git a/core/modules/layout_builder/src/Controller/LayoutBuilderController.php b/core/modules/layout_builder/src/Controller/LayoutBuilderController.php new file mode 100644 index 0000000000000000000000000000000000000000..a4163a40a82151106eeca5b3abcf1fe08e79c44e --- /dev/null +++ b/core/modules/layout_builder/src/Controller/LayoutBuilderController.php @@ -0,0 +1,319 @@ +<?php + +namespace Drupal\layout_builder\Controller; + +use Drupal\Core\Block\BlockManagerInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Layout\LayoutPluginManagerInterface; +use Drupal\Core\Plugin\PluginFormInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Drupal\layout_builder\LayoutSectionBuilder; +use Drupal\layout_builder\Field\LayoutSectionItemInterface; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; + +/** + * Defines a controller to provide the Layout Builder admin UI. + * + * @internal + */ +class LayoutBuilderController implements ContainerInjectionInterface { + + use StringTranslationTrait; + + /** + * The layout builder. + * + * @var \Drupal\layout_builder\LayoutSectionBuilder + */ + protected $builder; + + /** + * The layout manager. + * + * @var \Drupal\Core\Layout\LayoutPluginManagerInterface + */ + protected $layoutManager; + + /** + * The block manager. + * + * @var \Drupal\Core\Block\BlockManagerInterface + */ + protected $blockManager; + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * LayoutBuilderController constructor. + * + * @param \Drupal\layout_builder\LayoutSectionBuilder $builder + * The layout section builder. + * @param \Drupal\Core\Layout\LayoutPluginManagerInterface $layout_manager + * The layout manager. + * @param \Drupal\Core\Block\BlockManagerInterface $block_manager + * The block manager. + * @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository + * The layout tempstore repository. + */ + public function __construct(LayoutSectionBuilder $builder, LayoutPluginManagerInterface $layout_manager, BlockManagerInterface $block_manager, LayoutTempstoreRepositoryInterface $layout_tempstore_repository) { + $this->builder = $builder; + $this->layoutManager = $layout_manager; + $this->blockManager = $block_manager; + $this->layoutTempstoreRepository = $layout_tempstore_repository; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.builder'), + $container->get('plugin.manager.core.layout'), + $container->get('plugin.manager.block'), + $container->get('layout_builder.tempstore_repository') + ); + } + + /** + * Provides a title callback. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return string + * The title for the layout page. + */ + public function title(EntityInterface $entity) { + return $this->t('Edit layout for %label', ['%label' => $entity->label()]); + } + + /** + * Renders the Layout UI. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * @param bool $is_rebuilding + * (optional) Indicates if the layout is rebuilding, defaults to FALSE. + * + * @return array + * A render array. + */ + public function layout(EntityInterface $entity, $is_rebuilding = FALSE) { + $entity_id = $entity->id(); + $entity_type_id = $entity->getEntityTypeId(); + + /** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */ + $field_list = $entity->layout_builder__layout; + + // For a new layout override, begin with a single section of one column. + if (!$is_rebuilding && $field_list->isEmpty()) { + $field_list->addItem(0, ['layout' => 'layout_onecol']); + $this->layoutTempstoreRepository->set($entity); + } + + $output = []; + $count = 0; + foreach ($field_list as $item) { + $output[] = $this->buildAddSectionLink($entity_type_id, $entity_id, $count); + $output[] = $this->buildAdministrativeSection($item, $entity, $count); + $count++; + } + $output[] = $this->buildAddSectionLink($entity_type_id, $entity_id, $count); + $output['#attached']['library'][] = 'layout_builder/drupal.layout_builder'; + $output['#type'] = 'container'; + $output['#attributes']['id'] = 'layout-builder'; + // Mark this UI as uncacheable. + $output['#cache']['max-age'] = 0; + return $output; + } + + /** + * Builds a link to add a new section at a given delta. + * + * @param string $entity_type_id + * The entity type. + * @param string $entity_id + * The entity ID. + * @param int $delta + * The delta of the section to splice. + * + * @return array + * A render array for a link. + */ + protected function buildAddSectionLink($entity_type_id, $entity_id, $delta) { + return [ + 'link' => [ + '#type' => 'link', + '#title' => $this->t('Add Section'), + '#url' => Url::fromRoute('layout_builder.choose_section', + [ + 'entity_type_id' => $entity_type_id, + 'entity' => $entity_id, + 'delta' => $delta, + ], + [ + 'attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'dialog', + 'data-dialog-renderer' => 'off_canvas', + ], + ] + ), + ], + '#type' => 'container', + '#attributes' => [ + 'class' => ['add-section'], + ], + ]; + } + + /** + * Builds the render array for the layout section while editing. + * + * @param \Drupal\layout_builder\Field\LayoutSectionItemInterface $item + * The layout section item. + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * @param int $delta + * The delta of the section. + * + * @return array + * The render array for a given section. + */ + protected function buildAdministrativeSection(LayoutSectionItemInterface $item, EntityInterface $entity, $delta) { + $entity_type_id = $entity->getEntityTypeId(); + $entity_id = $entity->id(); + + $layout = $this->layoutManager->createInstance($item->layout, $item->layout_settings); + $build = $this->builder->buildSectionFromLayout($layout, $item->section); + $layout_definition = $layout->getPluginDefinition(); + + foreach ($layout_definition->getRegions() as $region => $info) { + if (!empty($build[$region])) { + foreach ($build[$region] as $uuid => $block) { + $build[$region][$uuid]['#attributes']['class'][] = 'draggable'; + $build[$region][$uuid]['#attributes']['data-layout-block-uuid'] = $uuid; + $build[$region][$uuid]['#contextual_links'] = [ + 'layout_builder_block' => [ + 'route_parameters' => [ + 'entity_type_id' => $entity_type_id, + 'entity' => $entity_id, + 'delta' => $delta, + 'region' => $region, + 'uuid' => $uuid, + ], + ], + ]; + } + } + + $build[$region]['layout_builder_add_block']['link'] = [ + '#type' => 'link', + '#title' => $this->t('Add Block'), + '#url' => Url::fromRoute('layout_builder.choose_block', + [ + 'entity_type_id' => $entity_type_id, + 'entity' => $entity_id, + 'delta' => $delta, + 'region' => $region, + ], + [ + 'attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'dialog', + 'data-dialog-renderer' => 'off_canvas', + ], + ] + ), + ]; + $build[$region]['layout_builder_add_block']['#type'] = 'container'; + $build[$region]['layout_builder_add_block']['#attributes'] = ['class' => ['add-block']]; + $build[$region]['layout_builder_add_block']['#weight'] = -1000; + $build[$region]['#attributes']['data-region'] = $region; + $build[$region]['#attributes']['class'][] = 'layout-builder--layout__region'; + } + + $build['#attributes']['data-layout-update-url'] = Url::fromRoute('layout_builder.move_block', [ + 'entity_type_id' => $entity_type_id, + 'entity' => $entity_id, + ])->toString(); + $build['#attributes']['data-layout-delta'] = $delta; + $build['#attributes']['class'][] = 'layout-builder--layout'; + + return [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['layout-section'], + ], + 'configure' => [ + '#type' => 'link', + '#title' => $this->t('Configure section'), + '#access' => $layout instanceof PluginFormInterface, + '#url' => Url::fromRoute('layout_builder.configure_section', [ + 'entity_type_id' => $entity_type_id, + 'entity' => $entity_id, + 'delta' => $delta, + ]), + '#attributes' => [ + 'class' => ['use-ajax', 'configure-section'], + 'data-dialog-type' => 'dialog', + 'data-dialog-renderer' => 'off_canvas', + ], + ], + 'remove' => [ + '#type' => 'link', + '#title' => $this->t('Remove section'), + '#url' => Url::fromRoute('layout_builder.remove_section', [ + 'entity_type_id' => $entity_type_id, + 'entity' => $entity_id, + 'delta' => $delta, + ]), + '#attributes' => [ + 'class' => ['use-ajax', 'remove-section'], + 'data-dialog-type' => 'dialog', + 'data-dialog-renderer' => 'off_canvas', + ], + ], + 'layout-section' => $build, + ]; + } + + /** + * Saves the layout. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A redirect response. + */ + public function saveLayout(EntityInterface $entity) { + $entity->save(); + $this->layoutTempstoreRepository->delete($entity); + return new RedirectResponse($entity->toUrl()->setAbsolute()->toString()); + } + + /** + * Cancels the layout. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A redirect response. + */ + public function cancelLayout(EntityInterface $entity) { + $this->layoutTempstoreRepository->delete($entity); + return new RedirectResponse($entity->toUrl()->setAbsolute()->toString()); + } + +} diff --git a/core/modules/layout_builder/src/Controller/LayoutRebuildTrait.php b/core/modules/layout_builder/src/Controller/LayoutRebuildTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..53fd4e8b0ac379bf0383df82a8f8588ed39597ea --- /dev/null +++ b/core/modules/layout_builder/src/Controller/LayoutRebuildTrait.php @@ -0,0 +1,58 @@ +<?php + +namespace Drupal\layout_builder\Controller; + +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\CloseDialogCommand; +use Drupal\Core\Ajax\ReplaceCommand; +use Drupal\Core\Entity\EntityInterface; + +/** + * Provides AJAX responses to rebuild the Layout Builder. + * + * @internal + */ +trait LayoutRebuildTrait { + + /** + * The class resolver. + * + * @var \Drupal\Core\DependencyInjection\ClassResolverInterface + */ + protected $classResolver; + + /** + * Rebuilds the layout. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * An AJAX response to either rebuild the layout and close the dialog, or + * reload the page. + */ + protected function rebuildAndClose(EntityInterface $entity) { + $response = $this->rebuildLayout($entity); + $response->addCommand(new CloseDialogCommand('#drupal-off-canvas')); + return $response; + } + + /** + * Rebuilds the layout. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * An AJAX response to either rebuild the layout and close the dialog, or + * reload the page. + */ + protected function rebuildLayout(EntityInterface $entity) { + $response = new AjaxResponse(); + $layout_controller = $this->classResolver->getInstanceFromDefinition(LayoutBuilderController::class); + $layout = $layout_controller->layout($entity, TRUE); + $response->addCommand(new ReplaceCommand('#layout-builder', $layout)); + return $response; + } + +} diff --git a/core/modules/layout_builder/src/Controller/MoveBlockController.php b/core/modules/layout_builder/src/Controller/MoveBlockController.php new file mode 100644 index 0000000000000000000000000000000000000000..d648416d9e418d0e4c94740197937ca60cf8302d --- /dev/null +++ b/core/modules/layout_builder/src/Controller/MoveBlockController.php @@ -0,0 +1,102 @@ +<?php + +namespace Drupal\layout_builder\Controller; + +use Drupal\Core\DependencyInjection\ClassResolverInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Defines a controller to move a block. + * + * @internal + */ +class MoveBlockController implements ContainerInjectionInterface { + + use LayoutRebuildTrait; + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * LayoutController constructor. + * + * @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository + * The layout tempstore repository. + * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver + * The class resolver. + */ + public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, ClassResolverInterface $class_resolver) { + $this->layoutTempstoreRepository = $layout_tempstore_repository; + $this->classResolver = $class_resolver; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.tempstore_repository'), + $container->get('class_resolver') + ); + } + + /** + * Moves a block to another region. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * @param int $delta_from + * The delta of the original section. + * @param int $delta_to + * The delta of the destination section. + * @param string $region_from + * The original region for this block. + * @param string $region_to + * The new region for this block. + * @param string $block_uuid + * The UUID for this block. + * @param string|null $preceding_block_uuid + * (optional) If provided, the UUID of the block to insert this block after. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * An AJAX response. + */ + public function build(EntityInterface $entity, $delta_from, $delta_to, $region_from, $region_to, $block_uuid, $preceding_block_uuid = NULL) { + /** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */ + $field = $entity->layout_builder__layout->get($delta_from); + $section = $field->getSection(); + + $block = $section->getBlock($region_from, $block_uuid); + $section->removeBlock($region_from, $block_uuid); + + // If the block is moving from one section to another, update the original + // section and load the new one. + if ($delta_from !== $delta_to) { + $field->updateFromSection($section); + $field = $entity->layout_builder__layout->get($delta_to); + $section = $field->getSection(); + } + + // If a preceding block was specified, insert after that. Otherwise add the + // block to the front. + if (isset($preceding_block_uuid)) { + $section->insertBlock($region_to, $block_uuid, $block, $preceding_block_uuid); + } + else { + $section->addBlock($region_to, $block_uuid, $block); + } + + $field->updateFromSection($section); + + $this->layoutTempstoreRepository->set($entity); + return $this->rebuildLayout($entity); + } + +} diff --git a/core/modules/layout_builder/src/Field/LayoutSectionItemInterface.php b/core/modules/layout_builder/src/Field/LayoutSectionItemInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..786b6b4b19acd4f932d7d64c922a46ba99b37613 --- /dev/null +++ b/core/modules/layout_builder/src/Field/LayoutSectionItemInterface.php @@ -0,0 +1,40 @@ +<?php + +namespace Drupal\layout_builder\Field; + +use Drupal\Core\Field\FieldItemInterface; +use Drupal\layout_builder\Section; + +/** + * Defines an interface for the layout section field item. + * + * @internal + * Layout Builder is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + * + * @property string layout + * @property array[] layout_settings + * @property array[] section + */ +interface LayoutSectionItemInterface extends FieldItemInterface { + + /** + * Gets a domain object for the layout section. + * + * @return \Drupal\layout_builder\Section + * The layout section. + */ + public function getSection(); + + /** + * Updates the stored value based on the domain object. + * + * @param \Drupal\layout_builder\Section $section + * The layout section. + * + * @return $this + */ + public function updateFromSection(Section $section); + +} diff --git a/core/modules/layout_builder/src/Field/LayoutSectionItemList.php b/core/modules/layout_builder/src/Field/LayoutSectionItemList.php new file mode 100644 index 0000000000000000000000000000000000000000..933ed6eb974e866bc32cdde910e0409e6e75d5e2 --- /dev/null +++ b/core/modules/layout_builder/src/Field/LayoutSectionItemList.php @@ -0,0 +1,32 @@ +<?php + +namespace Drupal\layout_builder\Field; + +use Drupal\Core\Field\FieldItemList; + +/** + * Defines a item list class for layout section fields. + * + * @internal + * + * @see \Drupal\layout_builder\Plugin\Field\FieldType\LayoutSectionItem + */ +class LayoutSectionItemList extends FieldItemList implements LayoutSectionItemListInterface { + + /** + * {@inheritdoc} + */ + public function addItem($index, $value) { + if ($this->get($index)) { + $start = array_slice($this->list, 0, $index); + $end = array_slice($this->list, $index); + $item = $this->createItem($index, $value); + $this->list = array_merge($start, [$item], $end); + } + else { + $item = $this->appendItem($value); + } + return $item; + } + +} diff --git a/core/modules/layout_builder/src/Field/LayoutSectionItemListInterface.php b/core/modules/layout_builder/src/Field/LayoutSectionItemListInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..81839d17747b6975efe3912847cab60bcafafaf3 --- /dev/null +++ b/core/modules/layout_builder/src/Field/LayoutSectionItemListInterface.php @@ -0,0 +1,46 @@ +<?php + +namespace Drupal\layout_builder\Field; + +use Drupal\Core\Field\FieldItemListInterface; + +/** + * Defines a item list class for layout section fields. + * + * @internal + * Layout Builder is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + * + * @see \Drupal\layout_builder\Plugin\Field\FieldType\LayoutSectionItem + */ +interface LayoutSectionItemListInterface extends FieldItemListInterface { + + /** + * {@inheritdoc} + * + * @return \Drupal\layout_builder\Field\LayoutSectionItemInterface|null + * The layout section item, if it exists. + */ + public function get($index); + + /** + * Adds a new item to the list. + * + * If an item exists at the given index, the item at that position and others + * after it are shifted backward. + * + * @param int $index + * The position of the item in the list. + * @param mixed $value + * The value of the item to be stored at the specified position. + * + * @return \Drupal\Core\TypedData\TypedDataInterface + * The item that was appended. + * + * @todo Move to \Drupal\Core\TypedData\ListInterface directly in + * https://www.drupal.org/node/2907417. + */ + public function addItem($index, $value); + +} diff --git a/core/modules/layout_builder/src/Form/AddBlockForm.php b/core/modules/layout_builder/src/Form/AddBlockForm.php new file mode 100644 index 0000000000000000000000000000000000000000..1757606002c2b2a85209cb6fa65f24bf3e908453 --- /dev/null +++ b/core/modules/layout_builder/src/Form/AddBlockForm.php @@ -0,0 +1,35 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\layout_builder\Section; + +/** + * Provides a form to add a block. + * + * @internal + */ +class AddBlockForm extends ConfigureBlockFormBase { + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_add_block'; + } + + /** + * {@inheritdoc} + */ + protected function submitLabel() { + return $this->t('Add Block'); + } + + /** + * {@inheritdoc} + */ + protected function submitBlock(Section $section, $region, $uuid, array $configuration) { + $section->addBlock($region, $uuid, $configuration); + } + +} diff --git a/core/modules/layout_builder/src/Form/AjaxFormHelperTrait.php b/core/modules/layout_builder/src/Form/AjaxFormHelperTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..a5a387020f9d6c383295ca037a4da8380f1d05e3 --- /dev/null +++ b/core/modules/layout_builder/src/Form/AjaxFormHelperTrait.php @@ -0,0 +1,61 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\ReplaceCommand; +use Drupal\Core\Form\FormStateInterface; +use Drupal\layout_builder\Controller\AjaxHelperTrait; + +/** + * Provides a helper to for submitting an AJAX form. + * + * @internal + * + * @todo Move to \Drupal\Core in https://www.drupal.org/node/2896535. + */ +trait AjaxFormHelperTrait { + + use AjaxHelperTrait; + + /** + * Submit form dialog #ajax callback. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * An AJAX response that display validation error messages or represents a + * successful submission. + */ + public function ajaxSubmit(array &$form, FormStateInterface $form_state) { + if ($form_state->hasAnyErrors()) { + $form['status_messages'] = [ + '#type' => 'status_messages', + '#weight' => -1000, + ]; + $response = new AjaxResponse(); + $response->addCommand(new ReplaceCommand('[data-drupal-selector="' . $form['#attributes']['data-drupal-selector'] . '"]', $form)); + } + else { + $response = $this->successfulAjaxSubmit($form, $form_state); + } + return $response; + } + + /** + * Allows the form to respond to a successful AJAX submission. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\Core\Ajax\AjaxResponse + * An AJAX response. + */ + abstract protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state); + +} diff --git a/core/modules/layout_builder/src/Form/ConfigureBlockFormBase.php b/core/modules/layout_builder/src/Form/ConfigureBlockFormBase.php new file mode 100644 index 0000000000000000000000000000000000000000..7356ef4ccdf18a0abc3670e0aad42ea1c0545694 --- /dev/null +++ b/core/modules/layout_builder/src/Form/ConfigureBlockFormBase.php @@ -0,0 +1,282 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\Component\Uuid\UuidInterface; +use Drupal\Core\Block\BlockManagerInterface; +use Drupal\Core\Block\BlockPluginInterface; +use Drupal\Core\DependencyInjection\ClassResolverInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Form\SubformState; +use Drupal\Core\Plugin\Context\ContextRepositoryInterface; +use Drupal\Core\Plugin\ContextAwarePluginAssignmentTrait; +use Drupal\Core\Plugin\ContextAwarePluginInterface; +use Drupal\Core\Plugin\PluginFormFactoryInterface; +use Drupal\Core\Plugin\PluginWithFormsInterface; +use Drupal\layout_builder\Controller\LayoutRebuildTrait; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Drupal\layout_builder\Section; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a base form for configuring a block. + * + * @internal + */ +abstract class ConfigureBlockFormBase extends FormBase { + + use AjaxFormHelperTrait; + use ContextAwarePluginAssignmentTrait; + use LayoutRebuildTrait; + + /** + * The plugin being configured. + * + * @var \Drupal\Core\Block\BlockPluginInterface + */ + protected $block; + + /** + * The context repository. + * + * @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface + */ + protected $contextRepository; + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * The block manager. + * + * @var \Drupal\Core\Block\BlockManagerInterface + */ + protected $blockManager; + + /** + * The UUID generator. + * + * @var \Drupal\Component\Uuid\UuidInterface + */ + protected $uuid; + + /** + * The plugin form manager. + * + * @var \Drupal\Core\Plugin\PluginFormFactoryInterface + */ + protected $pluginFormFactory; + + /** + * The field delta. + * + * @var int + */ + protected $delta; + + /** + * The current region. + * + * @var string + */ + protected $region; + + /** + * The entity. + * + * @var \Drupal\Core\Entity\EntityInterface + */ + protected $entity; + + /** + * Constructs a new block form. + * + * @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository + * The layout tempstore repository. + * @param \Drupal\Core\Plugin\Context\ContextRepositoryInterface $context_repository + * The context repository. + * @param \Drupal\Core\Block\BlockManagerInterface $block_manager + * The block manager. + * @param \Drupal\Component\Uuid\UuidInterface $uuid + * The UUID generator. + * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver + * The class resolver. + * @param \Drupal\Core\Plugin\PluginFormFactoryInterface $plugin_form_manager + * The plugin form manager. + */ + public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, ContextRepositoryInterface $context_repository, BlockManagerInterface $block_manager, UuidInterface $uuid, ClassResolverInterface $class_resolver, PluginFormFactoryInterface $plugin_form_manager) { + $this->layoutTempstoreRepository = $layout_tempstore_repository; + $this->contextRepository = $context_repository; + $this->blockManager = $block_manager; + $this->uuid = $uuid; + $this->classResolver = $class_resolver; + $this->pluginFormFactory = $plugin_form_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.tempstore_repository'), + $container->get('context.repository'), + $container->get('plugin.manager.block'), + $container->get('uuid'), + $container->get('class_resolver'), + $container->get('plugin_form.factory') + ); + } + + /** + * Prepares the block plugin based on the block ID. + * + * @param string $block_id + * Either a block ID, or the plugin ID used to create a new block. + * @param array $configuration + * The block configuration. + * + * @return \Drupal\Core\Block\BlockPluginInterface + * The block plugin. + */ + protected function prepareBlock($block_id, array $configuration) { + if (!isset($configuration['uuid'])) { + $configuration['uuid'] = $this->uuid->generate(); + } + + return $this->blockManager->createInstance($block_id, $configuration); + } + + /** + * Builds the form for the block. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being configured. + * @param int $delta + * The delta of the section. + * @param string $region + * The region of the block. + * @param string|null $plugin_id + * The plugin ID of the block to add. + * @param array $configuration + * (optional) The array of configuration for the block. + * + * @return array + * The form array. + */ + public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL, $region = NULL, $plugin_id = NULL, array $configuration = []) { + $this->entity = $entity; + $this->delta = $delta; + $this->region = $region; + $this->block = $this->prepareBlock($plugin_id, $configuration); + + $form_state->setTemporaryValue('gathered_contexts', $this->contextRepository->getAvailableContexts()); + + // @todo Remove once https://www.drupal.org/node/2268787 is resolved. + $form_state->set('block_theme', $this->config('system.theme')->get('default')); + + $form['#tree'] = TRUE; + $form['settings'] = []; + $subform_state = SubformState::createForSubform($form['settings'], $form, $form_state); + $form['settings'] = $this->getPluginForm($this->block)->buildConfigurationForm($form['settings'], $subform_state); + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->submitLabel(), + '#button_type' => 'primary', + ]; + if ($this->isAjax()) { + $form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit'; + } + + return $form; + } + + /** + * Returns the label for the submit button. + * + * @return string + * Submit label. + */ + abstract protected function submitLabel(); + + /** + * Handles the submission of a block. + * + * @param \Drupal\layout_builder\Section $section + * The layout section. + * @param string $region + * The region name. + * @param string $uuid + * The UUID of the block. + * @param array $configuration + * The block configuration. + */ + abstract protected function submitBlock(Section $section, $region, $uuid, array $configuration); + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $subform_state = SubformState::createForSubform($form['settings'], $form, $form_state); + $this->getPluginForm($this->block)->validateConfigurationForm($form['settings'], $subform_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // Call the plugin submit handler. + $subform_state = SubformState::createForSubform($form['settings'], $form, $form_state); + $this->getPluginForm($this->block)->submitConfigurationForm($form, $subform_state); + + // If this block is context-aware, set the context mapping. + if ($this->block instanceof ContextAwarePluginInterface) { + $this->block->setContextMapping($subform_state->getValue('context_mapping', [])); + } + + $configuration = $this->block->getConfiguration(); + + /** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */ + $field = $this->entity->layout_builder__layout->get($this->delta); + $section = $field->getSection(); + $this->submitBlock($section, $this->region, $configuration['uuid'], ['block' => $configuration]); + $field->updateFromSection($section); + + $this->layoutTempstoreRepository->set($this->entity); + $form_state->setRedirectUrl($this->entity->toUrl('layout-builder')); + } + + /** + * {@inheritdoc} + */ + protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) { + return $this->rebuildAndClose($this->entity); + } + + /** + * Retrieves the plugin form for a given block. + * + * @param \Drupal\Core\Block\BlockPluginInterface $block + * The block plugin. + * + * @return \Drupal\Core\Plugin\PluginFormInterface + * The plugin form for the block. + */ + protected function getPluginForm(BlockPluginInterface $block) { + if ($block instanceof PluginWithFormsInterface) { + return $this->pluginFormFactory->createInstance($block, 'configure'); + } + return $block; + } + +} diff --git a/core/modules/layout_builder/src/Form/ConfigureSectionForm.php b/core/modules/layout_builder/src/Form/ConfigureSectionForm.php new file mode 100644 index 0000000000000000000000000000000000000000..17913237d51ddfe6b0beddc63a15ec0f8950e445 --- /dev/null +++ b/core/modules/layout_builder/src/Form/ConfigureSectionForm.php @@ -0,0 +1,216 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\Core\DependencyInjection\ClassResolverInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Form\SubformState; +use Drupal\Core\Layout\LayoutInterface; +use Drupal\Core\Layout\LayoutPluginManagerInterface; +use Drupal\Core\Plugin\PluginFormFactoryInterface; +use Drupal\Core\Plugin\PluginFormInterface; +use Drupal\Core\Plugin\PluginWithFormsInterface; +use Drupal\layout_builder\Controller\LayoutRebuildTrait; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a form for configuring a layout section. + * + * @internal + */ +class ConfigureSectionForm extends FormBase { + + use AjaxFormHelperTrait; + use LayoutRebuildTrait; + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * The plugin being configured. + * + * @var \Drupal\Core\Layout\LayoutInterface|\Drupal\Core\Plugin\PluginFormInterface + */ + protected $layout; + + /** + * The layout manager. + * + * @var \Drupal\Core\Layout\LayoutPluginManagerInterface + */ + protected $layoutManager; + + /** + * The plugin form manager. + * + * @var \Drupal\Core\Plugin\PluginFormFactoryInterface + */ + protected $pluginFormFactory; + + /** + * The entity. + * + * @var \Drupal\Core\Entity\EntityInterface + */ + protected $entity; + + /** + * The field delta. + * + * @var int + */ + protected $delta; + + /** + * Indicates whether the section is being added or updated. + * + * @var bool + */ + protected $isUpdate; + + /** + * Constructs a new ConfigureSectionForm. + * + * @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository + * The layout tempstore repository. + * @param \Drupal\Core\Layout\LayoutPluginManagerInterface $layout_manager + * The layout manager. + * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver + * The class resolver. + * @param \Drupal\Core\Plugin\PluginFormFactoryInterface $plugin_form_manager + * The plugin form manager. + */ + public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, LayoutPluginManagerInterface $layout_manager, ClassResolverInterface $class_resolver, PluginFormFactoryInterface $plugin_form_manager) { + $this->layoutTempstoreRepository = $layout_tempstore_repository; + $this->layoutManager = $layout_manager; + $this->classResolver = $class_resolver; + $this->pluginFormFactory = $plugin_form_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.tempstore_repository'), + $container->get('plugin.manager.core.layout'), + $container->get('class_resolver'), + $container->get('plugin_form.factory') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_configure_section'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL, $plugin_id = NULL) { + $this->entity = $entity; + $this->delta = $delta; + $this->isUpdate = is_null($plugin_id); + + $configuration = []; + if ($this->isUpdate) { + /** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */ + $field = $this->entity->layout_builder__layout->get($this->delta); + $plugin_id = $field->layout; + $configuration = $field->layout_settings; + } + $this->layout = $this->layoutManager->createInstance($plugin_id, $configuration); + + $form['#tree'] = TRUE; + $form['layout_settings'] = []; + $subform_state = SubformState::createForSubform($form['layout_settings'], $form, $form_state); + $form['layout_settings'] = $this->getPluginForm($this->layout)->buildConfigurationForm($form['layout_settings'], $subform_state); + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->isUpdate ? $this->t('Update') : $this->t('Add section'), + '#button_type' => 'primary', + ]; + if ($this->isAjax()) { + $form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit'; + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $subform_state = SubformState::createForSubform($form['layout_settings'], $form, $form_state); + $this->getPluginForm($this->layout)->validateConfigurationForm($form['layout_settings'], $subform_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // Call the plugin submit handler. + $subform_state = SubformState::createForSubform($form['layout_settings'], $form, $form_state); + $this->getPluginForm($this->layout)->submitConfigurationForm($form['layout_settings'], $subform_state); + + $plugin_id = $this->layout->getPluginId(); + $configuration = $this->layout->getConfiguration(); + + /** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */ + $field_list = $this->entity->layout_builder__layout; + if ($this->isUpdate) { + $field = $field_list->get($this->delta); + $field->layout = $plugin_id; + $field->layout_settings = $configuration; + } + else { + $field_list->addItem($this->delta, [ + 'layout' => $plugin_id, + 'layout_settings' => $configuration, + 'section' => [], + ]); + } + + $this->layoutTempstoreRepository->set($this->entity); + $form_state->setRedirectUrl($this->entity->toUrl('layout-builder')); + } + + /** + * {@inheritdoc} + */ + protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) { + return $this->rebuildAndClose($this->entity); + } + + /** + * Retrieves the plugin form for a given layout. + * + * @param \Drupal\Core\Layout\LayoutInterface $layout + * The layout plugin. + * + * @return \Drupal\Core\Plugin\PluginFormInterface + * The plugin form for the layout. + */ + protected function getPluginForm(LayoutInterface $layout) { + if ($layout instanceof PluginWithFormsInterface) { + return $this->pluginFormFactory->createInstance($layout, 'configure'); + } + + if ($layout instanceof PluginFormInterface) { + return $layout; + } + + throw new \InvalidArgumentException(sprintf('The "%s" layout does not provide a configuration form', $layout->getPluginId())); + } + +} diff --git a/core/modules/layout_builder/src/Form/LayoutRebuildConfirmFormBase.php b/core/modules/layout_builder/src/Form/LayoutRebuildConfirmFormBase.php new file mode 100644 index 0000000000000000000000000000000000000000..8bee062d4e45bd3d94860469c07797b4c91f30e3 --- /dev/null +++ b/core/modules/layout_builder/src/Form/LayoutRebuildConfirmFormBase.php @@ -0,0 +1,119 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\Core\DependencyInjection\ClassResolverInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Form\ConfirmFormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\layout_builder\Controller\LayoutRebuildTrait; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a base class for confirmation forms that rebuild the Layout Builder. + * + * @internal + */ +abstract class LayoutRebuildConfirmFormBase extends ConfirmFormBase { + + use AjaxFormHelperTrait; + use LayoutRebuildTrait; + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * The entity. + * + * @var \Drupal\Core\Entity\EntityInterface + */ + protected $entity; + + /** + * The field delta. + * + * @var int + */ + protected $delta; + + /** + * Constructs a new RemoveSectionForm. + * + * @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository + * The layout tempstore repository. + * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver + * The class resolver. + */ + public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, ClassResolverInterface $class_resolver) { + $this->layoutTempstoreRepository = $layout_tempstore_repository; + $this->classResolver = $class_resolver; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.tempstore_repository'), + $container->get('class_resolver') + ); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return $this->entity->toUrl('layout-builder', ['query' => ['layout_is_rebuilding' => TRUE]]); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL) { + $this->entity = $entity; + $this->delta = $delta; + + $form = parent::buildForm($form, $form_state); + + if ($this->isAjax()) { + $form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit'; + $form['actions']['cancel']['#attributes']['class'][] = 'dialog-cancel'; + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->handleEntity($this->entity, $form_state); + + $this->layoutTempstoreRepository->set($this->entity); + + $form_state->setRedirectUrl($this->getCancelUrl()); + } + + /** + * {@inheritdoc} + */ + protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) { + return $this->rebuildAndClose($this->entity); + } + + /** + * Performs any actions on the layout entity before saving. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + abstract protected function handleEntity(EntityInterface $entity, FormStateInterface $form_state); + +} diff --git a/core/modules/layout_builder/src/Form/RemoveBlockForm.php b/core/modules/layout_builder/src/Form/RemoveBlockForm.php new file mode 100644 index 0000000000000000000000000000000000000000..139186af66541b656648f3ae3a356fce7e3ca5b1 --- /dev/null +++ b/core/modules/layout_builder/src/Form/RemoveBlockForm.php @@ -0,0 +1,70 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Form\FormStateInterface; + +/** + * Provides a form to confirm the removal of a block. + * + * @internal + */ +class RemoveBlockForm extends LayoutRebuildConfirmFormBase { + + /** + * The current region. + * + * @var string + */ + protected $region; + + /** + * The UUID of the block being removed. + * + * @var string + */ + protected $uuid; + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return $this->t('Are you sure you want to remove this block?'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Remove'); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_remove_block'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL, $region = NULL, $uuid = NULL) { + $this->region = $region; + $this->uuid = $uuid; + return parent::buildForm($form, $form_state, $entity, $delta); + } + + /** + * {@inheritdoc} + */ + protected function handleEntity(EntityInterface $entity, FormStateInterface $form_state) { + /** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */ + $field = $entity->layout_builder__layout->get($this->delta); + $section = $field->getSection(); + $section->removeBlock($this->region, $this->uuid); + $field->updateFromSection($section); + } + +} diff --git a/core/modules/layout_builder/src/Form/RemoveSectionForm.php b/core/modules/layout_builder/src/Form/RemoveSectionForm.php new file mode 100644 index 0000000000000000000000000000000000000000..e44edd694736706a33d550f2ddc1ccacc5042ab9 --- /dev/null +++ b/core/modules/layout_builder/src/Form/RemoveSectionForm.php @@ -0,0 +1,43 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Form\FormStateInterface; + +/** + * Provides a form to confirm the removal of a section. + * + * @internal + */ +class RemoveSectionForm extends LayoutRebuildConfirmFormBase { + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_remove_section'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return $this->t('Are you sure you want to remove this section?'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Remove'); + } + + /** + * {@inheritdoc} + */ + protected function handleEntity(EntityInterface $entity, FormStateInterface $form_state) { + $entity->layout_builder__layout->removeItem($this->delta); + } + +} diff --git a/core/modules/layout_builder/src/Form/UpdateBlockForm.php b/core/modules/layout_builder/src/Form/UpdateBlockForm.php new file mode 100644 index 0000000000000000000000000000000000000000..2f2aa600e44c7e08d5ed5cd5cf293cce3e372369 --- /dev/null +++ b/core/modules/layout_builder/src/Form/UpdateBlockForm.php @@ -0,0 +1,67 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\layout_builder\Section; + +/** + * Provides a form to update a block. + * + * @internal + */ +class UpdateBlockForm extends ConfigureBlockFormBase { + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_update_block'; + } + + /** + * Builds the block form. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being configured. + * @param int $delta + * The delta of the section. + * @param string $region + * The region of the block. + * @param string $uuid + * The UUID of the block being updated. + * + * @return array + * The form array. + */ + public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL, $region = NULL, $uuid = NULL) { + /** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */ + $field = $entity->layout_builder__layout->get($delta); + $block = $field->getSection()->getBlock($region, $uuid); + if (empty($block['block']['id'])) { + throw new \InvalidArgumentException('Invalid UUID specified'); + } + + return parent::buildForm($form, $form_state, $entity, $delta, $region, $block['block']['id'], $block['block']); + } + + /** + * {@inheritdoc} + */ + protected function submitLabel() { + return $this->t('Update'); + } + + /** + * {@inheritdoc} + */ + protected function submitBlock(Section $section, $region, $uuid, array $configuration) { + $section->updateBlock($region, $uuid, $configuration); + } + +} diff --git a/core/modules/layout_builder/src/LayoutSectionBuilder.php b/core/modules/layout_builder/src/LayoutSectionBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..1682974f1134533f61792cf627a14d964beac892 --- /dev/null +++ b/core/modules/layout_builder/src/LayoutSectionBuilder.php @@ -0,0 +1,201 @@ +<?php + +namespace Drupal\layout_builder; + +use Drupal\Component\Plugin\Exception\PluginException; +use Drupal\Core\Block\BlockManagerInterface; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Layout\LayoutInterface; +use Drupal\Core\Layout\LayoutPluginManagerInterface; +use Drupal\Core\Plugin\Context\ContextHandlerInterface; +use Drupal\Core\Plugin\Context\ContextRepositoryInterface; +use Drupal\Core\Plugin\ContextAwarePluginInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * Builds the UI for layout sections. + * + * @internal + */ +class LayoutSectionBuilder { + + use StringTranslationTrait; + + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected $account; + + /** + * The layout plugin manager. + * + * @var \Drupal\Core\Layout\LayoutPluginManagerInterface + */ + protected $layoutPluginManager; + + /** + * The block plugin manager. + * + * @var \Drupal\Core\Block\BlockManagerInterface + */ + protected $blockManager; + + /** + * The plugin context handler. + * + * @var \Drupal\Core\Plugin\Context\ContextHandlerInterface + */ + protected $contextHandler; + + /** + * The context manager service. + * + * @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface + */ + protected $contextRepository; + + /** + * Constructs a LayoutSectionFormatter object. + * + * @param \Drupal\Core\Session\AccountInterface $account + * The current user. + * @param \Drupal\Core\Layout\LayoutPluginManagerInterface $layoutPluginManager + * The layout plugin manager. + * @param \Drupal\Core\Block\BlockManagerInterface $blockManager + * THe block plugin manager. + * @param \Drupal\Core\Plugin\Context\ContextHandlerInterface $context_handler + * The ContextHandler for applying contexts to conditions properly. + * @param \Drupal\Core\Plugin\Context\ContextRepositoryInterface $context_repository + * The lazy context repository service. + */ + public function __construct(AccountInterface $account, LayoutPluginManagerInterface $layoutPluginManager, BlockManagerInterface $blockManager, ContextHandlerInterface $context_handler, ContextRepositoryInterface $context_repository) { + $this->account = $account; + $this->layoutPluginManager = $layoutPluginManager; + $this->blockManager = $blockManager; + $this->contextHandler = $context_handler; + $this->contextRepository = $context_repository; + } + + /** + * Builds the render array for the layout section. + * + * @param \Drupal\Core\Layout\LayoutInterface $layout + * The ID of the layout. + * @param array $section + * An array of configuration, keyed first by region and then by block UUID. + * + * @return array + * The render array for a given section. + */ + public function buildSectionFromLayout(LayoutInterface $layout, array $section) { + $cacheability = CacheableMetadata::createFromRenderArray([]); + + $regions = []; + $weight = 0; + foreach ($section as $region => $blocks) { + if (!is_array($blocks)) { + throw new \InvalidArgumentException(sprintf('The "%s" region in the "%s" layout has invalid configuration', $region, $layout->getPluginId())); + } + + foreach ($blocks as $uuid => $configuration) { + if (!is_array($configuration) || !isset($configuration['block'])) { + throw new \InvalidArgumentException(sprintf('The block with UUID of "%s" has invalid configuration', $uuid)); + } + + if ($block_output = $this->buildBlock($uuid, $configuration['block'], $cacheability)) { + $block_output['#weight'] = $weight++; + $regions[$region][$uuid] = $block_output; + } + } + } + + $result = $layout->build($regions); + $cacheability->applyTo($result); + return $result; + } + + /** + * Builds the render array for the layout section. + * + * @param string $layout_id + * The ID of the layout. + * @param array $layout_settings + * The configuration for the layout. + * @param array $section + * An array of configuration, keyed first by region and then by block UUID. + * + * @return array + * The render array for a given section. + */ + public function buildSection($layout_id, array $layout_settings, array $section) { + $layout = $this->layoutPluginManager->createInstance($layout_id, $layout_settings); + return $this->buildSectionFromLayout($layout, $section); + } + + /** + * Builds the render array for a given block. + * + * @param string $uuid + * The UUID of this block instance. + * @param array $configuration + * An array of configuration relevant to the block instance. Must contain + * the plugin ID with the key 'id'. + * @param \Drupal\Core\Cache\CacheableMetadata $cacheability + * The cacheability metadata. + * + * @return array|null + * The render array representing this block, if accessible. NULL otherwise. + */ + protected function buildBlock($uuid, array $configuration, CacheableMetadata $cacheability) { + $block = $this->getBlock($uuid, $configuration); + + $access = $block->access($this->account, TRUE); + $cacheability->addCacheableDependency($access); + + $block_output = NULL; + if ($access->isAllowed()) { + $block_output = [ + '#theme' => 'block', + '#configuration' => $block->getConfiguration(), + '#plugin_id' => $block->getPluginId(), + '#base_plugin_id' => $block->getBaseId(), + '#derivative_plugin_id' => $block->getDerivativeId(), + 'content' => $block->build(), + ]; + $cacheability->addCacheableDependency($block); + } + return $block_output; + } + + /** + * Gets a block instance. + * + * @param string $uuid + * The UUID of this block instance. + * @param array $configuration + * An array of configuration relevant to the block instance. Must contain + * the plugin ID with the key 'id'. + * + * @return \Drupal\Core\Block\BlockPluginInterface + * The block instance. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * Thrown when the configuration parameter does not contain 'id'. + */ + protected function getBlock($uuid, array $configuration) { + if (!isset($configuration['id'])) { + throw new PluginException(sprintf('No plugin ID specified for block with "%s" UUID', $uuid)); + } + + $block = $this->blockManager->createInstance($configuration['id'], $configuration); + if ($block instanceof ContextAwarePluginInterface) { + $contexts = $this->contextRepository->getRuntimeContexts(array_values($block->getContextMapping())); + $this->contextHandler->applyContextMapping($block, $contexts); + } + return $block; + } + +} diff --git a/core/modules/layout_builder/src/LayoutTempstoreRepository.php b/core/modules/layout_builder/src/LayoutTempstoreRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..87baa1331c05c5724f55501f19f5c25752f2b288 --- /dev/null +++ b/core/modules/layout_builder/src/LayoutTempstoreRepository.php @@ -0,0 +1,118 @@ +<?php + +namespace Drupal\layout_builder; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\user\SharedTempStoreFactory; + +/** + * Provides a mechanism for loading layouts from tempstore. + * + * @internal + */ +class LayoutTempstoreRepository implements LayoutTempstoreRepositoryInterface { + + /** + * The shared tempstore factory. + * + * @var \Drupal\user\SharedTempStoreFactory + */ + protected $tempStoreFactory; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * LayoutTempstoreRepository constructor. + * + * @param \Drupal\user\SharedTempStoreFactory $temp_store_factory + * The shared tempstore factory. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + */ + public function __construct(SharedTempStoreFactory $temp_store_factory, EntityTypeManagerInterface $entity_type_manager) { + $this->tempStoreFactory = $temp_store_factory; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public function get(EntityInterface $entity) { + $id = $this->generateTempstoreId($entity); + $tempstore = $this->getTempstore($entity)->get($id); + if (!empty($tempstore['entity'])) { + $entity_type_id = $entity->getEntityTypeId(); + $entity = $tempstore['entity']; + + if (!($entity instanceof EntityInterface)) { + throw new \UnexpectedValueException(sprintf('The entry with entity type "%s" and ID "%s" is not a valid entity', $entity_type_id, $id)); + } + } + return $entity; + } + + /** + * {@inheritdoc} + */ + public function getFromId($entity_type_id, $entity_id) { + $entity = $this->entityTypeManager->getStorage($entity_type_id)->loadRevision($entity_id); + return $this->get($entity); + } + + /** + * {@inheritdoc} + */ + public function set(EntityInterface $entity) { + $id = $this->generateTempstoreId($entity); + $this->getTempstore($entity)->set($id, ['entity' => $entity]); + } + + /** + * {@inheritdoc} + */ + public function delete(EntityInterface $entity) { + if ($this->get($entity)) { + $id = $this->generateTempstoreId($entity); + $this->getTempstore($entity)->delete($id); + } + } + + /** + * Generates an ID for putting an entity in tempstore. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being stored. + * + * @return string + * The tempstore ID. + */ + protected function generateTempstoreId(EntityInterface $entity) { + $id = "{$entity->id()}.{$entity->language()->getId()}"; + if ($entity instanceof RevisionableInterface) { + $id .= '.' . $entity->getRevisionId(); + } + return $id; + } + + /** + * Gets the shared tempstore. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being stored. + * + * @return \Drupal\user\SharedTempStore + * The tempstore. + */ + protected function getTempstore(EntityInterface $entity) { + $collection = $entity->getEntityTypeId() . '.layout_builder__layout'; + return $this->tempStoreFactory->get($collection); + } + +} diff --git a/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php b/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..ffce1c3008ad62e8dbb9c005653050cc7696eac3 --- /dev/null +++ b/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php @@ -0,0 +1,65 @@ +<?php + +namespace Drupal\layout_builder; + +use Drupal\Core\Entity\EntityInterface; + +/** + * Provides an interface for loading layouts from tempstore. + * + * @internal + * Layout Builder is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + */ +interface LayoutTempstoreRepositoryInterface { + + /** + * Gets the tempstore version of an entity, if it exists. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to check for in tempstore. + * + * @return \Drupal\Core\Entity\EntityInterface + * Either the version of this entity from tempstore, or the passed entity if + * none exists. + * + * @throw \UnexpectedValueException + * Thrown if a value exists, but is not an entity. + */ + public function get(EntityInterface $entity); + + /** + * Loads an entity from tempstore given the entity ID. + * + * @param string $entity_type_id + * The entity type ID. + * @param string $entity_id + * The entity ID (or revision ID). + * + * @return \Drupal\Core\Entity\EntityInterface + * Either the version of this entity from tempstore, or the entity from + * storage if none exists. + * + * @throw \UnexpectedValueException + * Thrown if a value exists, but is not an entity. + */ + public function getFromId($entity_type_id, $entity_id); + + /** + * Stores this entity in tempstore. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to set in tempstore. + */ + public function set(EntityInterface $entity); + + /** + * Removes the tempstore version of an entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to remove from tempstore. + */ + public function delete(EntityInterface $entity); + +} diff --git a/core/modules/layout_builder/src/Plugin/Derivative/LayoutBuilderLocalTaskDeriver.php b/core/modules/layout_builder/src/Plugin/Derivative/LayoutBuilderLocalTaskDeriver.php new file mode 100644 index 0000000000000000000000000000000000000000..7054dd360532901b48c099355d379b970104195a --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Derivative/LayoutBuilderLocalTaskDeriver.php @@ -0,0 +1,96 @@ +<?php + +namespace Drupal\layout_builder\Plugin\Derivative; + +use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\layout_builder\Plugin\Menu\LayoutBuilderLocalTask; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides local task definitions for the layout builder user interface. + * + * @internal + */ +class LayoutBuilderLocalTaskDeriver extends DeriverBase implements ContainerDeriverInterface { + + use StringTranslationTrait; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * Constructs a new LayoutBuilderLocalTaskDeriver. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + */ + public function __construct(EntityTypeManagerInterface $entity_type_manager) { + $this->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) { + foreach (array_keys($this->getEntityTypes()) as $entity_type_id) { + $this->derivatives["entity.$entity_type_id.layout_builder"] = $base_plugin_definition + [ + 'route_name' => "entity.$entity_type_id.layout_builder", + 'weight' => 15, + 'title' => $this->t('Layout'), + 'base_route' => "entity.$entity_type_id.canonical", + 'entity_type_id' => $entity_type_id, + 'class' => LayoutBuilderLocalTask::class, + 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], + ]; + $this->derivatives["entity.$entity_type_id.save_layout"] = $base_plugin_definition + [ + 'route_name' => "entity.$entity_type_id.save_layout", + 'title' => $this->t('Save Layout'), + 'parent_id' => "layout_builder_ui:entity.$entity_type_id.layout_builder", + 'entity_type_id' => $entity_type_id, + 'class' => LayoutBuilderLocalTask::class, + 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], + ]; + $this->derivatives["entity.$entity_type_id.cancel_layout"] = $base_plugin_definition + [ + 'route_name' => "entity.$entity_type_id.cancel_layout", + 'title' => $this->t('Cancel Layout'), + 'parent_id' => "layout_builder_ui:entity.$entity_type_id.layout_builder", + 'entity_type_id' => $entity_type_id, + 'class' => LayoutBuilderLocalTask::class, + 'weight' => 5, + 'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id], + ]; + } + + return $this->derivatives; + } + + /** + * Returns an array of relevant entity types. + * + * @return \Drupal\Core\Entity\EntityTypeInterface[] + * An array of entity types. + */ + protected function getEntityTypes() { + return array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $entity_type) { + return $entity_type->hasLinkTemplate('layout-builder'); + }); + } + +} diff --git a/core/modules/layout_builder/src/Plugin/Field/FieldFormatter/LayoutSectionFormatter.php b/core/modules/layout_builder/src/Plugin/Field/FieldFormatter/LayoutSectionFormatter.php new file mode 100644 index 0000000000000000000000000000000000000000..4951d01c4b9bca5ad8330adad9bbb9a59f638a99 --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Field/FieldFormatter/LayoutSectionFormatter.php @@ -0,0 +1,89 @@ +<?php + +namespace Drupal\layout_builder\Plugin\Field\FieldFormatter; + +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Field\FormatterBase; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\layout_builder\LayoutSectionBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Plugin implementation of the 'layout_section' formatter. + * + * @internal + * + * @FieldFormatter( + * id = "layout_section", + * label = @Translation("Layout Section"), + * field_types = { + * "layout_section" + * } + * ) + */ +class LayoutSectionFormatter extends FormatterBase implements ContainerFactoryPluginInterface { + + /** + * The layout section builder. + * + * @var \Drupal\layout_builder\LayoutSectionBuilder + */ + protected $builder; + + /** + * Constructs a LayoutSectionFormatter object. + * + * @param \Drupal\layout_builder\LayoutSectionBuilder $builder + * The layout section builder. + * @param string $plugin_id + * The plugin ID for the formatter. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition + * The definition of the field to which the formatter is associated. + * @param array $settings + * The formatter settings. + * @param string $label + * The formatter label display setting. + * @param string $view_mode + * The view mode. + * @param array $third_party_settings + * Any third party settings. + */ + public function __construct(LayoutSectionBuilder $builder, $plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings) { + $this->builder = $builder; + parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $container->get('layout_builder.builder'), + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['label'], + $configuration['view_mode'], + $configuration['third_party_settings'] + ); + } + + /** + * {@inheritdoc} + */ + public function viewElements(FieldItemListInterface $items, $langcode) { + $elements = []; + + /** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface[] $items */ + foreach ($items as $delta => $item) { + $elements[$delta] = $this->builder->buildSection($item->layout, $item->layout_settings, $item->section); + } + + return $elements; + } + +} diff --git a/core/modules/layout_builder/src/Plugin/Field/FieldType/LayoutSectionItem.php b/core/modules/layout_builder/src/Plugin/Field/FieldType/LayoutSectionItem.php new file mode 100644 index 0000000000000000000000000000000000000000..fc1c63413fa1ff7a327312abd6b18ac75dbe2246 --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Field/FieldType/LayoutSectionItem.php @@ -0,0 +1,132 @@ +<?php + +namespace Drupal\layout_builder\Plugin\Field\FieldType; + +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldItemBase; +use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\DataDefinition; +use Drupal\Core\TypedData\MapDataDefinition; +use Drupal\layout_builder\Field\LayoutSectionItemInterface; +use Drupal\layout_builder\Section; + +/** + * Plugin implementation of the 'layout_section' field type. + * + * @internal + * + * @FieldType( + * id = "layout_section", + * label = @Translation("Layout Section"), + * description = @Translation("Layout Section"), + * default_formatter = "layout_section", + * list_class = "\Drupal\layout_builder\Field\LayoutSectionItemList", + * no_ui = TRUE, + * cardinality = \Drupal\Core\Field\FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + * ) + */ +class LayoutSectionItem extends FieldItemBase implements LayoutSectionItemInterface { + + /** + * {@inheritdoc} + */ + public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) { + // Prevent early t() calls by using the TranslatableMarkup. + $properties['layout'] = DataDefinition::create('string') + ->setLabel(new TranslatableMarkup('Layout')) + ->setSetting('case_sensitive', FALSE) + ->setRequired(TRUE); + $properties['layout_settings'] = MapDataDefinition::create('map') + ->setLabel(new TranslatableMarkup('Layout Settings')) + ->setRequired(FALSE); + $properties['section'] = MapDataDefinition::create('map') + ->setLabel(new TranslatableMarkup('Layout Section')) + ->setRequired(FALSE); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public function __get($name) { + // @todo \Drupal\Core\Field\FieldItemBase::__get() does not return default + // values for uninstantiated properties. This will forcibly instantiate + // all properties with the side-effect of a performance hit, resolve + // properly in https://www.drupal.org/node/2413471. + $this->getProperties(); + + return parent::__get($name); + } + + /** + * {@inheritdoc} + */ + public static function mainPropertyName() { + return 'section'; + } + + /** + * {@inheritdoc} + */ + public static function schema(FieldStorageDefinitionInterface $field_definition) { + $schema = [ + 'columns' => [ + 'layout' => [ + 'type' => 'varchar', + 'length' => '255', + 'binary' => FALSE, + ], + 'layout_settings' => [ + 'type' => 'blob', + 'size' => 'normal', + // @todo Address in https://www.drupal.org/node/2914503. + 'serialize' => TRUE, + ], + 'section' => [ + 'type' => 'blob', + 'size' => 'normal', + // @todo Address in https://www.drupal.org/node/2914503. + 'serialize' => TRUE, + ], + ], + ]; + + return $schema; + } + + /** + * {@inheritdoc} + */ + public static function generateSampleValue(FieldDefinitionInterface $field_definition) { + $values['layout'] = 'layout_onecol'; + $values['layout_settings'] = []; + // @todo Expand this in https://www.drupal.org/node/2912331. + $values['section'] = []; + return $values; + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + return empty($this->layout); + } + + /** + * {@inheritdoc} + */ + public function getSection() { + return new Section($this->section); + } + + /** + * {@inheritdoc} + */ + public function updateFromSection(Section $section) { + $this->section = $section->getValue(); + return $this; + } + +} diff --git a/core/modules/layout_builder/src/Plugin/Menu/LayoutBuilderLocalTask.php b/core/modules/layout_builder/src/Plugin/Menu/LayoutBuilderLocalTask.php new file mode 100644 index 0000000000000000000000000000000000000000..0098e353135f7916d6c8747bdc01b551fd86b6d8 --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Menu/LayoutBuilderLocalTask.php @@ -0,0 +1,26 @@ +<?php + +namespace Drupal\layout_builder\Plugin\Menu; + +use Drupal\Core\Menu\LocalTaskDefault; +use Drupal\Core\Routing\RouteMatchInterface; + +/** + * Provides route parameters needed to link to layout related tabs. + * + * @internal + */ +class LayoutBuilderLocalTask extends LocalTaskDefault { + + /** + * {@inheritdoc} + */ + public function getRouteParameters(RouteMatchInterface $route_match) { + $parameters = parent::getRouteParameters($route_match); + + // @todo Revisit this code once https://www.drupal.org/node/2912363 is in. + $parameters['entity'] = $route_match->getParameter('entity'); + return $parameters; + } + +} diff --git a/core/modules/layout_builder/src/Routing/LayoutBuilderRouteEnhancer.php b/core/modules/layout_builder/src/Routing/LayoutBuilderRouteEnhancer.php new file mode 100644 index 0000000000000000000000000000000000000000..66dbbbadc982524a5a093076e6f32711e42e3fd5 --- /dev/null +++ b/core/modules/layout_builder/src/Routing/LayoutBuilderRouteEnhancer.php @@ -0,0 +1,50 @@ +<?php + +namespace Drupal\layout_builder\Routing; + +use Drupal\Core\Routing\EnhancerInterface; +use Symfony\Cmf\Component\Routing\RouteObjectInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Route; + +/** + * Enhances routes to ensure the entity is available with a generic name. + * + * @internal + */ +class LayoutBuilderRouteEnhancer implements EnhancerInterface { + + /** + * Returns whether the enhancer runs on the current route. + * + * @param \Symfony\Component\Routing\Route $route + * The current route. + * + * @return bool + * TRUE if this enhancer applies to this route. + */ + protected function applies(Route $route) { + return $route->getOption('_layout_builder') && $route->getDefault('entity_type_id'); + } + + /** + * {@inheritdoc} + */ + public function enhance(array $defaults, Request $request) { + $route = $defaults[RouteObjectInterface::ROUTE_OBJECT]; + if (!$this->applies($route)) { + return $defaults; + } + + $defaults['is_rebuilding'] = (bool) $request->query->get('layout_is_rebuilding', FALSE); + + if (!isset($defaults[$defaults['entity_type_id']])) { + throw new \RuntimeException(sprintf('Failed to find the "%s" entity in route named %s', $defaults['entity_type_id'], $defaults[RouteObjectInterface::ROUTE_NAME])); + } + + // Copy the entity by reference so that any changes are reflected. + $defaults['entity'] = &$defaults[$defaults['entity_type_id']]; + return $defaults; + } + +} diff --git a/core/modules/layout_builder/src/Routing/LayoutBuilderRoutes.php b/core/modules/layout_builder/src/Routing/LayoutBuilderRoutes.php new file mode 100644 index 0000000000000000000000000000000000000000..b8221dfd79b801bf3abf6ccfaf9cea03dd1aa478 --- /dev/null +++ b/core/modules/layout_builder/src/Routing/LayoutBuilderRoutes.php @@ -0,0 +1,157 @@ +<?php + +namespace Drupal\layout_builder\Routing; + +use Drupal\Core\Entity\EntityFieldManagerInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Symfony\Component\Routing\Route; + +/** + * Provides routes for the Layout Builder UI. + * + * @internal + */ +class LayoutBuilderRoutes { + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The entity field manager. + * + * @var \Drupal\Core\Entity\EntityFieldManagerInterface + */ + protected $entityFieldManager; + + /** + * Constructs a new LayoutBuilderRoutes. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager + * The entity field manager. + */ + public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager) { + $this->entityTypeManager = $entity_type_manager; + $this->entityFieldManager = $entity_field_manager; + } + + /** + * Generates layout builder routes. + * + * @return \Symfony\Component\Routing\Route[] + * An array of route objects. + */ + public function getRoutes() { + $routes = []; + + foreach ($this->getEntityTypes() as $entity_type_id => $entity_type) { + $integer_id = $this->hasIntegerId($entity_type); + + $template = $entity_type->getLinkTemplate('layout-builder'); + $route = (new Route($template)) + ->setDefaults([ + '_controller' => '\Drupal\layout_builder\Controller\LayoutBuilderController::layout', + '_title_callback' => '\Drupal\layout_builder\Controller\LayoutBuilderController::title', + 'entity' => NULL, + 'entity_type_id' => $entity_type_id, + 'is_rebuilding' => FALSE, + ]) + ->addRequirements([ + '_has_layout_section' => 'true', + ]) + ->addOptions([ + '_layout_builder' => TRUE, + 'parameters' => [ + $entity_type_id => [ + 'type' => 'entity:{entity_type_id}', + 'layout_builder_tempstore' => TRUE, + ], + ], + ]); + if ($integer_id) { + $route->setRequirement($entity_type_id, '\d+'); + } + $routes["entity.$entity_type_id.layout_builder"] = $route; + + $route = (new Route("$template/save")) + ->setDefaults([ + '_controller' => '\Drupal\layout_builder\Controller\LayoutBuilderController::saveLayout', + 'entity' => NULL, + 'entity_type_id' => $entity_type_id, + ]) + ->addRequirements([ + '_has_layout_section' => 'true', + ]) + ->addOptions([ + '_layout_builder' => TRUE, + 'parameters' => [ + $entity_type_id => [ + 'type' => 'entity:{entity_type_id}', + 'layout_builder_tempstore' => TRUE, + ], + ], + ]); + if ($integer_id) { + $route->setRequirement($entity_type_id, '\d+'); + } + $routes["entity.$entity_type_id.save_layout"] = $route; + + $route = (new Route("$template/cancel")) + ->setDefaults([ + '_controller' => '\Drupal\layout_builder\Controller\LayoutBuilderController::cancelLayout', + 'entity' => NULL, + 'entity_type_id' => $entity_type_id, + ]) + ->addRequirements([ + '_has_layout_section' => 'true', + ]) + ->addOptions([ + '_layout_builder' => TRUE, + 'parameters' => [ + $entity_type_id => [ + 'type' => 'entity:{entity_type_id}', + 'layout_builder_tempstore' => TRUE, + ], + ], + ]); + if ($integer_id) { + $route->setRequirement($entity_type_id, '\d+'); + } + $routes["entity.$entity_type_id.cancel_layout"] = $route; + } + return $routes; + } + + /** + * Determines if this entity type's ID is stored as an integer. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * An entity type. + * + * @return bool + * TRUE if this entity type's ID key is always an integer, FALSE otherwise. + */ + protected function hasIntegerId(EntityTypeInterface $entity_type) { + $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id()); + return $field_storage_definitions[$entity_type->getKey('id')]->getType() === 'integer'; + } + + /** + * Returns an array of relevant entity types. + * + * @return \Drupal\Core\Entity\EntityTypeInterface[] + * An array of entity types. + */ + protected function getEntityTypes() { + return array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $entity_type) { + return $entity_type->hasLinkTemplate('layout-builder'); + }); + } + +} diff --git a/core/modules/layout_builder/src/Routing/LayoutTempstoreParamConverter.php b/core/modules/layout_builder/src/Routing/LayoutTempstoreParamConverter.php new file mode 100644 index 0000000000000000000000000000000000000000..a1d5fc9140b0b2710ecb81681dda3856c1fe192b --- /dev/null +++ b/core/modules/layout_builder/src/Routing/LayoutTempstoreParamConverter.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\layout_builder\Routing; + +use Drupal\Core\Entity\EntityManagerInterface; +use Drupal\Core\ParamConverter\EntityConverter; +use Drupal\Core\ParamConverter\ParamConverterInterface; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Symfony\Component\Routing\Route; + +/** + * Loads the entity from the layout tempstore. + * + * @internal + */ +class LayoutTempstoreParamConverter extends EntityConverter implements ParamConverterInterface { + + /** + * The layout tempstore repository. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstoreRepository; + + /** + * Constructs a new LayoutTempstoreParamConverter. + * + * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager + * The entity manager. + * @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository + * The layout tempstore repository. + */ + public function __construct(EntityManagerInterface $entity_manager, LayoutTempstoreRepositoryInterface $layout_tempstore_repository) { + parent::__construct($entity_manager); + $this->layoutTempstoreRepository = $layout_tempstore_repository; + } + + /** + * {@inheritdoc} + */ + public function convert($value, $definition, $name, array $defaults) { + if ($entity = parent::convert($value, $definition, $name, $defaults)) { + return $this->layoutTempstoreRepository->get($entity); + } + } + + /** + * {@inheritdoc} + */ + public function applies($definition, $name, Route $route) { + return !empty($definition['layout_builder_tempstore']); + } + +} diff --git a/core/modules/layout_builder/src/Section.php b/core/modules/layout_builder/src/Section.php new file mode 100644 index 0000000000000000000000000000000000000000..f5e19003b58a80304b287edb8e65c8fcc3cba099 --- /dev/null +++ b/core/modules/layout_builder/src/Section.php @@ -0,0 +1,162 @@ +<?php + +namespace Drupal\layout_builder; + +/** + * Provides a domain object for layout sections. + * + * A section is a multi-dimensional array, keyed first by region machine name, + * then by block UUID, containing block configuration values. + */ +class Section { + + /** + * The section data. + * + * @var array + */ + protected $section; + + /** + * Constructs a new Section. + * + * @param array $section + * The section data. + */ + public function __construct(array $section) { + $this->section = $section; + } + + /** + * Returns the value of the section. + * + * @return array + * The section data. + */ + public function getValue() { + return $this->section; + } + + /** + * Gets the configuration of a given block from a region. + * + * @param string $region + * The region name. + * @param string $uuid + * The UUID of the block to retrieve. + * + * @return array + * The block configuration. + * + * @throws \InvalidArgumentException + * Thrown when the expected region or UUID do not exist. + */ + public function getBlock($region, $uuid) { + if (!isset($this->section[$region])) { + throw new \InvalidArgumentException('Invalid region'); + } + + if (!isset($this->section[$region][$uuid])) { + throw new \InvalidArgumentException('Invalid UUID'); + } + + return $this->section[$region][$uuid]; + } + + /** + * Updates the configuration of a given block from a region. + * + * @param string $region + * The region name. + * @param string $uuid + * The UUID of the block to retrieve. + * @param array $configuration + * The block configuration. + * + * @return $this + * + * @throws \InvalidArgumentException + * Thrown when the expected region or UUID do not exist. + */ + public function updateBlock($region, $uuid, array $configuration) { + if (!isset($this->section[$region])) { + throw new \InvalidArgumentException('Invalid region'); + } + + if (!isset($this->section[$region][$uuid])) { + throw new \InvalidArgumentException('Invalid UUID'); + } + + $this->section[$region][$uuid] = $configuration; + + return $this; + } + + /** + * Removes a given block from a region. + * + * @param string $region + * The region name. + * @param string $uuid + * The UUID of the block to remove. + * + * @return $this + */ + public function removeBlock($region, $uuid) { + unset($this->section[$region][$uuid]); + $this->section = array_filter($this->section); + return $this; + } + + /** + * Adds a block to the front of a region. + * + * @param string $region + * The region name. + * @param string $uuid + * The UUID of the block to add. + * @param array $configuration + * The block configuration. + * + * @return $this + */ + public function addBlock($region, $uuid, array $configuration) { + $this->section += [$region => []]; + $this->section[$region] = array_merge([$uuid => $configuration], $this->section[$region]); + return $this; + } + + /** + * Inserts a block after a specified existing block in a region. + * + * @param string $region + * The region name. + * @param string $uuid + * The UUID of the block to insert. + * @param array $configuration + * The block configuration. + * @param string $preceding_uuid + * The UUID of the existing block to insert after. + * + * @return $this + * + * @throws \InvalidArgumentException + * Thrown when the expected region does not exist. + */ + public function insertBlock($region, $uuid, array $configuration, $preceding_uuid) { + if (!isset($this->section[$region])) { + throw new \InvalidArgumentException('Invalid region'); + } + + $slice_id = array_search($preceding_uuid, array_keys($this->section[$region])); + if ($slice_id === FALSE) { + throw new \InvalidArgumentException('Invalid preceding UUID'); + } + + $before = array_slice($this->section[$region], 0, $slice_id + 1); + $after = array_slice($this->section[$region], $slice_id + 1); + $this->section[$region] = array_merge($before, [$uuid => $configuration], $after); + return $this; + } + +} diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php b/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..61e481c7c7c8d94be39a51309f002e4942cd70a0 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php @@ -0,0 +1,352 @@ +<?php + +namespace Drupal\Tests\layout_builder\Functional; + +use Drupal\Core\Entity\Entity\EntityViewDisplay; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests the rendering of a layout section field. + * + * @group layout_builder + */ +class LayoutSectionTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['layout_builder', 'node', 'block_test']; + + /** + * The name of the layout section field. + * + * @var string + */ + protected $fieldName = 'layout_builder__layout'; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->createContentType([ + 'type' => 'bundle_with_section_field', + ]); + $this->createContentType([ + 'type' => 'bundle_without_section_field', + ]); + + layout_builder_add_layout_section_field('node', 'bundle_with_section_field'); + $display = EntityViewDisplay::load('node.bundle_with_section_field.default'); + $display->setThirdPartySetting('layout_builder', 'allow_custom', TRUE); + $display->save(); + + $this->drupalLogin($this->drupalCreateUser([ + 'configure any layout', + ], 'foobar')); + } + + /** + * Provides test data for ::testLayoutSectionFormatter(). + */ + public function providerTestLayoutSectionFormatter() { + $data = []; + $data['block_with_context'] = [ + [ + [ + 'layout' => 'layout_onecol', + 'section' => [ + 'content' => [ + 'baz' => [ + 'block' => [ + 'id' => 'test_context_aware', + 'context_mapping' => [ + 'user' => '@user.current_user_context:current_user', + ], + ], + ], + ], + ], + ], + ], + [ + '.layout--onecol', + '#test_context_aware--username', + ], + [ + 'foobar', + 'User context found', + ], + 'user', + 'user:2', + 'UNCACHEABLE', + ]; + $data['single_section_single_block'] = [ + [ + [ + 'layout' => 'layout_onecol', + 'section' => [ + 'content' => [ + 'baz' => [ + 'block' => [ + 'id' => 'system_powered_by_block', + ], + ], + ], + ], + ], + ], + '.layout--onecol', + 'Powered by', + '', + '', + 'MISS', + ]; + $data['multiple_sections'] = [ + [ + [ + 'layout' => 'layout_onecol', + 'section' => [ + 'content' => [ + 'baz' => [ + 'block' => [ + 'id' => 'system_powered_by_block', + ], + ], + ], + ], + ], + [ + 'layout' => 'layout_twocol', + 'section' => [ + 'first' => [ + 'foo' => [ + 'block' => [ + 'id' => 'test_block_instantiation', + 'display_message' => 'foo text', + ], + ], + ], + 'second' => [ + 'bar' => [ + 'block' => [ + 'id' => 'test_block_instantiation', + 'display_message' => 'bar text', + ], + ], + ], + ], + ], + ], + [ + '.layout--onecol', + '.layout--twocol', + ], + [ + 'Powered by', + 'foo text', + 'bar text', + ], + 'user.permissions', + '', + 'MISS', + ]; + return $data; + } + + /** + * Tests layout_section formatter output. + * + * @dataProvider providerTestLayoutSectionFormatter + */ + public function testLayoutSectionFormatter($layout_data, $expected_selector, $expected_content, $expected_cache_contexts, $expected_cache_tags, $expected_dynamic_cache) { + $node = $this->createSectionNode($layout_data); + + $this->drupalGet($node->toUrl('canonical')); + $this->assertLayoutSection($expected_selector, $expected_content, $expected_cache_contexts, $expected_cache_tags, $expected_dynamic_cache); + + $this->drupalGet($node->toUrl('layout-builder')); + $this->assertLayoutSection($expected_selector, $expected_content, $expected_cache_contexts, $expected_cache_tags, 'UNCACHEABLE'); + } + + /** + * Tests the access checking of the section formatter. + */ + public function testLayoutSectionFormatterAccess() { + $node = $this->createSectionNode([ + [ + 'layout' => 'layout_onecol', + 'section' => [ + 'content' => [ + 'baz' => [ + 'block' => [ + 'id' => 'test_access', + ], + ], + ], + ], + ], + ]); + + // Restrict access to the block. + $this->container->get('state')->set('test_block_access', FALSE); + + $this->drupalGet($node->toUrl('canonical')); + $this->assertLayoutSection('.layout--onecol', NULL, '', '', 'UNCACHEABLE'); + // Ensure the block was not rendered. + $this->assertSession()->pageTextNotContains('Hello test world'); + + // Grant access to the block, and ensure it was rendered. + $this->container->get('state')->set('test_block_access', TRUE); + $this->drupalGet($node->toUrl('canonical')); + $this->assertLayoutSection('.layout--onecol', 'Hello test world', '', '', 'UNCACHEABLE'); + } + + /** + * Tests the multilingual support of the section formatter. + */ + public function testMultilingualLayoutSectionFormatter() { + $this->container->get('module_installer')->install(['content_translation']); + $this->rebuildContainer(); + + ConfigurableLanguage::createFromLangcode('es')->save(); + $this->container->get('content_translation.manager')->setEnabled('node', 'bundle_with_section_field', TRUE); + + $entity = $this->createSectionNode([ + [ + 'layout' => 'layout_onecol', + 'section' => [ + 'content' => [ + 'baz' => [ + 'block' => [ + 'id' => 'system_powered_by_block', + ], + ], + ], + ], + ], + ]); + $entity->addTranslation('es', [ + 'title' => 'Translated node title', + $this->fieldName => [ + [ + 'layout' => 'layout_twocol', + 'section' => [ + 'first' => [ + 'foo' => [ + 'block' => [ + 'id' => 'test_block_instantiation', + 'display_message' => 'foo text', + ], + ], + ], + 'second' => [ + 'bar' => [ + 'block' => [ + 'id' => 'test_block_instantiation', + 'display_message' => 'bar text', + ], + ], + ], + ], + ], + ], + ]); + $entity->save(); + + $this->drupalGet($entity->toUrl('canonical')); + $this->assertLayoutSection('.layout--onecol', 'Powered by'); + $this->drupalGet($entity->toUrl('canonical')->setOption('prefix', 'es/')); + $this->assertLayoutSection('.layout--twocol', ['foo text', 'bar text']); + } + + /** + * Ensures that the entity title is displayed. + */ + public function testLayoutPageTitle() { + $this->drupalPlaceBlock('page_title_block'); + $node = $this->createSectionNode([]); + + $this->drupalGet($node->toUrl('layout-builder')); + $this->assertSession()->titleEquals('Edit layout for The node title | Drupal'); + $this->assertEquals('Edit layout for The node title', $this->cssSelect('h1.page-title')[0]->getText()); + } + + /** + * Tests that no Layout link shows without a section field. + */ + public function testLayoutUrlNoSectionField() { + $node = $this->createNode([ + 'type' => 'bundle_without_section_field', + 'title' => 'The node title', + 'body' => [ + [ + 'value' => 'The node body', + ], + ], + ]); + $node->save(); + $this->drupalGet($node->toUrl('layout-builder')); + $this->assertSession()->statusCodeEquals(403); + } + + /** + * Asserts the output of a layout section. + * + * @param string|array $expected_selector + * A selector or list of CSS selectors to find. + * @param string|array $expected_content + * A string or list of strings to find. + * @param string $expected_cache_contexts + * A string of cache contexts to be found in the header. + * @param string $expected_cache_tags + * A string of cache tags to be found in the header. + * @param string $expected_dynamic_cache + * The expected dynamic cache header. Either 'HIT', 'MISS' or 'UNCACHEABLE'. + */ + protected function assertLayoutSection($expected_selector, $expected_content, $expected_cache_contexts = '', $expected_cache_tags = '', $expected_dynamic_cache = 'MISS') { + $assert_session = $this->assertSession(); + // Find the given selector. + foreach ((array) $expected_selector as $selector) { + $element = $this->cssSelect($selector); + $this->assertNotEmpty($element); + } + + // Find the given content. + foreach ((array) $expected_content as $content) { + $assert_session->pageTextContains($content); + } + if ($expected_cache_contexts) { + $assert_session->responseHeaderContains('X-Drupal-Cache-Contexts', $expected_cache_contexts); + } + if ($expected_cache_tags) { + $assert_session->responseHeaderContains('X-Drupal-Cache-Tags', $expected_cache_tags); + } + $assert_session->responseHeaderEquals('X-Drupal-Dynamic-Cache', $expected_dynamic_cache); + } + + /** + * Creates a node with a section field. + * + * @param array $section_values + * An array of values for a section field. + * + * @return \Drupal\node\NodeInterface + * The node object. + */ + protected function createSectionNode(array $section_values) { + return $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The node title', + 'body' => [ + [ + 'value' => 'The node body', + ], + ], + $this->fieldName => $section_values, + ]); + } + +} diff --git a/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderFieldLayoutCompatibilityTest.php b/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderFieldLayoutCompatibilityTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c3a23c78710fb271ef18d1682720e0db39d89ad1 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderFieldLayoutCompatibilityTest.php @@ -0,0 +1,157 @@ +<?php + +namespace Drupal\Tests\layout_builder\Kernel; + +use Drupal\Core\Entity\Entity\EntityViewDisplay; +use Drupal\Core\Entity\EntityInterface; +use Drupal\entity_test\Entity\EntityTestBaseFieldDisplay; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\KernelTests\KernelTestBase; + +/** + * Ensures that Layout Builder and Field Layout are compatible with each other. + * + * @group layout_builder + */ +class LayoutBuilderFieldLayoutCompatibilityTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = [ + 'layout_discovery', + 'field_layout', + 'user', + 'field', + 'entity_test', + 'system', + 'text', + 'filter', + ]; + + /** + * The entity view display. + * + * @var \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface + */ + protected $display; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->installEntitySchema('entity_test_base_field_display'); + $this->installEntitySchema('user'); + $this->installSchema('system', ['sequences', 'key_value']); + $this->installConfig(['field', 'filter', 'user', 'system']); + + \Drupal::service('theme_handler')->install(['classy']); + $this->config('system.theme')->set('default', 'classy')->save(); + + $field_storage = FieldStorageConfig::create([ + 'entity_type' => 'entity_test_base_field_display', + 'field_name' => 'test_field_display_configurable', + 'type' => 'boolean', + ]); + $field_storage->save(); + FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => 'entity_test_base_field_display', + 'label' => 'FieldConfig with configurable display', + ])->save(); + + $this->display = EntityViewDisplay::create([ + 'targetEntityType' => 'entity_test_base_field_display', + 'bundle' => 'entity_test_base_field_display', + 'mode' => 'default', + 'status' => TRUE, + ]); + $this->display + ->setComponent('test_field_display_configurable', ['region' => 'content']) + ->setLayoutId('layout_twocol') + ->save(); + } + + /** + * Tests the compatibility of Layout Builder and Field Layout. + */ + public function testCompatibility() { + // Create an entity with fields that are configurable and non-configurable. + $entity_storage = $this->container->get('entity_type.manager')->getStorage('entity_test_base_field_display'); + // @todo Remove langcode workarounds after resolving + // https://www.drupal.org/node/2915034. + $entity = $entity_storage->createWithSampleValues('entity_test_base_field_display', [ + 'langcode' => 'en', + 'langcode_default' => TRUE, + ]); + $entity->save(); + + // Ensure that the configurable field is shown in the correct region and + // that the non-configurable field is shown outside the layout. + $original_markup = $this->renderEntity($entity); + $this->assertNotEmpty($this->cssSelect('.layout__region--first .field--name-test-display-configurable')); + $this->assertNotEmpty($this->cssSelect('.layout__region--first .field--name-test-field-display-configurable')); + $this->assertNotEmpty($this->cssSelect('.field--name-test-display-non-configurable')); + $this->assertEmpty($this->cssSelect('.layout__region .field--name-test-display-non-configurable')); + + // Install the Layout Builder, configure it for this entity display, and + // reload the entity. + $this->enableModules(['layout_builder']); + $this->display->setThirdPartySetting('layout_builder', 'allow_custom', TRUE)->save(); + $entity = EntityTestBaseFieldDisplay::load($entity->id()); + + // Without using Layout Builder for an override, the result has not changed. + $new_markup = $this->renderEntity($entity); + $this->assertSame($original_markup, $new_markup); + + // Add a layout override. + /** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */ + $field_list = $entity->layout_builder__layout; + $field_list->appendItem([ + 'layout' => 'layout_onecol', + 'layout_settings' => [], + 'section' => [], + ]); + $entity->save(); + + // The rendered entity has now changed. The non-configurable field is shown + // outside the layout, the configurable field is not shown at all, and the + // layout itself is rendered (but empty). + $new_markup = $this->renderEntity($entity); + $this->assertNotSame($original_markup, $new_markup); + $this->assertEmpty($this->cssSelect('.field--name-test-display-configurable')); + $this->assertEmpty($this->cssSelect('.field--name-test-field-display-configurable')); + $this->assertNotEmpty($this->cssSelect('.field--name-test-display-non-configurable')); + $this->assertNotEmpty($this->cssSelect('.layout--onecol')); + + // Removing the layout restores the original rendering of the entity. + $field_list->removeItem(0); + $entity->save(); + $new_markup = $this->renderEntity($entity); + $this->assertSame($original_markup, $new_markup); + } + + /** + * Renders the provided entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to render. + * @param string $view_mode + * (optional) The view mode that should be used to render the entity. + * @param string $langcode + * (optional) For which language the entity should be rendered, defaults to + * the current content language. + * + * @return string + * The rendered string output (typically HTML). + */ + protected function renderEntity(EntityInterface $entity, $view_mode = 'full', $langcode = NULL) { + $view_builder = $this->container->get('entity_type.manager')->getViewBuilder($entity->getEntityTypeId()); + $build = $view_builder->view($entity, $view_mode, $langcode); + return $this->render($build); + } + +} diff --git a/core/modules/layout_builder/tests/src/Kernel/LayoutSectionItemTest.php b/core/modules/layout_builder/tests/src/Kernel/LayoutSectionItemTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0e13471c155814143b703687a788165ceb967cef --- /dev/null +++ b/core/modules/layout_builder/tests/src/Kernel/LayoutSectionItemTest.php @@ -0,0 +1,89 @@ +<?php + +namespace Drupal\Tests\layout_builder\Kernel; + +use Drupal\Core\Field\FieldItemInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\entity_test\Entity\EntityTest; +use Drupal\layout_builder\Field\LayoutSectionItemInterface; +use Drupal\layout_builder\Field\LayoutSectionItemListInterface; +use Drupal\Tests\field\Kernel\FieldKernelTestBase; + +/** + * Tests the field type for Layout Sections. + * + * @group layout_builder + */ +class LayoutSectionItemTest extends FieldKernelTestBase { + + /** + * Modules to enable. + * + * @var array + */ + public static $modules = ['layout_builder', 'layout_discovery']; + + /** + * Tests using entity fields of the layout section field type. + */ + public function testLayoutSectionItem() { + layout_builder_add_layout_section_field('entity_test', 'entity_test'); + + $entity = EntityTest::create(); + /** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */ + $field_list = $entity->layout_builder__layout; + + // Test sample item generation. + $field_list->generateSampleItems(); + $this->entityValidateAndSave($entity); + + $field = $field_list->get(0); + $this->assertInstanceOf(LayoutSectionItemInterface::class, $field); + $this->assertInstanceOf(FieldItemInterface::class, $field); + $this->assertSame('section', $field->mainPropertyName()); + $this->assertSame('layout_onecol', $field->layout); + $this->assertSame([], $field->layout_settings); + $this->assertSame([], $field->section); + } + + /** + * {@inheritdoc} + */ + public function testLayoutSectionItemList() { + layout_builder_add_layout_section_field('entity_test', 'entity_test'); + + $entity = EntityTest::create(); + /** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */ + $field_list = $entity->layout_builder__layout; + $this->assertInstanceOf(LayoutSectionItemListInterface::class, $field_list); + $this->assertInstanceOf(FieldItemListInterface::class, $field_list); + $entity->save(); + + $field_list->appendItem(['layout' => 'layout_twocol']); + $field_list->appendItem(['layout' => 'layout_onecol']); + $field_list->appendItem(['layout' => 'layout_threecol_25_50_25']); + $this->assertSame([ + ['layout' => 'layout_twocol'], + ['layout' => 'layout_onecol'], + ['layout' => 'layout_threecol_25_50_25'], + ], $field_list->getValue()); + + $field_list->addItem(1, ['layout' => 'layout_threecol_33_34_33']); + $this->assertSame([ + ['layout' => 'layout_twocol'], + ['layout' => 'layout_threecol_33_34_33'], + ['layout' => 'layout_onecol'], + ['layout' => 'layout_threecol_25_50_25'], + ], $field_list->getValue()); + + $field_list->addItem($field_list->count(), ['layout' => 'layout_twocol_bricks']); + $this->assertSame([ + ['layout' => 'layout_twocol'], + ['layout' => 'layout_threecol_33_34_33'], + ['layout' => 'layout_onecol'], + ['layout' => 'layout_threecol_25_50_25'], + ['layout' => 'layout_twocol_bricks'], + ], $field_list->getValue()); + } + +} diff --git a/core/modules/layout_builder/tests/src/Unit/LayoutBuilderRouteEnhancerTest.php b/core/modules/layout_builder/tests/src/Unit/LayoutBuilderRouteEnhancerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2904480d024e894e5fcf2eb5e0bd4de4f1a9d522 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/LayoutBuilderRouteEnhancerTest.php @@ -0,0 +1,146 @@ +<?php + +namespace Drupal\Tests\layout_builder\Unit; + +use Drupal\layout_builder\Routing\LayoutBuilderRouteEnhancer; +use Drupal\Tests\UnitTestCase; +use Symfony\Cmf\Component\Routing\RouteObjectInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Route; + +/** + * @coversDefaultClass \Drupal\layout_builder\Routing\LayoutBuilderRouteEnhancer + * @group layout_builder + */ +class LayoutBuilderRouteEnhancerTest extends UnitTestCase { + + /** + * @covers ::applies + * @dataProvider providerTestApplies + */ + public function testApplies($defaults, $options, $expected) { + $route_enhancer = new LayoutBuilderRouteEnhancer(); + $route = new Route('/some/path', $defaults, [], $options); + + $reflection_method = new \ReflectionMethod($route_enhancer, 'applies'); + $reflection_method->setAccessible(TRUE); + $result = $reflection_method->invoke($route_enhancer, $route); + $this->assertSame($expected, $result); + } + + /** + * Provides test data for ::testApplies(). + */ + public function providerTestApplies() { + $data = []; + $data['layout_builder_true'] = [ + ['entity_type_id' => 'the_entity_type'], + ['_layout_builder' => TRUE], + TRUE, + ]; + $data['layout_builder_false'] = [ + ['entity_type_id' => 'the_entity_type'], + ['_layout_builder' => FALSE], + FALSE, + ]; + $data['layout_builder_null'] = [ + ['entity_type_id' => 'the_entity_type'], + ['_layout_builder' => NULL], + FALSE, + ]; + $data['entity_type_id_empty'] = [ + ['entity_type_id' => ''], + ['_layout_builder' => TRUE], + FALSE, + ]; + $data['no_entity_type_id'] = [ + [], + ['_layout_builder' => TRUE], + FALSE, + ]; + $data['no_layout_builder'] = [ + ['entity_type_id' => 'the_entity_type'], + [], + FALSE, + ]; + $data['empty'] = [ + [], + [], + FALSE, + ]; + return $data; + } + + /** + * @covers ::enhance + */ + public function testEnhanceValidDefaults() { + $route = new Route('/the/path', ['entity_type_id' => 'the_entity_type'], [], ['_layout_builder' => TRUE]); + $route_enhancer = new LayoutBuilderRouteEnhancer(); + $object = new \stdClass(); + $defaults = [ + 'entity_type_id' => 'the_entity_type', + 'the_entity_type' => $object, + RouteObjectInterface::ROUTE_NAME => 'the_route_name', + RouteObjectInterface::ROUTE_OBJECT => $route, + ]; + // Ensure that the 'entity' key now contains the value stored for a given + // entity type. + $expected = [ + 'entity_type_id' => 'the_entity_type', + 'the_entity_type' => $object, + RouteObjectInterface::ROUTE_NAME => 'the_route_name', + RouteObjectInterface::ROUTE_OBJECT => $route, + 'entity' => $object, + 'is_rebuilding' => TRUE, + ]; + $result = $route_enhancer->enhance($defaults, new Request(['layout_is_rebuilding' => TRUE])); + $this->assertEquals($expected, $result); + + $expected['is_rebuilding'] = FALSE; + $result = $route_enhancer->enhance($defaults, new Request()); + $this->assertEquals($expected, $result); + $this->assertSame($object, $result['entity']); + + // Modifying the original value updates the 'entity' copy. + $result['the_entity_type'] = 'something else'; + $this->assertSame('something else', $result['entity']); + } + + /** + * @covers ::enhance + */ + public function testEnhanceMissingEntity() { + $route_enhancer = new LayoutBuilderRouteEnhancer(); + $route = new Route('/the/path', ['entity_type_id' => 'the_entity_type'], [], ['_layout_builder' => TRUE]); + $defaults = [ + RouteObjectInterface::ROUTE_NAME => 'the_route', + RouteObjectInterface::ROUTE_OBJECT => $route, + 'entity_type_id' => 'the_entity_type', + ]; + $this->setExpectedException(\RuntimeException::class, 'Failed to find the "the_entity_type" entity in route named the_route'); + $route_enhancer->enhance($defaults, new Request()); + } + + /** + * Provides test data for ::testEnhanceException(). + */ + public function providerTestEnhanceException() { + $data = []; + $data['missing_entity'] = [ + [ + RouteObjectInterface::ROUTE_NAME => 'the_route', + 'entity_type_id' => 'the_entity_type', + ], + 'Failed to find the "the_entity_type" entity in route named the_route', + ]; + $data['missing_entity_type_id'] = [ + [ + RouteObjectInterface::ROUTE_NAME => 'the_route', + ], + 'Failed to find an entity type ID in route named the_route', + ]; + return $data; + } + +} diff --git a/core/modules/layout_builder/tests/src/Unit/LayoutSectionBuilderTest.php b/core/modules/layout_builder/tests/src/Unit/LayoutSectionBuilderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3e5b2ffad148a47c1ff54354b0befdbb698d0d7b --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/LayoutSectionBuilderTest.php @@ -0,0 +1,301 @@ +<?php + +namespace Drupal\Tests\layout_builder\Unit; + +use Drupal\Component\Plugin\Exception\PluginException; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Block\BlockManagerInterface; +use Drupal\Core\Block\BlockPluginInterface; +use Drupal\Core\Cache\Cache; +use Drupal\Core\Layout\LayoutInterface; +use Drupal\Core\Layout\LayoutPluginManagerInterface; +use Drupal\Core\Plugin\Context\ContextHandlerInterface; +use Drupal\Core\Plugin\Context\ContextRepositoryInterface; +use Drupal\Core\Plugin\ContextAwarePluginInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\layout_builder\LayoutSectionBuilder; +use Drupal\Tests\UnitTestCase; +use Prophecy\Argument; + +/** + * @coversDefaultClass \Drupal\layout_builder\LayoutSectionBuilder + * @group layout_builder + */ +class LayoutSectionBuilderTest extends UnitTestCase { + + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected $account; + + /** + * The layout plugin manager. + * + * @var \Drupal\Core\Layout\LayoutPluginManagerInterface + */ + protected $layoutPluginManager; + + /** + * The block plugin manager. + * + * @var \Drupal\Core\Block\BlockManagerInterface + */ + protected $blockManager; + + /** + * The plugin context handler. + * + * @var \Drupal\Core\Plugin\Context\ContextHandlerInterface + */ + protected $contextHandler; + + /** + * The context manager service. + * + * @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface + */ + protected $contextRepository; + + /** + * The object under test. + * + * @var \Drupal\layout_builder\LayoutSectionBuilder + */ + protected $layoutSectionBuilder; + + /** + * The layout plugin. + * + * @var \Drupal\Core\Layout\LayoutInterface + */ + protected $layout; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->account = $this->prophesize(AccountInterface::class); + $this->layoutPluginManager = $this->prophesize(LayoutPluginManagerInterface::class); + $this->blockManager = $this->prophesize(BlockManagerInterface::class); + $this->contextHandler = $this->prophesize(ContextHandlerInterface::class); + $this->contextRepository = $this->prophesize(ContextRepositoryInterface::class); + $this->layoutSectionBuilder = new LayoutSectionBuilder($this->account->reveal(), $this->layoutPluginManager->reveal(), $this->blockManager->reveal(), $this->contextHandler->reveal(), $this->contextRepository->reveal()); + + $this->layout = $this->prophesize(LayoutInterface::class); + $this->layoutPluginManager->createInstance('layout_onecol', [])->willReturn($this->layout->reveal()); + } + + /** + * @covers ::buildSection + */ + public function testBuildSection() { + $block_content = ['#markup' => 'The block content.']; + $render_array = [ + '#theme' => 'block', + '#weight' => 0, + '#configuration' => [], + '#plugin_id' => 'block_plugin_id', + '#base_plugin_id' => 'block_plugin_id', + '#derivative_plugin_id' => NULL, + 'content' => $block_content, + ]; + $this->layout->build(['content' => ['some_uuid' => $render_array]])->willReturnArgument(0); + + $block = $this->prophesize(BlockPluginInterface::class); + $this->blockManager->createInstance('block_plugin_id', ['id' => 'block_plugin_id'])->willReturn($block->reveal()); + + $access_result = AccessResult::allowed(); + $block->access($this->account->reveal(), TRUE)->willReturn($access_result); + $block->build()->willReturn($block_content); + $block->getCacheContexts()->willReturn([]); + $block->getCacheTags()->willReturn([]); + $block->getCacheMaxAge()->willReturn(Cache::PERMANENT); + $block->getPluginId()->willReturn('block_plugin_id'); + $block->getBaseId()->willReturn('block_plugin_id'); + $block->getDerivativeId()->willReturn(NULL); + $block->getConfiguration()->willReturn([]); + + $section = [ + 'content' => [ + 'some_uuid' => [ + 'block' => [ + 'id' => 'block_plugin_id', + ], + ], + ], + ]; + $expected = [ + '#cache' => [ + 'contexts' => [], + 'tags' => [], + 'max-age' => -1, + ], + 'content' => [ + 'some_uuid' => $render_array, + ], + ]; + $result = $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section); + $this->assertEquals($expected, $result); + } + + /** + * @covers ::buildSection + */ + public function testBuildSectionAccessDenied() { + $this->layout->build([])->willReturn([]); + + $block = $this->prophesize(BlockPluginInterface::class); + $this->blockManager->createInstance('block_plugin_id', ['id' => 'block_plugin_id'])->willReturn($block->reveal()); + + $access_result = AccessResult::forbidden(); + $block->access($this->account->reveal(), TRUE)->willReturn($access_result); + $block->build()->shouldNotBeCalled(); + + $section = [ + 'content' => [ + 'some_uuid' => [ + 'block' => [ + 'id' => 'block_plugin_id', + ], + ], + ], + ]; + $expected = [ + '#cache' => [ + 'contexts' => [], + 'tags' => [], + 'max-age' => -1, + ], + ]; + $result = $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section); + $this->assertEquals($expected, $result); + } + + /** + * @covers ::buildSection + */ + public function testBuildSectionEmpty() { + $this->layout->build([])->willReturn([]); + + $section = []; + $expected = [ + '#cache' => [ + 'contexts' => [], + 'tags' => [], + 'max-age' => -1, + ], + ]; + $result = $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section); + $this->assertEquals($expected, $result); + } + + /** + * @covers ::buildSection + * @covers ::getBlock + */ + public function testContextAwareBlock() { + $render_array = [ + '#theme' => 'block', + '#weight' => 0, + '#configuration' => [], + '#plugin_id' => 'block_plugin_id', + '#base_plugin_id' => 'block_plugin_id', + '#derivative_plugin_id' => NULL, + 'content' => [], + ]; + $this->layout->build(['content' => ['some_uuid' => $render_array]])->willReturnArgument(0); + + $block = $this->prophesize(BlockPluginInterface::class)->willImplement(ContextAwarePluginInterface::class); + $this->blockManager->createInstance('block_plugin_id', ['id' => 'block_plugin_id'])->willReturn($block->reveal()); + + $access_result = AccessResult::allowed(); + $block->access($this->account->reveal(), TRUE)->willReturn($access_result); + $block->build()->willReturn([]); + $block->getCacheContexts()->willReturn([]); + $block->getCacheTags()->willReturn([]); + $block->getCacheMaxAge()->willReturn(Cache::PERMANENT); + $block->getContextMapping()->willReturn([]); + $block->getPluginId()->willReturn('block_plugin_id'); + $block->getBaseId()->willReturn('block_plugin_id'); + $block->getDerivativeId()->willReturn(NULL); + $block->getConfiguration()->willReturn([]); + + $this->contextRepository->getRuntimeContexts([])->willReturn([]); + $this->contextHandler->applyContextMapping($block->reveal(), [])->shouldBeCalled(); + + $section = [ + 'content' => [ + 'some_uuid' => [ + 'block' => [ + 'id' => 'block_plugin_id', + ], + ], + ], + ]; + $expected = [ + '#cache' => [ + 'contexts' => [], + 'tags' => [], + 'max-age' => -1, + ], + 'content' => [ + 'some_uuid' => $render_array, + ], + ]; + $result = $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section); + $this->assertEquals($expected, $result); + } + + /** + * @covers ::buildSection + * @covers ::getBlock + */ + public function testBuildSectionMissingPluginId() { + $section = [ + 'content' => [ + 'some_uuid' => [ + 'block' => [], + ], + ], + ]; + $this->setExpectedException(PluginException::class, 'No plugin ID specified for block with "some_uuid" UUID'); + $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section); + } + + /** + * @covers ::buildSection + * + * @dataProvider providerTestBuildSectionMalformedData + */ + public function testBuildSectionMalformedData($section, $message) { + $this->layout->build(Argument::type('array'))->willReturnArgument(0); + $this->layout->getPluginId()->willReturn('the_plugin_id'); + $this->setExpectedException(\InvalidArgumentException::class, $message); + $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section); + } + + /** + * Provides test data for ::testBuildSectionMalformedData(). + */ + public function providerTestBuildSectionMalformedData() { + $data = []; + $data['invalid_region'] = [ + ['content' => 'bar'], + 'The "content" region in the "the_plugin_id" layout has invalid configuration', + ]; + $data['invalid_configuration'] = [ + ['content' => ['some_uuid' => 'bar']], + 'The block with UUID of "some_uuid" has invalid configuration', + ]; + $data['invalid_blocks'] = [ + ['content' => ['some_uuid' => []]], + 'The block with UUID of "some_uuid" has invalid configuration', + ]; + return $data; + } + +} diff --git a/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php b/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a652d4a56d5ce8fb0a72cf252b2fc888dcbb2ff0 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php @@ -0,0 +1,133 @@ +<?php + +namespace Drupal\Tests\layout_builder\Unit; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Language\Language; +use Drupal\layout_builder\LayoutTempstoreRepository; +use Drupal\Tests\UnitTestCase; +use Drupal\user\SharedTempStore; +use Drupal\user\SharedTempStoreFactory; + +/** + * @coversDefaultClass \Drupal\layout_builder\LayoutTempstoreRepository + * @group layout_builder + */ +class LayoutTempstoreRepositoryTest extends UnitTestCase { + + /** + * @covers ::getFromId + * @covers ::get + * @covers ::generateTempstoreId + */ + public function testGetFromIdEmptyTempstore() { + $tempstore = $this->prophesize(SharedTempStore::class); + $tempstore->get('the_entity_id.en')->shouldBeCalled(); + + $tempstore_factory = $this->prophesize(SharedTempStoreFactory::class); + $tempstore_factory->get('the_entity_type_id.layout_builder__layout')->willReturn($tempstore->reveal()); + + $entity = $this->prophesize(EntityInterface::class); + $entity->getEntityTypeId()->willReturn('the_entity_type_id'); + $entity->id()->willReturn('the_entity_id'); + $entity->language()->willReturn(new Language(['id' => 'en'])); + + $entity_storage = $this->prophesize(EntityStorageInterface::class); + $entity_storage->loadRevision('the_entity_id')->willReturn($entity->reveal()); + + $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + $entity_type_manager->getStorage('the_entity_type_id')->willReturn($entity_storage->reveal()); + + $repository = new LayoutTempstoreRepository($tempstore_factory->reveal(), $entity_type_manager->reveal()); + + $result = $repository->getFromId('the_entity_type_id', 'the_entity_id'); + $this->assertSame($entity->reveal(), $result); + } + + /** + * @covers ::getFromId + * @covers ::get + * @covers ::generateTempstoreId + */ + public function testGetFromIdLoadedTempstore() { + $tempstore_entity = $this->prophesize(EntityInterface::class); + $tempstore = $this->prophesize(SharedTempStore::class); + $tempstore->get('the_entity_id.en')->willReturn(['entity' => $tempstore_entity->reveal()]); + $tempstore_factory = $this->prophesize(SharedTempStoreFactory::class); + $tempstore_factory->get('the_entity_type_id.layout_builder__layout')->willReturn($tempstore->reveal()); + + $entity = $this->prophesize(EntityInterface::class); + $entity->getEntityTypeId()->willReturn('the_entity_type_id'); + $entity->id()->willReturn('the_entity_id'); + $entity->language()->willReturn(new Language(['id' => 'en'])); + + $entity_storage = $this->prophesize(EntityStorageInterface::class); + $entity_storage->loadRevision('the_entity_id')->willReturn($entity->reveal()); + + $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + $entity_type_manager->getStorage('the_entity_type_id')->willReturn($entity_storage->reveal()); + + $repository = new LayoutTempstoreRepository($tempstore_factory->reveal(), $entity_type_manager->reveal()); + + $result = $repository->getFromId('the_entity_type_id', 'the_entity_id'); + $this->assertSame($tempstore_entity->reveal(), $result); + $this->assertNotSame($entity->reveal(), $result); + } + + /** + * @covers ::getFromId + * @covers ::get + * @covers ::generateTempstoreId + */ + public function testGetFromIdRevisionable() { + $tempstore = $this->prophesize(SharedTempStore::class); + $tempstore->get('the_entity_id.en.the_revision_id')->shouldBeCalled(); + + $tempstore_factory = $this->prophesize(SharedTempStoreFactory::class); + $tempstore_factory->get('the_entity_type_id.layout_builder__layout')->willReturn($tempstore->reveal()); + + $entity = $this->prophesize(EntityInterface::class)->willImplement(RevisionableInterface::class); + $entity->getEntityTypeId()->willReturn('the_entity_type_id'); + $entity->id()->willReturn('the_entity_id'); + $entity->language()->willReturn(new Language(['id' => 'en'])); + $entity->getRevisionId()->willReturn('the_revision_id'); + + $entity_storage = $this->prophesize(EntityStorageInterface::class); + $entity_storage->loadRevision('the_entity_id')->willReturn($entity->reveal()); + + $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + $entity_type_manager->getStorage('the_entity_type_id')->willReturn($entity_storage->reveal()); + + $repository = new LayoutTempstoreRepository($tempstore_factory->reveal(), $entity_type_manager->reveal()); + + $result = $repository->getFromId('the_entity_type_id', 'the_entity_id'); + $this->assertSame($entity->reveal(), $result); + } + + /** + * @covers ::get + */ + public function testGetInvalidEntity() { + $tempstore = $this->prophesize(SharedTempStore::class); + $tempstore->get('the_entity_id.en')->willReturn(['entity' => 'this_is_not_an_entity']); + + $tempstore_factory = $this->prophesize(SharedTempStoreFactory::class); + $tempstore_factory->get('the_entity_type_id.layout_builder__layout')->willReturn($tempstore->reveal()); + + $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + + $repository = new LayoutTempstoreRepository($tempstore_factory->reveal(), $entity_type_manager->reveal()); + + $entity = $this->prophesize(EntityInterface::class); + $entity->language()->willReturn(new Language(['id' => 'en'])); + $entity->getEntityTypeId()->willReturn('the_entity_type_id'); + $entity->id()->willReturn('the_entity_id'); + + $this->setExpectedException(\UnexpectedValueException::class, 'The entry with entity type "the_entity_type_id" and ID "the_entity_id.en" is not a valid entity'); + $repository->get($entity->reveal()); + } + +} diff --git a/core/modules/layout_builder/tests/src/Unit/SectionTest.php b/core/modules/layout_builder/tests/src/Unit/SectionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..33a338706e27ec78aace18dc38969453ae03363e --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/SectionTest.php @@ -0,0 +1,265 @@ +<?php + +namespace Drupal\Tests\layout_builder\Unit; + +use Drupal\layout_builder\Section; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\layout_builder\Section + * @group layout_builder + */ +class SectionTest extends UnitTestCase { + + /** + * The section object to test. + * + * @var \Drupal\layout_builder\Section + */ + protected $section; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->section = new Section([ + 'empty-region' => [], + 'some-region' => [ + 'existing-uuid' => [ + 'block' => [ + 'id' => 'existing-block-id', + ], + ], + ], + 'ordered-region' => [ + 'first-uuid' => [ + 'block' => [ + 'id' => 'first-block-id', + ], + ], + 'second-uuid' => [ + 'block' => [ + 'id' => 'second-block-id', + ], + ], + ], + ]); + } + + /** + * @covers ::__construct + * @covers ::getValue + */ + public function testGetValue() { + $expected = [ + 'empty-region' => [], + 'some-region' => [ + 'existing-uuid' => [ + 'block' => [ + 'id' => 'existing-block-id', + ], + ], + ], + 'ordered-region' => [ + 'first-uuid' => [ + 'block' => [ + 'id' => 'first-block-id', + ], + ], + 'second-uuid' => [ + 'block' => [ + 'id' => 'second-block-id', + ], + ], + ], + ]; + $result = $this->section->getValue(); + $this->assertSame($expected, $result); + } + + /** + * @covers ::getBlock + */ + public function testGetBlockInvalidRegion() { + $this->setExpectedException(\InvalidArgumentException::class, 'Invalid region'); + $this->section->getBlock('invalid-region', 'existing-uuid'); + } + + /** + * @covers ::getBlock + */ + public function testGetBlockInvalidUuid() { + $this->setExpectedException(\InvalidArgumentException::class, 'Invalid UUID'); + $this->section->getBlock('some-region', 'invalid-uuid'); + } + + /** + * @covers ::getBlock + */ + public function testGetBlock() { + $expected = ['block' => ['id' => 'existing-block-id']]; + + $block = $this->section->getBlock('some-region', 'existing-uuid'); + $this->assertSame($expected, $block); + } + + /** + * @covers ::removeBlock + */ + public function testRemoveBlock() { + $this->section->removeBlock('some-region', 'existing-uuid'); + $expected = [ + 'ordered-region' => [ + 'first-uuid' => [ + 'block' => [ + 'id' => 'first-block-id', + ], + ], + 'second-uuid' => [ + 'block' => [ + 'id' => 'second-block-id', + ], + ], + ], + ]; + $this->assertSame($expected, $this->section->getValue()); + } + + /** + * @covers ::addBlock + */ + public function testAddBlock() { + $this->section->addBlock('some-region', 'new-uuid', []); + $expected = [ + 'empty-region' => [], + 'some-region' => [ + 'new-uuid' => [], + 'existing-uuid' => [ + 'block' => [ + 'id' => 'existing-block-id', + ], + ], + ], + 'ordered-region' => [ + 'first-uuid' => [ + 'block' => [ + 'id' => 'first-block-id', + ], + ], + 'second-uuid' => [ + 'block' => [ + 'id' => 'second-block-id', + ], + ], + ], + ]; + $this->assertSame($expected, $this->section->getValue()); + } + + /** + * @covers ::insertBlock + */ + public function testInsertBlock() { + $this->section->insertBlock('ordered-region', 'new-uuid', [], 'first-uuid'); + $expected = [ + 'empty-region' => [], + 'some-region' => [ + 'existing-uuid' => [ + 'block' => [ + 'id' => 'existing-block-id', + ], + ], + ], + 'ordered-region' => [ + 'first-uuid' => [ + 'block' => [ + 'id' => 'first-block-id', + ], + ], + 'new-uuid' => [], + 'second-uuid' => [ + 'block' => [ + 'id' => 'second-block-id', + ], + ], + ], + ]; + $this->assertSame($expected, $this->section->getValue()); + } + + /** + * @covers ::insertBlock + */ + public function testInsertBlockInvalidRegion() { + $this->setExpectedException(\InvalidArgumentException::class, 'Invalid region'); + $this->section->insertBlock('invalid-region', 'new-uuid', [], 'first-uuid'); + } + + /** + * @covers ::insertBlock + */ + public function testInsertBlockInvalidUuid() { + $this->setExpectedException(\InvalidArgumentException::class, 'Invalid preceding UUID'); + $this->section->insertBlock('ordered-region', 'new-uuid', [], 'invalid-uuid'); + } + + /** + * @covers ::updateBlock + */ + public function testUpdateBlock() { + $this->section->updateBlock('some-region', 'existing-uuid', [ + 'block' => [ + 'id' => 'existing-block-id', + 'settings' => [ + 'foo' => 'bar', + ], + ], + ]); + + $expected = [ + 'empty-region' => [], + 'some-region' => [ + 'existing-uuid' => [ + 'block' => [ + 'id' => 'existing-block-id', + 'settings' => [ + 'foo' => 'bar', + ], + ], + ], + ], + 'ordered-region' => [ + 'first-uuid' => [ + 'block' => [ + 'id' => 'first-block-id', + ], + ], + 'second-uuid' => [ + 'block' => [ + 'id' => 'second-block-id', + ], + ], + ], + ]; + $this->assertSame($expected, $this->section->getValue()); + } + + /** + * @covers ::updateBlock + */ + public function testUpdateBlockInvalidRegion() { + $this->setExpectedException(\InvalidArgumentException::class, 'Invalid region'); + $this->section->updateBlock('invalid-region', 'new-uuid', []); + } + + /** + * @covers ::updateBlock + */ + public function testUpdateBlockInvalidUuid() { + $this->setExpectedException(\InvalidArgumentException::class, 'Invalid UUID'); + $this->section->updateBlock('ordered-region', 'new-uuid', []); + } + +}