diff --git a/core/composer.json b/core/composer.json index 48e6619880..31bc51a0fa 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 0000000000..b870007e33 --- /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 0000000000..11d8c5508f --- /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 0000000000..391e462cc6 --- /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 0000000000..d4dadc8934 --- /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 0000000000..e985911464 --- /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 0000000000..8472775636 --- /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 0000000000..bcf2a9cf06 --- /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 0000000000..b003d7737c --- /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 0000000000..2192168cb0 --- /dev/null +++ b/core/modules/layout_builder/layout_builder.module @@ -0,0 +1,200 @@ +' . t('About') . ''; + $output .= '

' . t('Layout Builder provides layout building utility.') . '

'; + $output .= '

' . t('For more information, see the online documentation for the Layout Builder module.', [':layout-builder-documentation' => 'https://www.drupal.org/docs/8/core/modules/layout_builder']) . '

'; + 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 0000000000..00aac63924 --- /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 0000000000..8fe952afbe --- /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 0000000000..518d9ee942 --- /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 0000000000..e13a149238 --- /dev/null +++ b/core/modules/layout_builder/src/Access/LayoutSectionAccessCheck.php @@ -0,0 +1,68 @@ +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 0000000000..c632f4b33a --- /dev/null +++ b/core/modules/layout_builder/src/Cache/LayoutBuilderIsActiveCacheContext.php @@ -0,0 +1,87 @@ +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 0000000000..d677108238 --- /dev/null +++ b/core/modules/layout_builder/src/Controller/AddSectionController.php @@ -0,0 +1,85 @@ +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 0000000000..072eccab35 --- /dev/null +++ b/core/modules/layout_builder/src/Controller/AjaxHelperTrait.php @@ -0,0 +1,41 @@ +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 0000000000..9eddb79fc3 --- /dev/null +++ b/core/modules/layout_builder/src/Controller/ChooseBlockController.php @@ -0,0 +1,94 @@ +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 0000000000..0414d2abf1 --- /dev/null +++ b/core/modules/layout_builder/src/Controller/ChooseSectionController.php @@ -0,0 +1,105 @@ +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 0000000000..a4163a40a8 --- /dev/null +++ b/core/modules/layout_builder/src/Controller/LayoutBuilderController.php @@ -0,0 +1,319 @@ +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 0000000000..53fd4e8b0a --- /dev/null +++ b/core/modules/layout_builder/src/Controller/LayoutRebuildTrait.php @@ -0,0 +1,58 @@ +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 0000000000..d648416d9e --- /dev/null +++ b/core/modules/layout_builder/src/Controller/MoveBlockController.php @@ -0,0 +1,102 @@ +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 0000000000..786b6b4b19 --- /dev/null +++ b/core/modules/layout_builder/src/Field/LayoutSectionItemInterface.php @@ -0,0 +1,40 @@ +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 0000000000..81839d1774 --- /dev/null +++ b/core/modules/layout_builder/src/Field/LayoutSectionItemListInterface.php @@ -0,0 +1,46 @@ +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 0000000000..a5a387020f --- /dev/null +++ b/core/modules/layout_builder/src/Form/AjaxFormHelperTrait.php @@ -0,0 +1,61 @@ +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 0000000000..7356ef4ccd --- /dev/null +++ b/core/modules/layout_builder/src/Form/ConfigureBlockFormBase.php @@ -0,0 +1,282 @@ +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 0000000000..17913237d5 --- /dev/null +++ b/core/modules/layout_builder/src/Form/ConfigureSectionForm.php @@ -0,0 +1,216 @@ +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 0000000000..8bee062d4e --- /dev/null +++ b/core/modules/layout_builder/src/Form/LayoutRebuildConfirmFormBase.php @@ -0,0 +1,119 @@ +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 0000000000..139186af66 --- /dev/null +++ b/core/modules/layout_builder/src/Form/RemoveBlockForm.php @@ -0,0 +1,70 @@ +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 0000000000..e44edd6947 --- /dev/null +++ b/core/modules/layout_builder/src/Form/RemoveSectionForm.php @@ -0,0 +1,43 @@ +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 0000000000..2f2aa600e4 --- /dev/null +++ b/core/modules/layout_builder/src/Form/UpdateBlockForm.php @@ -0,0 +1,67 @@ +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 0000000000..1682974f11 --- /dev/null +++ b/core/modules/layout_builder/src/LayoutSectionBuilder.php @@ -0,0 +1,201 @@ +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 0000000000..87baa1331c --- /dev/null +++ b/core/modules/layout_builder/src/LayoutTempstoreRepository.php @@ -0,0 +1,118 @@ +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 0000000000..ffce1c3008 --- /dev/null +++ b/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php @@ -0,0 +1,65 @@ +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 0000000000..4951d01c4b --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Field/FieldFormatter/LayoutSectionFormatter.php @@ -0,0 +1,89 @@ +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 0000000000..fc1c63413f --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Field/FieldType/LayoutSectionItem.php @@ -0,0 +1,132 @@ +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 0000000000..0098e35313 --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Menu/LayoutBuilderLocalTask.php @@ -0,0 +1,26 @@ +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 0000000000..66dbbbadc9 --- /dev/null +++ b/core/modules/layout_builder/src/Routing/LayoutBuilderRouteEnhancer.php @@ -0,0 +1,50 @@ +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 0000000000..b8221dfd79 --- /dev/null +++ b/core/modules/layout_builder/src/Routing/LayoutBuilderRoutes.php @@ -0,0 +1,157 @@ +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 0000000000..a1d5fc9140 --- /dev/null +++ b/core/modules/layout_builder/src/Routing/LayoutTempstoreParamConverter.php @@ -0,0 +1,54 @@ +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 0000000000..f5e19003b5 --- /dev/null +++ b/core/modules/layout_builder/src/Section.php @@ -0,0 +1,162 @@ +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 0000000000..61e481c7c7 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php @@ -0,0 +1,352 @@ +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/FunctionalJavascript/LayoutBuilderTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderTest.php new file mode 100644 index 0000000000..6dbe8eb924 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderTest.php @@ -0,0 +1,402 @@ +drupalPlaceBlock('local_tasks_block'); + + $bundle = BlockContentType::create([ + 'id' => 'basic', + 'label' => 'Basic', + ]); + $bundle->save(); + block_content_add_body_field($bundle->id()); + BlockContent::create([ + 'info' => 'My custom block', + 'type' => 'basic', + 'body' => [ + [ + 'value' => 'This is the block content', + 'format' => filter_default_format(), + ], + ], + ])->save(); + + $this->createContentType(['type' => 'bundle_with_section_field']); + $this->node = $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The node title', + 'body' => [ + [ + 'value' => 'The node body', + ], + ], + ]); + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + ], 'foobar')); + } + + /** + * Tests the Layout Builder UI. + */ + public function testLayoutBuilderUi() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Ensure the block is not displayed initially. + $this->drupalGet($this->node->toUrl('canonical')); + $assert_session->pageTextContains('The node body'); + $assert_session->pageTextNotContains('Powered by Drupal'); + $assert_session->linkNotExists('Layout'); + + // Enable layout support. + $this->drupalGet('admin/structure/types/manage/bundle_with_section_field/display'); + $page->checkField('layout[allow_custom]'); + $page->pressButton('Save'); + + // The existing content is still shown until overridden. + $this->drupalGet($this->node->toUrl('canonical')); + $assert_session->pageTextContains('The node body'); + + // Enter the layout editing mode. + $assert_session->linkExists('Layout'); + $this->clickLink('Layout'); + $this->markCurrentPage(); + $assert_session->pageTextNotContains('The node body'); + $assert_session->linkExists('Add Section'); + $assert_session->linkExists('Add Block'); + + // Add a new block. + $assert_session->linkExists('Add Block'); + $this->clickLink('Add Block'); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->elementExists('css', '#drupal-off-canvas'); + + $assert_session->linkExists('Powered by Drupal'); + $this->clickLink('Powered by Drupal'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementExists('css', '#drupal-off-canvas'); + + $page->fillField('settings[label]', 'This is the label'); + $page->checkField('settings[label_display]'); + + // Save the new block, and ensure it is displayed on the page. + $page->pressButton('Add Block'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementNotExists('css', '#drupal-off-canvas'); + + $assert_session->addressEquals($this->node->toUrl('layout-builder')); + $assert_session->pageTextContains('Powered by Drupal'); + $assert_session->pageTextContains('This is the label'); + $this->assertPageNotReloaded(); + + // Until the layout is saved, the new block is not visible on the node page. + $this->drupalGet($this->node->toUrl('canonical')); + $assert_session->pageTextNotContains('Powered by Drupal'); + + // When returning to the layout edit mode, the new block is visible. + $this->drupalGet($this->node->toUrl('layout-builder')); + $assert_session->pageTextContains('Powered by Drupal'); + + // Save the layout, and the new block is visible. + $assert_session->linkExists('Save Layout'); + $this->clickLink('Save Layout'); + $assert_session->addressEquals($this->node->toUrl('canonical')); + $assert_session->pageTextContains('Powered by Drupal'); + $assert_session->pageTextContains('This is the label'); + $assert_session->elementExists('css', '.layout'); + + // Drag one block from one region to another. + $this->drupalGet($this->node->toUrl('layout-builder')); + $this->markCurrentPage(); + + $assert_session->linkExists('Add Section'); + $this->clickLink('Add Section'); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->linkExists('Two column'); + $this->clickLink('Two column'); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->elementNotExists('css', '.layout__region--second .block-system-powered-by-block'); + $assert_session->elementTextNotContains('css', '.layout__region--second', 'Powered by Drupal'); + // Drag the block from one layout to another. + $page->find('css', '.layout__region--content .block-system-powered-by-block')->dragTo($page->find('css', '.layout__region--second')); + $assert_session->assertWaitOnAjaxRequest(); + // Ensure the drag succeeded. + $assert_session->elementExists('css', '.layout__region--second .block-system-powered-by-block'); + $assert_session->elementTextContains('css', '.layout__region--second', 'Powered by Drupal'); + $this->assertPageNotReloaded(); + + // Ensure the drag persisted after reload. + $this->drupalGet($this->node->toUrl('layout-builder')); + $assert_session->elementExists('css', '.layout__region--second .block-system-powered-by-block'); + $assert_session->elementTextContains('css', '.layout__region--second', 'Powered by Drupal'); + + // Ensure the drag persisted after save. + $assert_session->linkExists('Save Layout'); + $this->clickLink('Save Layout'); + $assert_session->elementExists('css', '.layout__region--second .block-system-powered-by-block'); + $assert_session->elementTextContains('css', '.layout__region--second', 'Powered by Drupal'); + + // Configure a block. + $this->drupalGet($this->node->toUrl('layout-builder')); + $this->markCurrentPage(); + + $this->clickContextualLink('.block-system-powered-by-block', 'Configure'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementExists('css', '#drupal-off-canvas'); + + $page->fillField('settings[label]', 'This is the new label'); + $page->pressButton('Update'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementNotExists('css', '#drupal-off-canvas'); + + $assert_session->addressEquals($this->node->toUrl('layout-builder')); + $assert_session->pageTextContains('Powered by Drupal'); + $assert_session->pageTextContains('This is the new label'); + $assert_session->pageTextNotContains('This is the label'); + + // Remove a block. + $this->clickContextualLink('.block-system-powered-by-block', 'Remove block'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementExists('css', '#drupal-off-canvas'); + + $page->pressButton('Remove'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementNotExists('css', '#drupal-off-canvas'); + + $assert_session->pageTextNotContains('Powered by Drupal'); + $assert_session->linkExists('Add Block'); + $assert_session->addressEquals($this->node->toUrl('layout-builder')); + $this->assertPageNotReloaded(); + + $assert_session->linkExists('Save Layout'); + $this->clickLink('Save Layout'); + $assert_session->elementExists('css', '.layout'); + + // Test deriver-based blocks. + $this->drupalGet($this->node->toUrl('layout-builder')); + $this->markCurrentPage(); + + $assert_session->linkExists('Add Block'); + $this->clickLink('Add Block'); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->linkExists('My custom block'); + $this->clickLink('My custom block'); + $assert_session->assertWaitOnAjaxRequest(); + + $page->pressButton('Add Block'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextContains('This is the block content'); + + // Remove both sections. + $assert_session->linkExists('Remove section'); + $this->clickLink('Remove section'); + $assert_session->assertWaitOnAjaxRequest(); + + $page->pressButton('Remove'); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->linkExists('Remove section'); + $this->clickLink('Remove section'); + $assert_session->assertWaitOnAjaxRequest(); + + $page->pressButton('Remove'); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->pageTextNotContains('This is the block content'); + $assert_session->linkNotExists('Add Block'); + $this->assertPageNotReloaded(); + + $assert_session->linkExists('Save Layout'); + $this->clickLink('Save Layout'); + $assert_session->elementNotExists('css', '.layout'); + + // Removing all sections results in the original display being used. + $assert_session->addressEquals($this->node->toUrl('canonical')); + $assert_session->pageTextContains('The node body'); + } + + /** + * Tests configurable layouts. + */ + public function testConfigurableLayouts() { + entity_get_display('node', 'bundle_with_section_field', 'full') + ->setThirdPartySetting('layout_builder', 'allow_custom', TRUE) + ->save(); + + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalGet($this->node->toUrl('layout-builder')); + $this->markCurrentPage(); + + $assert_session->linkExists('Add Section'); + $this->clickLink('Add Section'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementExists('css', '#drupal-off-canvas'); + + $assert_session->linkExists('One column'); + $this->clickLink('One column'); + $assert_session->assertWaitOnAjaxRequest(); + + // Add another section. + $assert_session->linkExists('Add Section'); + $this->clickLink('Add Section'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementExists('css', '#drupal-off-canvas'); + + $assert_session->linkExists('Layout plugin (with settings)'); + $this->clickLink('Layout plugin (with settings)'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->fieldExists('layout_settings[setting_1]'); + $page->pressButton('Add section'); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->elementNotExists('css', '#drupal-off-canvas'); + $assert_session->pageTextContains('Default'); + $assert_session->linkExists('Add Block'); + + // Configure the existing section. + $assert_session->linkExists('Configure section'); + $this->clickLink('Configure section'); + $assert_session->assertWaitOnAjaxRequest(); + $page->fillField('layout_settings[setting_1]', 'Test setting value'); + $page->pressButton('Update'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementNotExists('css', '#drupal-off-canvas'); + $assert_session->pageTextContains('Test setting value'); + $this->assertPageNotReloaded(); + } + + /** + * Tests bypassing the Off Canvas dialog. + */ + public function testLayoutNoDialog() { + entity_get_display('node', 'bundle_with_section_field', 'full') + ->setThirdPartySetting('layout_builder', 'allow_custom', TRUE) + ->save(); + + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Set up a layout with one section. + $this->drupalGet(Url::fromRoute('layout_builder.choose_section', [ + 'entity_type_id' => 'node', + 'entity' => 1, + 'delta' => 0, + ])); + $assert_session->linkExists('One column'); + $this->clickLink('One column'); + + // Add a block. + $this->drupalGet(Url::fromRoute('layout_builder.add_block', [ + 'entity_type_id' => 'node', + 'entity' => 1, + 'delta' => 0, + 'region' => 'content', + 'plugin_id' => 'system_powered_by_block', + ])); + $assert_session->elementNotExists('css', '#drupal-off-canvas'); + $page->fillField('settings[label]', 'The block label'); + $page->fillField('settings[label_display]', TRUE); + $page->pressButton('Add Block'); + + $assert_session->addressEquals($this->node->toUrl('layout-builder')); + $assert_session->pageTextContains('Powered by Drupal'); + $assert_session->pageTextContains('The block label'); + + // Remove the section. + $this->drupalGet(Url::fromRoute('layout_builder.remove_section', [ + 'entity_type_id' => 'node', + 'entity' => 1, + 'delta' => 0, + ])); + $page->pressButton('Remove'); + $assert_session->addressEquals($this->node->toUrl('layout-builder')); + $assert_session->pageTextNotContains('Powered by Drupal'); + $assert_session->pageTextNotContains('The block label'); + $assert_session->linkNotExists('Add Block'); + } + + /** + * {@inheritdoc} + * + * @todo Workaround for https://www.drupal.org/node/2918718. + */ + protected function clickContextualLink($selector, $link_locator, $force_visible = TRUE) { + $assert_session = $this->assertSession(); + + if ($force_visible) { + $this->getSession()->executeScript("jQuery('{$selector} .contextual .trigger').removeClass('visually-hidden');"); + $assert_session->assertWaitOnAjaxRequest(); + } + + $element = $this->getSession()->getPage()->find('css', $selector); + $link = $element->findLink($link_locator); + if (!$link) { + $this->fail("Link $link_locator was found"); + } + else { + // If the link is not visible, click the contextual link button first. + if (!$link->isVisible()) { + $element->find('css', '.contextual button')->press(); + $assert_session->assertWaitOnAjaxRequest(); + } + $this->assertTrue($link->isVisible(), "Link $link_locator is visible."); + $link->click(); + } + + if ($force_visible) { + $this->getSession()->executeScript("jQuery('{$selector} .contextual .trigger').addClass('visually-hidden');"); + $assert_session->assertWaitOnAjaxRequest(); + } + } + +} diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/PageReloadHelperTrait.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/PageReloadHelperTrait.php new file mode 100644 index 0000000000..c6f0b49933 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/PageReloadHelperTrait.php @@ -0,0 +1,41 @@ +pageReloadMarker = $this->randomMachineName(); + $this->getSession()->executeScript('document.body.appendChild(document.createTextNode("' . $this->pageReloadMarker . '"));'); + } + + /** + * Asserts that the page has not been reloaded. + */ + protected function assertPageNotReloaded() { + $this->assertSession()->pageTextContains($this->pageReloadMarker); + } + + /** + * Asserts that the page has been reloaded. + */ + protected function assertPageReloaded() { + $this->assertSession()->pageTextNotContains($this->pageReloadMarker); + } + +} 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 0000000000..c3a23c7871 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Kernel/LayoutBuilderFieldLayoutCompatibilityTest.php @@ -0,0 +1,157 @@ +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 0000000000..0e13471c15 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Kernel/LayoutSectionItemTest.php @@ -0,0 +1,89 @@ +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 0000000000..2904480d02 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/LayoutBuilderRouteEnhancerTest.php @@ -0,0 +1,146 @@ +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 0000000000..3e5b2ffad1 --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/LayoutSectionBuilderTest.php @@ -0,0 +1,301 @@ +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 0000000000..a652d4a56d --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php @@ -0,0 +1,133 @@ +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 0000000000..33a338706e --- /dev/null +++ b/core/modules/layout_builder/tests/src/Unit/SectionTest.php @@ -0,0 +1,265 @@ +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', []); + } + +}