Issue #2905922 by tim.plunkett, tedbow, xjm, EclipseGc, webchick, vijaycs85, larowlan, andrewmacpherson, droplet, Bojhan, mgifford, drpal, phenaproxima, DyanneNova, japerry: Implementation issue for Layout Builder

8.5.x
xjm 2017-11-17 14:01:26 -05:00
parent 596837b6ec
commit f7c9dd9352
51 changed files with 5093 additions and 0 deletions

View File

@ -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",

View File

@ -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'

View File

@ -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;
}

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
layout_builder_ui:
deriver: '\Drupal\layout_builder\Plugin\Derivative\LayoutBuilderLocalTaskDeriver'

View File

@ -0,0 +1,200 @@
<?php
/**
* @file
* Provides hook implementations for Layout Builder.
*/
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
/**
* Implements hook_help().
*/
function layout_builder_help($route_name) {
switch ($route_name) {
case 'help.page.layout_builder':
$output = '<h3>' . t('About') . '</h3>';
$output .= '<p>' . t('Layout Builder provides layout building utility.') . '</p>';
$output .= '<p>' . t('For more information, see the <a href=":layout-builder-documentation">online documentation for the Layout Builder module</a>.', [':layout-builder-documentation' => 'https://www.drupal.org/docs/8/core/modules/layout_builder']) . '</p>';
return $output;
}
}
/**
* Implements hook_entity_type_alter().
*/
function layout_builder_entity_type_alter(array &$entity_types) {
/** @var \Drupal\Core\Entity\EntityTypeInterface[] $entity_types */
foreach ($entity_types as $entity_type) {
if ($entity_type->entityClassImplements(FieldableEntityInterface::class) && $entity_type->hasLinkTemplate('canonical') && $entity_type->hasViewBuilderClass()) {
$entity_type->setLinkTemplate('layout-builder', $entity_type->getLinkTemplate('canonical') . '/layout');
}
}
}
/**
* Removes the Layout Builder field both visually and from the #fields handling.
*
* This prevents any interaction with this field. It is rendered directly
* in layout_builder_entity_view_display_alter().
*
* @internal
*/
function _layout_builder_hide_layout_field(array &$form) {
unset($form['fields']['layout_builder__layout']);
$key = array_search('layout_builder__layout', $form['#fields']);
if ($key !== FALSE) {
unset($form['#fields'][$key]);
}
}
/**
* Implements hook_form_FORM_ID_alter() for \Drupal\field_ui\Form\EntityFormDisplayEditForm.
*/
function layout_builder_form_entity_form_display_edit_form_alter(&$form, FormStateInterface $form_state) {
_layout_builder_hide_layout_field($form);
}
/**
* Implements hook_form_FORM_ID_alter() for \Drupal\field_ui\Form\EntityViewDisplayEditForm.
*/
function layout_builder_form_entity_view_display_edit_form_alter(&$form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display */
$display = $form_state->getFormObject()->getEntity();
$entity_type = \Drupal::entityTypeManager()->getDefinition($display->getTargetEntityTypeId());
_layout_builder_hide_layout_field($form);
// @todo Expand to work for all view modes in
// https://www.drupal.org/node/2907413.
if (!in_array($display->getMode(), ['full', 'default'], TRUE)) {
return;
}
$form['layout'] = [
'#type' => 'details',
'#open' => TRUE,
'#title' => t('Layout options'),
'#tree' => TRUE,
];
// @todo Unchecking this box is a destructive action, this should be made
// clear to the user in https://www.drupal.org/node/2914484.
$form['layout']['allow_custom'] = [
'#type' => 'checkbox',
'#title' => t('Allow each @entity to have its layout customized.', [
'@entity' => $entity_type->getSingularLabel(),
]),
'#default_value' => $display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE),
];
$form['#entity_builders'][] = 'layout_builder_form_entity_view_display_edit_entity_builder';
}
/**
* Entity builder for layout options on the entity view display form.
*
* @see layout_builder_form_entity_view_display_edit_form_alter()
*/
function layout_builder_form_entity_view_display_edit_entity_builder($entity_type_id, EntityViewDisplayInterface $display, &$form, FormStateInterface &$form_state) {
$new_value = (bool) $form_state->getValue(['layout', 'allow_custom'], FALSE);
$display->setThirdPartySetting('layout_builder', 'allow_custom', $new_value);
}
/**
* Implements hook_ENTITY_TYPE_presave().
*/
function layout_builder_entity_view_display_presave(EntityViewDisplayInterface $display) {
$original_value = isset($display->original) ? $display->original->getThirdPartySetting('layout_builder', 'allow_custom', FALSE) : FALSE;
$new_value = $display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE);
if ($original_value !== $new_value) {
$entity_type_id = $display->getTargetEntityTypeId();
$bundle = $display->getTargetBundle();
if ($new_value) {
layout_builder_add_layout_section_field($entity_type_id, $bundle);
}
elseif ($field = FieldConfig::loadByName($entity_type_id, $bundle, 'layout_builder__layout')) {
$field->delete();
}
}
}
/**
* Adds a layout section field to a given bundle.
*
* @param string $entity_type_id
* The entity type ID.
* @param string $bundle
* The bundle.
* @param string $field_name
* (optional) The name for the layout section field. Defaults to
* 'layout_builder__layout'.
*
* @return \Drupal\field\FieldConfigInterface
* A layout section field.
*/
function layout_builder_add_layout_section_field($entity_type_id, $bundle, $field_name = 'layout_builder__layout') {
$field = FieldConfig::loadByName($entity_type_id, $bundle, $field_name);
if (!$field) {
$field_storage = FieldStorageConfig::loadByName($entity_type_id, $field_name);
if (!$field_storage) {
$field_storage = FieldStorageConfig::create([
'entity_type' => $entity_type_id,
'field_name' => $field_name,
'type' => 'layout_section',
]);
$field_storage->save();
}
$field = FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => $bundle,
'label' => t('Layout'),
]);
$field->save();
}
return $field;
}
/**
* Implements hook_entity_view_display_alter().
*/
function layout_builder_entity_view_display_alter(EntityViewDisplayInterface $display, array $context) {
if ($display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE)) {
// Force the layout to render with no label.
$display->setComponent('layout_builder__layout', [
'label' => 'hidden',
'region' => '__layout_builder',
]);
}
else {
$display->removeComponent('layout_builder__layout');
}
}
/**
* Implements hook_entity_view_alter().
*/
function layout_builder_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
if ($display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE) && !$entity->layout_builder__layout->isEmpty()) {
// If field layout is active, that is all that needs to be removed.
if (\Drupal::moduleHandler()->moduleExists('field_layout') && isset($build['_field_layout'])) {
unset($build['_field_layout']);
return;
}
/** @var \Drupal\Core\Field\FieldDefinitionInterface[] $field_definitions */
$field_definitions = \Drupal::service('entity_field.manager')->getFieldDefinitions($display->getTargetEntityTypeId(), $display->getTargetBundle());
// Remove all display-configurable fields.
foreach (array_keys($display->getComponents()) as $name) {
if ($name !== 'layout_builder__layout' && isset($field_definitions[$name]) && $field_definitions[$name]->isDisplayConfigurable('view')) {
unset($build[$name]);
}
}
}
}

View File

@ -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

View File

@ -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'

View File

@ -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}

View File

@ -0,0 +1,68 @@
<?php
namespace Drupal\layout_builder\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Provides an access check for the Layout Builder UI.
*
* @internal
*/
class LayoutSectionAccessCheck implements AccessInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new LayoutSectionAccessCheck.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* Checks routing access to layout for the entity.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The current route match.
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(RouteMatchInterface $route_match, AccountInterface $account) {
// Attempt to retrieve the generic 'entity' parameter, otherwise look up the
// specific entity via the entity type ID.
$entity = $route_match->getParameter('entity') ?: $route_match->getParameter($route_match->getParameter('entity_type_id'));
// If we don't have an entity, forbid access.
if (empty($entity)) {
return AccessResult::forbidden()->addCacheContexts(['route']);
}
// If the entity isn't fieldable, forbid access.
if (!$entity instanceof FieldableEntityInterface || !$entity->hasField('layout_builder__layout')) {
$access = AccessResult::forbidden();
}
else {
$access = AccessResult::allowedIfHasPermission($account, 'configure any layout');
}
return $access->addCacheableDependency($entity);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace Drupal\layout_builder\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\Context\CalculatedCacheContextInterface;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Determines whether Layout Builder is active for a given entity type or not.
*
* Cache context ID: 'layout_builder_is_active:%entity_type_id', e.g.
* 'layout_builder_is_active:node' (to vary by whether the Node entity type has
* Layout Builder enabled).
*/
class LayoutBuilderIsActiveCacheContext implements CalculatedCacheContextInterface {
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* LayoutBuilderCacheContext constructor.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The current route match.
*/
public function __construct(RouteMatchInterface $route_match) {
$this->routeMatch = $route_match;
}
/**
* {@inheritdoc}
*/
public static function getLabel() {
return t('Layout Builder');
}
/**
* {@inheritdoc}
*/
public function getContext($entity_type_id = NULL) {
if (!$entity_type_id) {
throw new \LogicException('Missing entity type ID');
}
$display = $this->getDisplay($entity_type_id);
return ($display && $display->getThirdPartySetting('layout_builder', 'allow_custom', FALSE)) ? '1' : '0';
}
/**
* {@inheritdoc}
*/
public function getCacheableMetadata($entity_type_id = NULL) {
if (!$entity_type_id) {
throw new \LogicException('Missing entity type ID');
}
$cacheable_metadata = new CacheableMetadata();
if ($display = $this->getDisplay($entity_type_id)) {
$cacheable_metadata->addCacheableDependency($display);
}
return $cacheable_metadata;
}
/**
* Returns the entity view display for a given entity type and view mode.
*
* @param string $entity_type_id
* The entity type ID.
* @param string $view_mode
* (optional) The view mode that should be used to render the entity.
*
* @return \Drupal\Core\Entity\Display\EntityViewDisplayInterface|null
* The entity view display, if it exists.
*/
protected function getDisplay($entity_type_id, $view_mode = 'full') {
if ($entity = $this->routeMatch->getParameter($entity_type_id)) {
return EntityViewDisplay::collectRenderDisplay($entity, $view_mode);
}
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace Drupal\layout_builder\Controller;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\layout_builder\LayoutTempstoreRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Defines a controller to add a new section.
*
* @internal
*/
class AddSectionController implements ContainerInjectionInterface {
use AjaxHelperTrait;
use LayoutRebuildTrait;
/**
* The layout tempstore repository.
*
* @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface
*/
protected $layoutTempstoreRepository;
/**
* AddSectionController constructor.
*
* @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository
* The layout tempstore repository.
* @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
* The class resolver.
*/
public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, ClassResolverInterface $class_resolver) {
$this->layoutTempstoreRepository = $layout_tempstore_repository;
$this->classResolver = $class_resolver;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('layout_builder.tempstore_repository'),
$container->get('class_resolver')
);
}
/**
* Add the layout to the entity field in a tempstore.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param int $delta
* The delta of the section to splice.
* @param string $plugin_id
* The plugin ID of the layout to add.
*
* @return \Symfony\Component\HttpFoundation\Response
* The controller response.
*/
public function build(EntityInterface $entity, $delta, $plugin_id) {
/** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */
$field_list = $entity->layout_builder__layout;
$field_list->addItem($delta, [
'layout' => $plugin_id,
'layout_settings' => [],
'section' => [],
]);
$this->layoutTempstoreRepository->set($entity);
if ($this->isAjax()) {
return $this->rebuildAndClose($entity);
}
else {
$url = $entity->toUrl('layout-builder');
return new RedirectResponse($url->setAbsolute()->toString());
}
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace Drupal\layout_builder\Controller;
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
/**
* Provides a helper to determine if the current request is via AJAX.
*
* @internal
*
* @todo Move to \Drupal\Core in https://www.drupal.org/node/2896535.
*/
trait AjaxHelperTrait {
/**
* Determines if the current request is via AJAX.
*
* @return bool
* TRUE if the current request is via AJAX, FALSE otherwise.
*/
protected function isAjax() {
return in_array($this->getRequestWrapperFormat(), [
'drupal_ajax',
'drupal_dialog',
'drupal_dialog.off_canvas',
'drupal_modal',
]);
}
/**
* Gets the wrapper format of the current request.
*
* @string
* The wrapper format.
*/
protected function getRequestWrapperFormat() {
return \Drupal::request()->get(MainContentViewSubscriber::WRAPPER_FORMAT);
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace Drupal\layout_builder\Controller;
use Drupal\Core\Block\BlockManagerInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a controller to choose a new block.
*
* @internal
*/
class ChooseBlockController implements ContainerInjectionInterface {
use AjaxHelperTrait;
/**
* The block manager.
*
* @var \Drupal\Core\Block\BlockManagerInterface
*/
protected $blockManager;
/**
* ChooseBlockController constructor.
*
* @param \Drupal\Core\Block\BlockManagerInterface $block_manager
* The block manager.
*/
public function __construct(BlockManagerInterface $block_manager) {
$this->blockManager = $block_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.block')
);
}
/**
* Provides the UI for choosing a new block.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param int $delta
* The delta of the section to splice.
* @param string $region
* The region the block is going in.
*
* @return array
* A render array.
*/
public function build(EntityInterface $entity, $delta, $region) {
$build['#type'] = 'container';
$build['#attributes']['class'][] = 'block-categories';
foreach ($this->blockManager->getGroupedDefinitions() as $category => $blocks) {
$build[$category]['#type'] = 'details';
$build[$category]['#open'] = TRUE;
$build[$category]['#title'] = $category;
$build[$category]['links'] = [
'#theme' => 'links',
];
foreach ($blocks as $block_id => $block) {
$link = [
'title' => $block['admin_label'],
'url' => Url::fromRoute('layout_builder.add_block',
[
'entity_type_id' => $entity->getEntityTypeId(),
'entity' => $entity->id(),
'delta' => $delta,
'region' => $region,
'plugin_id' => $block_id,
]
),
];
if ($this->isAjax()) {
$link['attributes']['class'][] = 'use-ajax';
$link['attributes']['data-dialog-type'][] = 'dialog';
$link['attributes']['data-dialog-renderer'][] = 'off_canvas';
}
$build[$category]['links']['#links'][] = $link;
}
}
return $build;
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace Drupal\layout_builder\Controller;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Layout\LayoutPluginManagerInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a controller to choose a new section.
*
* @internal
*/
class ChooseSectionController implements ContainerInjectionInterface {
use AjaxHelperTrait;
use StringTranslationTrait;
/**
* The layout manager.
*
* @var \Drupal\Core\Layout\LayoutPluginManagerInterface
*/
protected $layoutManager;
/**
* ChooseSectionController constructor.
*
* @param \Drupal\Core\Layout\LayoutPluginManagerInterface $layout_manager
* The layout manager.
*/
public function __construct(LayoutPluginManagerInterface $layout_manager) {
$this->layoutManager = $layout_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.core.layout')
);
}
/**
* Choose a layout plugin to add as a section.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param int $delta
* The delta of the section to splice.
*
* @return array
* The render array.
*/
public function build(EntityInterface $entity, $delta) {
$output['#title'] = $this->t('Choose a layout');
$items = [];
foreach ($this->layoutManager->getDefinitions() as $plugin_id => $definition) {
$layout = $this->layoutManager->createInstance($plugin_id);
$item = [
'#type' => 'link',
'#title' => [
$definition->getIcon(60, 80, 1, 3),
[
'#type' => 'container',
'#children' => $definition->getLabel(),
],
],
'#url' => Url::fromRoute(
$layout instanceof PluginFormInterface ? 'layout_builder.configure_section' : 'layout_builder.add_section',
[
'entity_type_id' => $entity->getEntityTypeId(),
'entity' => $entity->id(),
'delta' => $delta,
'plugin_id' => $plugin_id,
]
),
];
if ($this->isAjax()) {
$item['#attributes']['class'][] = 'use-ajax';
$item['#attributes']['data-dialog-type'][] = 'dialog';
$item['#attributes']['data-dialog-renderer'][] = 'off_canvas';
}
$items[] = $item;
}
$output['layouts'] = [
'#theme' => 'item_list',
'#items' => $items,
'#attributes' => [
'class' => [
'layout-selection',
],
],
];
return $output;
}
}

View File

@ -0,0 +1,319 @@
<?php
namespace Drupal\layout_builder\Controller;
use Drupal\Core\Block\BlockManagerInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Layout\LayoutPluginManagerInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\layout_builder\LayoutSectionBuilder;
use Drupal\layout_builder\Field\LayoutSectionItemInterface;
use Drupal\layout_builder\LayoutTempstoreRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Defines a controller to provide the Layout Builder admin UI.
*
* @internal
*/
class LayoutBuilderController implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* The layout builder.
*
* @var \Drupal\layout_builder\LayoutSectionBuilder
*/
protected $builder;
/**
* The layout manager.
*
* @var \Drupal\Core\Layout\LayoutPluginManagerInterface
*/
protected $layoutManager;
/**
* The block manager.
*
* @var \Drupal\Core\Block\BlockManagerInterface
*/
protected $blockManager;
/**
* The layout tempstore repository.
*
* @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface
*/
protected $layoutTempstoreRepository;
/**
* LayoutBuilderController constructor.
*
* @param \Drupal\layout_builder\LayoutSectionBuilder $builder
* The layout section builder.
* @param \Drupal\Core\Layout\LayoutPluginManagerInterface $layout_manager
* The layout manager.
* @param \Drupal\Core\Block\BlockManagerInterface $block_manager
* The block manager.
* @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository
* The layout tempstore repository.
*/
public function __construct(LayoutSectionBuilder $builder, LayoutPluginManagerInterface $layout_manager, BlockManagerInterface $block_manager, LayoutTempstoreRepositoryInterface $layout_tempstore_repository) {
$this->builder = $builder;
$this->layoutManager = $layout_manager;
$this->blockManager = $block_manager;
$this->layoutTempstoreRepository = $layout_tempstore_repository;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('layout_builder.builder'),
$container->get('plugin.manager.core.layout'),
$container->get('plugin.manager.block'),
$container->get('layout_builder.tempstore_repository')
);
}
/**
* Provides a title callback.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return string
* The title for the layout page.
*/
public function title(EntityInterface $entity) {
return $this->t('Edit layout for %label', ['%label' => $entity->label()]);
}
/**
* Renders the Layout UI.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param bool $is_rebuilding
* (optional) Indicates if the layout is rebuilding, defaults to FALSE.
*
* @return array
* A render array.
*/
public function layout(EntityInterface $entity, $is_rebuilding = FALSE) {
$entity_id = $entity->id();
$entity_type_id = $entity->getEntityTypeId();
/** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */
$field_list = $entity->layout_builder__layout;
// For a new layout override, begin with a single section of one column.
if (!$is_rebuilding && $field_list->isEmpty()) {
$field_list->addItem(0, ['layout' => 'layout_onecol']);
$this->layoutTempstoreRepository->set($entity);
}
$output = [];
$count = 0;
foreach ($field_list as $item) {
$output[] = $this->buildAddSectionLink($entity_type_id, $entity_id, $count);
$output[] = $this->buildAdministrativeSection($item, $entity, $count);
$count++;
}
$output[] = $this->buildAddSectionLink($entity_type_id, $entity_id, $count);
$output['#attached']['library'][] = 'layout_builder/drupal.layout_builder';
$output['#type'] = 'container';
$output['#attributes']['id'] = 'layout-builder';
// Mark this UI as uncacheable.
$output['#cache']['max-age'] = 0;
return $output;
}
/**
* Builds a link to add a new section at a given delta.
*
* @param string $entity_type_id
* The entity type.
* @param string $entity_id
* The entity ID.
* @param int $delta
* The delta of the section to splice.
*
* @return array
* A render array for a link.
*/
protected function buildAddSectionLink($entity_type_id, $entity_id, $delta) {
return [
'link' => [
'#type' => 'link',
'#title' => $this->t('Add Section'),
'#url' => Url::fromRoute('layout_builder.choose_section',
[
'entity_type_id' => $entity_type_id,
'entity' => $entity_id,
'delta' => $delta,
],
[
'attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'dialog',
'data-dialog-renderer' => 'off_canvas',
],
]
),
],
'#type' => 'container',
'#attributes' => [
'class' => ['add-section'],
],
];
}
/**
* Builds the render array for the layout section while editing.
*
* @param \Drupal\layout_builder\Field\LayoutSectionItemInterface $item
* The layout section item.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param int $delta
* The delta of the section.
*
* @return array
* The render array for a given section.
*/
protected function buildAdministrativeSection(LayoutSectionItemInterface $item, EntityInterface $entity, $delta) {
$entity_type_id = $entity->getEntityTypeId();
$entity_id = $entity->id();
$layout = $this->layoutManager->createInstance($item->layout, $item->layout_settings);
$build = $this->builder->buildSectionFromLayout($layout, $item->section);
$layout_definition = $layout->getPluginDefinition();
foreach ($layout_definition->getRegions() as $region => $info) {
if (!empty($build[$region])) {
foreach ($build[$region] as $uuid => $block) {
$build[$region][$uuid]['#attributes']['class'][] = 'draggable';
$build[$region][$uuid]['#attributes']['data-layout-block-uuid'] = $uuid;
$build[$region][$uuid]['#contextual_links'] = [
'layout_builder_block' => [
'route_parameters' => [
'entity_type_id' => $entity_type_id,
'entity' => $entity_id,
'delta' => $delta,
'region' => $region,
'uuid' => $uuid,
],
],
];
}
}
$build[$region]['layout_builder_add_block']['link'] = [
'#type' => 'link',
'#title' => $this->t('Add Block'),
'#url' => Url::fromRoute('layout_builder.choose_block',
[
'entity_type_id' => $entity_type_id,
'entity' => $entity_id,
'delta' => $delta,
'region' => $region,
],
[
'attributes' => [
'class' => ['use-ajax'],
'data-dialog-type' => 'dialog',
'data-dialog-renderer' => 'off_canvas',
],
]
),
];
$build[$region]['layout_builder_add_block']['#type'] = 'container';
$build[$region]['layout_builder_add_block']['#attributes'] = ['class' => ['add-block']];
$build[$region]['layout_builder_add_block']['#weight'] = -1000;
$build[$region]['#attributes']['data-region'] = $region;
$build[$region]['#attributes']['class'][] = 'layout-builder--layout__region';
}
$build['#attributes']['data-layout-update-url'] = Url::fromRoute('layout_builder.move_block', [
'entity_type_id' => $entity_type_id,
'entity' => $entity_id,
])->toString();
$build['#attributes']['data-layout-delta'] = $delta;
$build['#attributes']['class'][] = 'layout-builder--layout';
return [
'#type' => 'container',
'#attributes' => [
'class' => ['layout-section'],
],
'configure' => [
'#type' => 'link',
'#title' => $this->t('Configure section'),
'#access' => $layout instanceof PluginFormInterface,
'#url' => Url::fromRoute('layout_builder.configure_section', [
'entity_type_id' => $entity_type_id,
'entity' => $entity_id,
'delta' => $delta,
]),
'#attributes' => [
'class' => ['use-ajax', 'configure-section'],
'data-dialog-type' => 'dialog',
'data-dialog-renderer' => 'off_canvas',
],
],
'remove' => [
'#type' => 'link',
'#title' => $this->t('Remove section'),
'#url' => Url::fromRoute('layout_builder.remove_section', [
'entity_type_id' => $entity_type_id,
'entity' => $entity_id,
'delta' => $delta,
]),
'#attributes' => [
'class' => ['use-ajax', 'remove-section'],
'data-dialog-type' => 'dialog',
'data-dialog-renderer' => 'off_canvas',
],
],
'layout-section' => $build,
];
}
/**
* Saves the layout.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* A redirect response.
*/
public function saveLayout(EntityInterface $entity) {
$entity->save();
$this->layoutTempstoreRepository->delete($entity);
return new RedirectResponse($entity->toUrl()->setAbsolute()->toString());
}
/**
* Cancels the layout.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* A redirect response.
*/
public function cancelLayout(EntityInterface $entity) {
$this->layoutTempstoreRepository->delete($entity);
return new RedirectResponse($entity->toUrl()->setAbsolute()->toString());
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Drupal\layout_builder\Controller;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\CloseDialogCommand;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Entity\EntityInterface;
/**
* Provides AJAX responses to rebuild the Layout Builder.
*
* @internal
*/
trait LayoutRebuildTrait {
/**
* The class resolver.
*
* @var \Drupal\Core\DependencyInjection\ClassResolverInterface
*/
protected $classResolver;
/**
* Rebuilds the layout.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* An AJAX response to either rebuild the layout and close the dialog, or
* reload the page.
*/
protected function rebuildAndClose(EntityInterface $entity) {
$response = $this->rebuildLayout($entity);
$response->addCommand(new CloseDialogCommand('#drupal-off-canvas'));
return $response;
}
/**
* Rebuilds the layout.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* An AJAX response to either rebuild the layout and close the dialog, or
* reload the page.
*/
protected function rebuildLayout(EntityInterface $entity) {
$response = new AjaxResponse();
$layout_controller = $this->classResolver->getInstanceFromDefinition(LayoutBuilderController::class);
$layout = $layout_controller->layout($entity, TRUE);
$response->addCommand(new ReplaceCommand('#layout-builder', $layout));
return $response;
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace Drupal\layout_builder\Controller;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\layout_builder\LayoutTempstoreRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a controller to move a block.
*
* @internal
*/
class MoveBlockController implements ContainerInjectionInterface {
use LayoutRebuildTrait;
/**
* The layout tempstore repository.
*
* @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface
*/
protected $layoutTempstoreRepository;
/**
* LayoutController constructor.
*
* @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository
* The layout tempstore repository.
* @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
* The class resolver.
*/
public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, ClassResolverInterface $class_resolver) {
$this->layoutTempstoreRepository = $layout_tempstore_repository;
$this->classResolver = $class_resolver;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('layout_builder.tempstore_repository'),
$container->get('class_resolver')
);
}
/**
* Moves a block to another region.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param int $delta_from
* The delta of the original section.
* @param int $delta_to
* The delta of the destination section.
* @param string $region_from
* The original region for this block.
* @param string $region_to
* The new region for this block.
* @param string $block_uuid
* The UUID for this block.
* @param string|null $preceding_block_uuid
* (optional) If provided, the UUID of the block to insert this block after.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* An AJAX response.
*/
public function build(EntityInterface $entity, $delta_from, $delta_to, $region_from, $region_to, $block_uuid, $preceding_block_uuid = NULL) {
/** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */
$field = $entity->layout_builder__layout->get($delta_from);
$section = $field->getSection();
$block = $section->getBlock($region_from, $block_uuid);
$section->removeBlock($region_from, $block_uuid);
// If the block is moving from one section to another, update the original
// section and load the new one.
if ($delta_from !== $delta_to) {
$field->updateFromSection($section);
$field = $entity->layout_builder__layout->get($delta_to);
$section = $field->getSection();
}
// If a preceding block was specified, insert after that. Otherwise add the
// block to the front.
if (isset($preceding_block_uuid)) {
$section->insertBlock($region_to, $block_uuid, $block, $preceding_block_uuid);
}
else {
$section->addBlock($region_to, $block_uuid, $block);
}
$field->updateFromSection($section);
$this->layoutTempstoreRepository->set($entity);
return $this->rebuildLayout($entity);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Drupal\layout_builder\Field;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\layout_builder\Section;
/**
* Defines an interface for the layout section field item.
*
* @internal
* Layout Builder is currently experimental and should only be leveraged by
* experimental modules and development releases of contributed modules.
* See https://www.drupal.org/core/experimental for more information.
*
* @property string layout
* @property array[] layout_settings
* @property array[] section
*/
interface LayoutSectionItemInterface extends FieldItemInterface {
/**
* Gets a domain object for the layout section.
*
* @return \Drupal\layout_builder\Section
* The layout section.
*/
public function getSection();
/**
* Updates the stored value based on the domain object.
*
* @param \Drupal\layout_builder\Section $section
* The layout section.
*
* @return $this
*/
public function updateFromSection(Section $section);
}

View File

@ -0,0 +1,32 @@
<?php
namespace Drupal\layout_builder\Field;
use Drupal\Core\Field\FieldItemList;
/**
* Defines a item list class for layout section fields.
*
* @internal
*
* @see \Drupal\layout_builder\Plugin\Field\FieldType\LayoutSectionItem
*/
class LayoutSectionItemList extends FieldItemList implements LayoutSectionItemListInterface {
/**
* {@inheritdoc}
*/
public function addItem($index, $value) {
if ($this->get($index)) {
$start = array_slice($this->list, 0, $index);
$end = array_slice($this->list, $index);
$item = $this->createItem($index, $value);
$this->list = array_merge($start, [$item], $end);
}
else {
$item = $this->appendItem($value);
}
return $item;
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Drupal\layout_builder\Field;
use Drupal\Core\Field\FieldItemListInterface;
/**
* Defines a item list class for layout section fields.
*
* @internal
* Layout Builder is currently experimental and should only be leveraged by
* experimental modules and development releases of contributed modules.
* See https://www.drupal.org/core/experimental for more information.
*
* @see \Drupal\layout_builder\Plugin\Field\FieldType\LayoutSectionItem
*/
interface LayoutSectionItemListInterface extends FieldItemListInterface {
/**
* {@inheritdoc}
*
* @return \Drupal\layout_builder\Field\LayoutSectionItemInterface|null
* The layout section item, if it exists.
*/
public function get($index);
/**
* Adds a new item to the list.
*
* If an item exists at the given index, the item at that position and others
* after it are shifted backward.
*
* @param int $index
* The position of the item in the list.
* @param mixed $value
* The value of the item to be stored at the specified position.
*
* @return \Drupal\Core\TypedData\TypedDataInterface
* The item that was appended.
*
* @todo Move to \Drupal\Core\TypedData\ListInterface directly in
* https://www.drupal.org/node/2907417.
*/
public function addItem($index, $value);
}

View File

@ -0,0 +1,35 @@
<?php
namespace Drupal\layout_builder\Form;
use Drupal\layout_builder\Section;
/**
* Provides a form to add a block.
*
* @internal
*/
class AddBlockForm extends ConfigureBlockFormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'layout_builder_add_block';
}
/**
* {@inheritdoc}
*/
protected function submitLabel() {
return $this->t('Add Block');
}
/**
* {@inheritdoc}
*/
protected function submitBlock(Section $section, $region, $uuid, array $configuration) {
$section->addBlock($region, $uuid, $configuration);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace Drupal\layout_builder\Form;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Form\FormStateInterface;
use Drupal\layout_builder\Controller\AjaxHelperTrait;
/**
* Provides a helper to for submitting an AJAX form.
*
* @internal
*
* @todo Move to \Drupal\Core in https://www.drupal.org/node/2896535.
*/
trait AjaxFormHelperTrait {
use AjaxHelperTrait;
/**
* Submit form dialog #ajax callback.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* An AJAX response that display validation error messages or represents a
* successful submission.
*/
public function ajaxSubmit(array &$form, FormStateInterface $form_state) {
if ($form_state->hasAnyErrors()) {
$form['status_messages'] = [
'#type' => 'status_messages',
'#weight' => -1000,
];
$response = new AjaxResponse();
$response->addCommand(new ReplaceCommand('[data-drupal-selector="' . $form['#attributes']['data-drupal-selector'] . '"]', $form));
}
else {
$response = $this->successfulAjaxSubmit($form, $form_state);
}
return $response;
}
/**
* Allows the form to respond to a successful AJAX submission.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* An AJAX response.
*/
abstract protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state);
}

View File

@ -0,0 +1,282 @@
<?php
namespace Drupal\layout_builder\Form;
use Drupal\Component\Uuid\UuidInterface;
use Drupal\Core\Block\BlockManagerInterface;
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Plugin\Context\ContextRepositoryInterface;
use Drupal\Core\Plugin\ContextAwarePluginAssignmentTrait;
use Drupal\Core\Plugin\ContextAwarePluginInterface;
use Drupal\Core\Plugin\PluginFormFactoryInterface;
use Drupal\Core\Plugin\PluginWithFormsInterface;
use Drupal\layout_builder\Controller\LayoutRebuildTrait;
use Drupal\layout_builder\LayoutTempstoreRepositoryInterface;
use Drupal\layout_builder\Section;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a base form for configuring a block.
*
* @internal
*/
abstract class ConfigureBlockFormBase extends FormBase {
use AjaxFormHelperTrait;
use ContextAwarePluginAssignmentTrait;
use LayoutRebuildTrait;
/**
* The plugin being configured.
*
* @var \Drupal\Core\Block\BlockPluginInterface
*/
protected $block;
/**
* The context repository.
*
* @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface
*/
protected $contextRepository;
/**
* The layout tempstore repository.
*
* @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface
*/
protected $layoutTempstoreRepository;
/**
* The block manager.
*
* @var \Drupal\Core\Block\BlockManagerInterface
*/
protected $blockManager;
/**
* The UUID generator.
*
* @var \Drupal\Component\Uuid\UuidInterface
*/
protected $uuid;
/**
* The plugin form manager.
*
* @var \Drupal\Core\Plugin\PluginFormFactoryInterface
*/
protected $pluginFormFactory;
/**
* The field delta.
*
* @var int
*/
protected $delta;
/**
* The current region.
*
* @var string
*/
protected $region;
/**
* The entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* Constructs a new block form.
*
* @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository
* The layout tempstore repository.
* @param \Drupal\Core\Plugin\Context\ContextRepositoryInterface $context_repository
* The context repository.
* @param \Drupal\Core\Block\BlockManagerInterface $block_manager
* The block manager.
* @param \Drupal\Component\Uuid\UuidInterface $uuid
* The UUID generator.
* @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
* The class resolver.
* @param \Drupal\Core\Plugin\PluginFormFactoryInterface $plugin_form_manager
* The plugin form manager.
*/
public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, ContextRepositoryInterface $context_repository, BlockManagerInterface $block_manager, UuidInterface $uuid, ClassResolverInterface $class_resolver, PluginFormFactoryInterface $plugin_form_manager) {
$this->layoutTempstoreRepository = $layout_tempstore_repository;
$this->contextRepository = $context_repository;
$this->blockManager = $block_manager;
$this->uuid = $uuid;
$this->classResolver = $class_resolver;
$this->pluginFormFactory = $plugin_form_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('layout_builder.tempstore_repository'),
$container->get('context.repository'),
$container->get('plugin.manager.block'),
$container->get('uuid'),
$container->get('class_resolver'),
$container->get('plugin_form.factory')
);
}
/**
* Prepares the block plugin based on the block ID.
*
* @param string $block_id
* Either a block ID, or the plugin ID used to create a new block.
* @param array $configuration
* The block configuration.
*
* @return \Drupal\Core\Block\BlockPluginInterface
* The block plugin.
*/
protected function prepareBlock($block_id, array $configuration) {
if (!isset($configuration['uuid'])) {
$configuration['uuid'] = $this->uuid->generate();
}
return $this->blockManager->createInstance($block_id, $configuration);
}
/**
* Builds the form for the block.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being configured.
* @param int $delta
* The delta of the section.
* @param string $region
* The region of the block.
* @param string|null $plugin_id
* The plugin ID of the block to add.
* @param array $configuration
* (optional) The array of configuration for the block.
*
* @return array
* The form array.
*/
public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL, $region = NULL, $plugin_id = NULL, array $configuration = []) {
$this->entity = $entity;
$this->delta = $delta;
$this->region = $region;
$this->block = $this->prepareBlock($plugin_id, $configuration);
$form_state->setTemporaryValue('gathered_contexts', $this->contextRepository->getAvailableContexts());
// @todo Remove once https://www.drupal.org/node/2268787 is resolved.
$form_state->set('block_theme', $this->config('system.theme')->get('default'));
$form['#tree'] = TRUE;
$form['settings'] = [];
$subform_state = SubformState::createForSubform($form['settings'], $form, $form_state);
$form['settings'] = $this->getPluginForm($this->block)->buildConfigurationForm($form['settings'], $subform_state);
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->submitLabel(),
'#button_type' => 'primary',
];
if ($this->isAjax()) {
$form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit';
}
return $form;
}
/**
* Returns the label for the submit button.
*
* @return string
* Submit label.
*/
abstract protected function submitLabel();
/**
* Handles the submission of a block.
*
* @param \Drupal\layout_builder\Section $section
* The layout section.
* @param string $region
* The region name.
* @param string $uuid
* The UUID of the block.
* @param array $configuration
* The block configuration.
*/
abstract protected function submitBlock(Section $section, $region, $uuid, array $configuration);
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
$subform_state = SubformState::createForSubform($form['settings'], $form, $form_state);
$this->getPluginForm($this->block)->validateConfigurationForm($form['settings'], $subform_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Call the plugin submit handler.
$subform_state = SubformState::createForSubform($form['settings'], $form, $form_state);
$this->getPluginForm($this->block)->submitConfigurationForm($form, $subform_state);
// If this block is context-aware, set the context mapping.
if ($this->block instanceof ContextAwarePluginInterface) {
$this->block->setContextMapping($subform_state->getValue('context_mapping', []));
}
$configuration = $this->block->getConfiguration();
/** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */
$field = $this->entity->layout_builder__layout->get($this->delta);
$section = $field->getSection();
$this->submitBlock($section, $this->region, $configuration['uuid'], ['block' => $configuration]);
$field->updateFromSection($section);
$this->layoutTempstoreRepository->set($this->entity);
$form_state->setRedirectUrl($this->entity->toUrl('layout-builder'));
}
/**
* {@inheritdoc}
*/
protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) {
return $this->rebuildAndClose($this->entity);
}
/**
* Retrieves the plugin form for a given block.
*
* @param \Drupal\Core\Block\BlockPluginInterface $block
* The block plugin.
*
* @return \Drupal\Core\Plugin\PluginFormInterface
* The plugin form for the block.
*/
protected function getPluginForm(BlockPluginInterface $block) {
if ($block instanceof PluginWithFormsInterface) {
return $this->pluginFormFactory->createInstance($block, 'configure');
}
return $block;
}
}

View File

@ -0,0 +1,216 @@
<?php
namespace Drupal\layout_builder\Form;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Layout\LayoutInterface;
use Drupal\Core\Layout\LayoutPluginManagerInterface;
use Drupal\Core\Plugin\PluginFormFactoryInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\Plugin\PluginWithFormsInterface;
use Drupal\layout_builder\Controller\LayoutRebuildTrait;
use Drupal\layout_builder\LayoutTempstoreRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a form for configuring a layout section.
*
* @internal
*/
class ConfigureSectionForm extends FormBase {
use AjaxFormHelperTrait;
use LayoutRebuildTrait;
/**
* The layout tempstore repository.
*
* @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface
*/
protected $layoutTempstoreRepository;
/**
* The plugin being configured.
*
* @var \Drupal\Core\Layout\LayoutInterface|\Drupal\Core\Plugin\PluginFormInterface
*/
protected $layout;
/**
* The layout manager.
*
* @var \Drupal\Core\Layout\LayoutPluginManagerInterface
*/
protected $layoutManager;
/**
* The plugin form manager.
*
* @var \Drupal\Core\Plugin\PluginFormFactoryInterface
*/
protected $pluginFormFactory;
/**
* The entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* The field delta.
*
* @var int
*/
protected $delta;
/**
* Indicates whether the section is being added or updated.
*
* @var bool
*/
protected $isUpdate;
/**
* Constructs a new ConfigureSectionForm.
*
* @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository
* The layout tempstore repository.
* @param \Drupal\Core\Layout\LayoutPluginManagerInterface $layout_manager
* The layout manager.
* @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
* The class resolver.
* @param \Drupal\Core\Plugin\PluginFormFactoryInterface $plugin_form_manager
* The plugin form manager.
*/
public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, LayoutPluginManagerInterface $layout_manager, ClassResolverInterface $class_resolver, PluginFormFactoryInterface $plugin_form_manager) {
$this->layoutTempstoreRepository = $layout_tempstore_repository;
$this->layoutManager = $layout_manager;
$this->classResolver = $class_resolver;
$this->pluginFormFactory = $plugin_form_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('layout_builder.tempstore_repository'),
$container->get('plugin.manager.core.layout'),
$container->get('class_resolver'),
$container->get('plugin_form.factory')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'layout_builder_configure_section';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL, $plugin_id = NULL) {
$this->entity = $entity;
$this->delta = $delta;
$this->isUpdate = is_null($plugin_id);
$configuration = [];
if ($this->isUpdate) {
/** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */
$field = $this->entity->layout_builder__layout->get($this->delta);
$plugin_id = $field->layout;
$configuration = $field->layout_settings;
}
$this->layout = $this->layoutManager->createInstance($plugin_id, $configuration);
$form['#tree'] = TRUE;
$form['layout_settings'] = [];
$subform_state = SubformState::createForSubform($form['layout_settings'], $form, $form_state);
$form['layout_settings'] = $this->getPluginForm($this->layout)->buildConfigurationForm($form['layout_settings'], $subform_state);
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->isUpdate ? $this->t('Update') : $this->t('Add section'),
'#button_type' => 'primary',
];
if ($this->isAjax()) {
$form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit';
}
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
$subform_state = SubformState::createForSubform($form['layout_settings'], $form, $form_state);
$this->getPluginForm($this->layout)->validateConfigurationForm($form['layout_settings'], $subform_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Call the plugin submit handler.
$subform_state = SubformState::createForSubform($form['layout_settings'], $form, $form_state);
$this->getPluginForm($this->layout)->submitConfigurationForm($form['layout_settings'], $subform_state);
$plugin_id = $this->layout->getPluginId();
$configuration = $this->layout->getConfiguration();
/** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */
$field_list = $this->entity->layout_builder__layout;
if ($this->isUpdate) {
$field = $field_list->get($this->delta);
$field->layout = $plugin_id;
$field->layout_settings = $configuration;
}
else {
$field_list->addItem($this->delta, [
'layout' => $plugin_id,
'layout_settings' => $configuration,
'section' => [],
]);
}
$this->layoutTempstoreRepository->set($this->entity);
$form_state->setRedirectUrl($this->entity->toUrl('layout-builder'));
}
/**
* {@inheritdoc}
*/
protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) {
return $this->rebuildAndClose($this->entity);
}
/**
* Retrieves the plugin form for a given layout.
*
* @param \Drupal\Core\Layout\LayoutInterface $layout
* The layout plugin.
*
* @return \Drupal\Core\Plugin\PluginFormInterface
* The plugin form for the layout.
*/
protected function getPluginForm(LayoutInterface $layout) {
if ($layout instanceof PluginWithFormsInterface) {
return $this->pluginFormFactory->createInstance($layout, 'configure');
}
if ($layout instanceof PluginFormInterface) {
return $layout;
}
throw new \InvalidArgumentException(sprintf('The "%s" layout does not provide a configuration form', $layout->getPluginId()));
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace Drupal\layout_builder\Form;
use Drupal\Core\DependencyInjection\ClassResolverInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\layout_builder\Controller\LayoutRebuildTrait;
use Drupal\layout_builder\LayoutTempstoreRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a base class for confirmation forms that rebuild the Layout Builder.
*
* @internal
*/
abstract class LayoutRebuildConfirmFormBase extends ConfirmFormBase {
use AjaxFormHelperTrait;
use LayoutRebuildTrait;
/**
* The layout tempstore repository.
*
* @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface
*/
protected $layoutTempstoreRepository;
/**
* The entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* The field delta.
*
* @var int
*/
protected $delta;
/**
* Constructs a new RemoveSectionForm.
*
* @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository
* The layout tempstore repository.
* @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
* The class resolver.
*/
public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository, ClassResolverInterface $class_resolver) {
$this->layoutTempstoreRepository = $layout_tempstore_repository;
$this->classResolver = $class_resolver;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('layout_builder.tempstore_repository'),
$container->get('class_resolver')
);
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return $this->entity->toUrl('layout-builder', ['query' => ['layout_is_rebuilding' => TRUE]]);
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL) {
$this->entity = $entity;
$this->delta = $delta;
$form = parent::buildForm($form, $form_state);
if ($this->isAjax()) {
$form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit';
$form['actions']['cancel']['#attributes']['class'][] = 'dialog-cancel';
}
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->handleEntity($this->entity, $form_state);
$this->layoutTempstoreRepository->set($this->entity);
$form_state->setRedirectUrl($this->getCancelUrl());
}
/**
* {@inheritdoc}
*/
protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) {
return $this->rebuildAndClose($this->entity);
}
/**
* Performs any actions on the layout entity before saving.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
abstract protected function handleEntity(EntityInterface $entity, FormStateInterface $form_state);
}

View File

@ -0,0 +1,70 @@
<?php
namespace Drupal\layout_builder\Form;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a form to confirm the removal of a block.
*
* @internal
*/
class RemoveBlockForm extends LayoutRebuildConfirmFormBase {
/**
* The current region.
*
* @var string
*/
protected $region;
/**
* The UUID of the block being removed.
*
* @var string
*/
protected $uuid;
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to remove this block?');
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Remove');
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'layout_builder_remove_block';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL, $region = NULL, $uuid = NULL) {
$this->region = $region;
$this->uuid = $uuid;
return parent::buildForm($form, $form_state, $entity, $delta);
}
/**
* {@inheritdoc}
*/
protected function handleEntity(EntityInterface $entity, FormStateInterface $form_state) {
/** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */
$field = $entity->layout_builder__layout->get($this->delta);
$section = $field->getSection();
$section->removeBlock($this->region, $this->uuid);
$field->updateFromSection($section);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Drupal\layout_builder\Form;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a form to confirm the removal of a section.
*
* @internal
*/
class RemoveSectionForm extends LayoutRebuildConfirmFormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'layout_builder_remove_section';
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to remove this section?');
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Remove');
}
/**
* {@inheritdoc}
*/
protected function handleEntity(EntityInterface $entity, FormStateInterface $form_state) {
$entity->layout_builder__layout->removeItem($this->delta);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace Drupal\layout_builder\Form;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\layout_builder\Section;
/**
* Provides a form to update a block.
*
* @internal
*/
class UpdateBlockForm extends ConfigureBlockFormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'layout_builder_update_block';
}
/**
* Builds the block form.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being configured.
* @param int $delta
* The delta of the section.
* @param string $region
* The region of the block.
* @param string $uuid
* The UUID of the block being updated.
*
* @return array
* The form array.
*/
public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $delta = NULL, $region = NULL, $uuid = NULL) {
/** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface $field */
$field = $entity->layout_builder__layout->get($delta);
$block = $field->getSection()->getBlock($region, $uuid);
if (empty($block['block']['id'])) {
throw new \InvalidArgumentException('Invalid UUID specified');
}
return parent::buildForm($form, $form_state, $entity, $delta, $region, $block['block']['id'], $block['block']);
}
/**
* {@inheritdoc}
*/
protected function submitLabel() {
return $this->t('Update');
}
/**
* {@inheritdoc}
*/
protected function submitBlock(Section $section, $region, $uuid, array $configuration) {
$section->updateBlock($region, $uuid, $configuration);
}
}

View File

@ -0,0 +1,201 @@
<?php
namespace Drupal\layout_builder;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Core\Block\BlockManagerInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Layout\LayoutInterface;
use Drupal\Core\Layout\LayoutPluginManagerInterface;
use Drupal\Core\Plugin\Context\ContextHandlerInterface;
use Drupal\Core\Plugin\Context\ContextRepositoryInterface;
use Drupal\Core\Plugin\ContextAwarePluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Builds the UI for layout sections.
*
* @internal
*/
class LayoutSectionBuilder {
use StringTranslationTrait;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $account;
/**
* The layout plugin manager.
*
* @var \Drupal\Core\Layout\LayoutPluginManagerInterface
*/
protected $layoutPluginManager;
/**
* The block plugin manager.
*
* @var \Drupal\Core\Block\BlockManagerInterface
*/
protected $blockManager;
/**
* The plugin context handler.
*
* @var \Drupal\Core\Plugin\Context\ContextHandlerInterface
*/
protected $contextHandler;
/**
* The context manager service.
*
* @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface
*/
protected $contextRepository;
/**
* Constructs a LayoutSectionFormatter object.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The current user.
* @param \Drupal\Core\Layout\LayoutPluginManagerInterface $layoutPluginManager
* The layout plugin manager.
* @param \Drupal\Core\Block\BlockManagerInterface $blockManager
* THe block plugin manager.
* @param \Drupal\Core\Plugin\Context\ContextHandlerInterface $context_handler
* The ContextHandler for applying contexts to conditions properly.
* @param \Drupal\Core\Plugin\Context\ContextRepositoryInterface $context_repository
* The lazy context repository service.
*/
public function __construct(AccountInterface $account, LayoutPluginManagerInterface $layoutPluginManager, BlockManagerInterface $blockManager, ContextHandlerInterface $context_handler, ContextRepositoryInterface $context_repository) {
$this->account = $account;
$this->layoutPluginManager = $layoutPluginManager;
$this->blockManager = $blockManager;
$this->contextHandler = $context_handler;
$this->contextRepository = $context_repository;
}
/**
* Builds the render array for the layout section.
*
* @param \Drupal\Core\Layout\LayoutInterface $layout
* The ID of the layout.
* @param array $section
* An array of configuration, keyed first by region and then by block UUID.
*
* @return array
* The render array for a given section.
*/
public function buildSectionFromLayout(LayoutInterface $layout, array $section) {
$cacheability = CacheableMetadata::createFromRenderArray([]);
$regions = [];
$weight = 0;
foreach ($section as $region => $blocks) {
if (!is_array($blocks)) {
throw new \InvalidArgumentException(sprintf('The "%s" region in the "%s" layout has invalid configuration', $region, $layout->getPluginId()));
}
foreach ($blocks as $uuid => $configuration) {
if (!is_array($configuration) || !isset($configuration['block'])) {
throw new \InvalidArgumentException(sprintf('The block with UUID of "%s" has invalid configuration', $uuid));
}
if ($block_output = $this->buildBlock($uuid, $configuration['block'], $cacheability)) {
$block_output['#weight'] = $weight++;
$regions[$region][$uuid] = $block_output;
}
}
}
$result = $layout->build($regions);
$cacheability->applyTo($result);
return $result;
}
/**
* Builds the render array for the layout section.
*
* @param string $layout_id
* The ID of the layout.
* @param array $layout_settings
* The configuration for the layout.
* @param array $section
* An array of configuration, keyed first by region and then by block UUID.
*
* @return array
* The render array for a given section.
*/
public function buildSection($layout_id, array $layout_settings, array $section) {
$layout = $this->layoutPluginManager->createInstance($layout_id, $layout_settings);
return $this->buildSectionFromLayout($layout, $section);
}
/**
* Builds the render array for a given block.
*
* @param string $uuid
* The UUID of this block instance.
* @param array $configuration
* An array of configuration relevant to the block instance. Must contain
* the plugin ID with the key 'id'.
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
* The cacheability metadata.
*
* @return array|null
* The render array representing this block, if accessible. NULL otherwise.
*/
protected function buildBlock($uuid, array $configuration, CacheableMetadata $cacheability) {
$block = $this->getBlock($uuid, $configuration);
$access = $block->access($this->account, TRUE);
$cacheability->addCacheableDependency($access);
$block_output = NULL;
if ($access->isAllowed()) {
$block_output = [
'#theme' => 'block',
'#configuration' => $block->getConfiguration(),
'#plugin_id' => $block->getPluginId(),
'#base_plugin_id' => $block->getBaseId(),
'#derivative_plugin_id' => $block->getDerivativeId(),
'content' => $block->build(),
];
$cacheability->addCacheableDependency($block);
}
return $block_output;
}
/**
* Gets a block instance.
*
* @param string $uuid
* The UUID of this block instance.
* @param array $configuration
* An array of configuration relevant to the block instance. Must contain
* the plugin ID with the key 'id'.
*
* @return \Drupal\Core\Block\BlockPluginInterface
* The block instance.
*
* @throws \Drupal\Component\Plugin\Exception\PluginException
* Thrown when the configuration parameter does not contain 'id'.
*/
protected function getBlock($uuid, array $configuration) {
if (!isset($configuration['id'])) {
throw new PluginException(sprintf('No plugin ID specified for block with "%s" UUID', $uuid));
}
$block = $this->blockManager->createInstance($configuration['id'], $configuration);
if ($block instanceof ContextAwarePluginInterface) {
$contexts = $this->contextRepository->getRuntimeContexts(array_values($block->getContextMapping()));
$this->contextHandler->applyContextMapping($block, $contexts);
}
return $block;
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace Drupal\layout_builder;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\user\SharedTempStoreFactory;
/**
* Provides a mechanism for loading layouts from tempstore.
*
* @internal
*/
class LayoutTempstoreRepository implements LayoutTempstoreRepositoryInterface {
/**
* The shared tempstore factory.
*
* @var \Drupal\user\SharedTempStoreFactory
*/
protected $tempStoreFactory;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* LayoutTempstoreRepository constructor.
*
* @param \Drupal\user\SharedTempStoreFactory $temp_store_factory
* The shared tempstore factory.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(SharedTempStoreFactory $temp_store_factory, EntityTypeManagerInterface $entity_type_manager) {
$this->tempStoreFactory = $temp_store_factory;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public function get(EntityInterface $entity) {
$id = $this->generateTempstoreId($entity);
$tempstore = $this->getTempstore($entity)->get($id);
if (!empty($tempstore['entity'])) {
$entity_type_id = $entity->getEntityTypeId();
$entity = $tempstore['entity'];
if (!($entity instanceof EntityInterface)) {
throw new \UnexpectedValueException(sprintf('The entry with entity type "%s" and ID "%s" is not a valid entity', $entity_type_id, $id));
}
}
return $entity;
}
/**
* {@inheritdoc}
*/
public function getFromId($entity_type_id, $entity_id) {
$entity = $this->entityTypeManager->getStorage($entity_type_id)->loadRevision($entity_id);
return $this->get($entity);
}
/**
* {@inheritdoc}
*/
public function set(EntityInterface $entity) {
$id = $this->generateTempstoreId($entity);
$this->getTempstore($entity)->set($id, ['entity' => $entity]);
}
/**
* {@inheritdoc}
*/
public function delete(EntityInterface $entity) {
if ($this->get($entity)) {
$id = $this->generateTempstoreId($entity);
$this->getTempstore($entity)->delete($id);
}
}
/**
* Generates an ID for putting an entity in tempstore.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being stored.
*
* @return string
* The tempstore ID.
*/
protected function generateTempstoreId(EntityInterface $entity) {
$id = "{$entity->id()}.{$entity->language()->getId()}";
if ($entity instanceof RevisionableInterface) {
$id .= '.' . $entity->getRevisionId();
}
return $id;
}
/**
* Gets the shared tempstore.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity being stored.
*
* @return \Drupal\user\SharedTempStore
* The tempstore.
*/
protected function getTempstore(EntityInterface $entity) {
$collection = $entity->getEntityTypeId() . '.layout_builder__layout';
return $this->tempStoreFactory->get($collection);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace Drupal\layout_builder;
use Drupal\Core\Entity\EntityInterface;
/**
* Provides an interface for loading layouts from tempstore.
*
* @internal
* Layout Builder is currently experimental and should only be leveraged by
* experimental modules and development releases of contributed modules.
* See https://www.drupal.org/core/experimental for more information.
*/
interface LayoutTempstoreRepositoryInterface {
/**
* Gets the tempstore version of an entity, if it exists.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check for in tempstore.
*
* @return \Drupal\Core\Entity\EntityInterface
* Either the version of this entity from tempstore, or the passed entity if
* none exists.
*
* @throw \UnexpectedValueException
* Thrown if a value exists, but is not an entity.
*/
public function get(EntityInterface $entity);
/**
* Loads an entity from tempstore given the entity ID.
*
* @param string $entity_type_id
* The entity type ID.
* @param string $entity_id
* The entity ID (or revision ID).
*
* @return \Drupal\Core\Entity\EntityInterface
* Either the version of this entity from tempstore, or the entity from
* storage if none exists.
*
* @throw \UnexpectedValueException
* Thrown if a value exists, but is not an entity.
*/
public function getFromId($entity_type_id, $entity_id);
/**
* Stores this entity in tempstore.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to set in tempstore.
*/
public function set(EntityInterface $entity);
/**
* Removes the tempstore version of an entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to remove from tempstore.
*/
public function delete(EntityInterface $entity);
}

View File

@ -0,0 +1,96 @@
<?php
namespace Drupal\layout_builder\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\layout_builder\Plugin\Menu\LayoutBuilderLocalTask;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides local task definitions for the layout builder user interface.
*
* @internal
*/
class LayoutBuilderLocalTaskDeriver extends DeriverBase implements ContainerDeriverInterface {
use StringTranslationTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new LayoutBuilderLocalTaskDeriver.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id) {
return new static(
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition) {
foreach (array_keys($this->getEntityTypes()) as $entity_type_id) {
$this->derivatives["entity.$entity_type_id.layout_builder"] = $base_plugin_definition + [
'route_name' => "entity.$entity_type_id.layout_builder",
'weight' => 15,
'title' => $this->t('Layout'),
'base_route' => "entity.$entity_type_id.canonical",
'entity_type_id' => $entity_type_id,
'class' => LayoutBuilderLocalTask::class,
'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id],
];
$this->derivatives["entity.$entity_type_id.save_layout"] = $base_plugin_definition + [
'route_name' => "entity.$entity_type_id.save_layout",
'title' => $this->t('Save Layout'),
'parent_id' => "layout_builder_ui:entity.$entity_type_id.layout_builder",
'entity_type_id' => $entity_type_id,
'class' => LayoutBuilderLocalTask::class,
'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id],
];
$this->derivatives["entity.$entity_type_id.cancel_layout"] = $base_plugin_definition + [
'route_name' => "entity.$entity_type_id.cancel_layout",
'title' => $this->t('Cancel Layout'),
'parent_id' => "layout_builder_ui:entity.$entity_type_id.layout_builder",
'entity_type_id' => $entity_type_id,
'class' => LayoutBuilderLocalTask::class,
'weight' => 5,
'cache_contexts' => ['layout_builder_is_active:' . $entity_type_id],
];
}
return $this->derivatives;
}
/**
* Returns an array of relevant entity types.
*
* @return \Drupal\Core\Entity\EntityTypeInterface[]
* An array of entity types.
*/
protected function getEntityTypes() {
return array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $entity_type) {
return $entity_type->hasLinkTemplate('layout-builder');
});
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Drupal\layout_builder\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\layout_builder\LayoutSectionBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'layout_section' formatter.
*
* @internal
*
* @FieldFormatter(
* id = "layout_section",
* label = @Translation("Layout Section"),
* field_types = {
* "layout_section"
* }
* )
*/
class LayoutSectionFormatter extends FormatterBase implements ContainerFactoryPluginInterface {
/**
* The layout section builder.
*
* @var \Drupal\layout_builder\LayoutSectionBuilder
*/
protected $builder;
/**
* Constructs a LayoutSectionFormatter object.
*
* @param \Drupal\layout_builder\LayoutSectionBuilder $builder
* The layout section builder.
* @param string $plugin_id
* The plugin ID for the formatter.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* The definition of the field to which the formatter is associated.
* @param array $settings
* The formatter settings.
* @param string $label
* The formatter label display setting.
* @param string $view_mode
* The view mode.
* @param array $third_party_settings
* Any third party settings.
*/
public function __construct(LayoutSectionBuilder $builder, $plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings) {
$this->builder = $builder;
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$container->get('layout_builder.builder'),
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['label'],
$configuration['view_mode'],
$configuration['third_party_settings']
);
}
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
/** @var \Drupal\layout_builder\Field\LayoutSectionItemInterface[] $items */
foreach ($items as $delta => $item) {
$elements[$delta] = $this->builder->buildSection($item->layout, $item->layout_settings, $item->section);
}
return $elements;
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace Drupal\layout_builder\Plugin\Field\FieldType;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemBase;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\Core\TypedData\MapDataDefinition;
use Drupal\layout_builder\Field\LayoutSectionItemInterface;
use Drupal\layout_builder\Section;
/**
* Plugin implementation of the 'layout_section' field type.
*
* @internal
*
* @FieldType(
* id = "layout_section",
* label = @Translation("Layout Section"),
* description = @Translation("Layout Section"),
* default_formatter = "layout_section",
* list_class = "\Drupal\layout_builder\Field\LayoutSectionItemList",
* no_ui = TRUE,
* cardinality = \Drupal\Core\Field\FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
* )
*/
class LayoutSectionItem extends FieldItemBase implements LayoutSectionItemInterface {
/**
* {@inheritdoc}
*/
public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
// Prevent early t() calls by using the TranslatableMarkup.
$properties['layout'] = DataDefinition::create('string')
->setLabel(new TranslatableMarkup('Layout'))
->setSetting('case_sensitive', FALSE)
->setRequired(TRUE);
$properties['layout_settings'] = MapDataDefinition::create('map')
->setLabel(new TranslatableMarkup('Layout Settings'))
->setRequired(FALSE);
$properties['section'] = MapDataDefinition::create('map')
->setLabel(new TranslatableMarkup('Layout Section'))
->setRequired(FALSE);
return $properties;
}
/**
* {@inheritdoc}
*/
public function __get($name) {
// @todo \Drupal\Core\Field\FieldItemBase::__get() does not return default
// values for uninstantiated properties. This will forcibly instantiate
// all properties with the side-effect of a performance hit, resolve
// properly in https://www.drupal.org/node/2413471.
$this->getProperties();
return parent::__get($name);
}
/**
* {@inheritdoc}
*/
public static function mainPropertyName() {
return 'section';
}
/**
* {@inheritdoc}
*/
public static function schema(FieldStorageDefinitionInterface $field_definition) {
$schema = [
'columns' => [
'layout' => [
'type' => 'varchar',
'length' => '255',
'binary' => FALSE,
],
'layout_settings' => [
'type' => 'blob',
'size' => 'normal',
// @todo Address in https://www.drupal.org/node/2914503.
'serialize' => TRUE,
],
'section' => [
'type' => 'blob',
'size' => 'normal',
// @todo Address in https://www.drupal.org/node/2914503.
'serialize' => TRUE,
],
],
];
return $schema;
}
/**
* {@inheritdoc}
*/
public static function generateSampleValue(FieldDefinitionInterface $field_definition) {
$values['layout'] = 'layout_onecol';
$values['layout_settings'] = [];
// @todo Expand this in https://www.drupal.org/node/2912331.
$values['section'] = [];
return $values;
}
/**
* {@inheritdoc}
*/
public function isEmpty() {
return empty($this->layout);
}
/**
* {@inheritdoc}
*/
public function getSection() {
return new Section($this->section);
}
/**
* {@inheritdoc}
*/
public function updateFromSection(Section $section) {
$this->section = $section->getValue();
return $this;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace Drupal\layout_builder\Plugin\Menu;
use Drupal\Core\Menu\LocalTaskDefault;
use Drupal\Core\Routing\RouteMatchInterface;
/**
* Provides route parameters needed to link to layout related tabs.
*
* @internal
*/
class LayoutBuilderLocalTask extends LocalTaskDefault {
/**
* {@inheritdoc}
*/
public function getRouteParameters(RouteMatchInterface $route_match) {
$parameters = parent::getRouteParameters($route_match);
// @todo Revisit this code once https://www.drupal.org/node/2912363 is in.
$parameters['entity'] = $route_match->getParameter('entity');
return $parameters;
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Drupal\layout_builder\Routing;
use Drupal\Core\Routing\EnhancerInterface;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Route;
/**
* Enhances routes to ensure the entity is available with a generic name.
*
* @internal
*/
class LayoutBuilderRouteEnhancer implements EnhancerInterface {
/**
* Returns whether the enhancer runs on the current route.
*
* @param \Symfony\Component\Routing\Route $route
* The current route.
*
* @return bool
* TRUE if this enhancer applies to this route.
*/
protected function applies(Route $route) {
return $route->getOption('_layout_builder') && $route->getDefault('entity_type_id');
}
/**
* {@inheritdoc}
*/
public function enhance(array $defaults, Request $request) {
$route = $defaults[RouteObjectInterface::ROUTE_OBJECT];
if (!$this->applies($route)) {
return $defaults;
}
$defaults['is_rebuilding'] = (bool) $request->query->get('layout_is_rebuilding', FALSE);
if (!isset($defaults[$defaults['entity_type_id']])) {
throw new \RuntimeException(sprintf('Failed to find the "%s" entity in route named %s', $defaults['entity_type_id'], $defaults[RouteObjectInterface::ROUTE_NAME]));
}
// Copy the entity by reference so that any changes are reflected.
$defaults['entity'] = &$defaults[$defaults['entity_type_id']];
return $defaults;
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace Drupal\layout_builder\Routing;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\Routing\Route;
/**
* Provides routes for the Layout Builder UI.
*
* @internal
*/
class LayoutBuilderRoutes {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity field manager.
*
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
*/
protected $entityFieldManager;
/**
* Constructs a new LayoutBuilderRoutes.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
* The entity field manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager) {
$this->entityTypeManager = $entity_type_manager;
$this->entityFieldManager = $entity_field_manager;
}
/**
* Generates layout builder routes.
*
* @return \Symfony\Component\Routing\Route[]
* An array of route objects.
*/
public function getRoutes() {
$routes = [];
foreach ($this->getEntityTypes() as $entity_type_id => $entity_type) {
$integer_id = $this->hasIntegerId($entity_type);
$template = $entity_type->getLinkTemplate('layout-builder');
$route = (new Route($template))
->setDefaults([
'_controller' => '\Drupal\layout_builder\Controller\LayoutBuilderController::layout',
'_title_callback' => '\Drupal\layout_builder\Controller\LayoutBuilderController::title',
'entity' => NULL,
'entity_type_id' => $entity_type_id,
'is_rebuilding' => FALSE,
])
->addRequirements([
'_has_layout_section' => 'true',
])
->addOptions([
'_layout_builder' => TRUE,
'parameters' => [
$entity_type_id => [
'type' => 'entity:{entity_type_id}',
'layout_builder_tempstore' => TRUE,
],
],
]);
if ($integer_id) {
$route->setRequirement($entity_type_id, '\d+');
}
$routes["entity.$entity_type_id.layout_builder"] = $route;
$route = (new Route("$template/save"))
->setDefaults([
'_controller' => '\Drupal\layout_builder\Controller\LayoutBuilderController::saveLayout',
'entity' => NULL,
'entity_type_id' => $entity_type_id,
])
->addRequirements([
'_has_layout_section' => 'true',
])
->addOptions([
'_layout_builder' => TRUE,
'parameters' => [
$entity_type_id => [
'type' => 'entity:{entity_type_id}',
'layout_builder_tempstore' => TRUE,
],
],
]);
if ($integer_id) {
$route->setRequirement($entity_type_id, '\d+');
}
$routes["entity.$entity_type_id.save_layout"] = $route;
$route = (new Route("$template/cancel"))
->setDefaults([
'_controller' => '\Drupal\layout_builder\Controller\LayoutBuilderController::cancelLayout',
'entity' => NULL,
'entity_type_id' => $entity_type_id,
])
->addRequirements([
'_has_layout_section' => 'true',
])
->addOptions([
'_layout_builder' => TRUE,
'parameters' => [
$entity_type_id => [
'type' => 'entity:{entity_type_id}',
'layout_builder_tempstore' => TRUE,
],
],
]);
if ($integer_id) {
$route->setRequirement($entity_type_id, '\d+');
}
$routes["entity.$entity_type_id.cancel_layout"] = $route;
}
return $routes;
}
/**
* Determines if this entity type's ID is stored as an integer.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* An entity type.
*
* @return bool
* TRUE if this entity type's ID key is always an integer, FALSE otherwise.
*/
protected function hasIntegerId(EntityTypeInterface $entity_type) {
$field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
return $field_storage_definitions[$entity_type->getKey('id')]->getType() === 'integer';
}
/**
* Returns an array of relevant entity types.
*
* @return \Drupal\Core\Entity\EntityTypeInterface[]
* An array of entity types.
*/
protected function getEntityTypes() {
return array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $entity_type) {
return $entity_type->hasLinkTemplate('layout-builder');
});
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Drupal\layout_builder\Routing;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\ParamConverter\EntityConverter;
use Drupal\Core\ParamConverter\ParamConverterInterface;
use Drupal\layout_builder\LayoutTempstoreRepositoryInterface;
use Symfony\Component\Routing\Route;
/**
* Loads the entity from the layout tempstore.
*
* @internal
*/
class LayoutTempstoreParamConverter extends EntityConverter implements ParamConverterInterface {
/**
* The layout tempstore repository.
*
* @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface
*/
protected $layoutTempstoreRepository;
/**
* Constructs a new LayoutTempstoreParamConverter.
*
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The entity manager.
* @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository
* The layout tempstore repository.
*/
public function __construct(EntityManagerInterface $entity_manager, LayoutTempstoreRepositoryInterface $layout_tempstore_repository) {
parent::__construct($entity_manager);
$this->layoutTempstoreRepository = $layout_tempstore_repository;
}
/**
* {@inheritdoc}
*/
public function convert($value, $definition, $name, array $defaults) {
if ($entity = parent::convert($value, $definition, $name, $defaults)) {
return $this->layoutTempstoreRepository->get($entity);
}
}
/**
* {@inheritdoc}
*/
public function applies($definition, $name, Route $route) {
return !empty($definition['layout_builder_tempstore']);
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace Drupal\layout_builder;
/**
* Provides a domain object for layout sections.
*
* A section is a multi-dimensional array, keyed first by region machine name,
* then by block UUID, containing block configuration values.
*/
class Section {
/**
* The section data.
*
* @var array
*/
protected $section;
/**
* Constructs a new Section.
*
* @param array $section
* The section data.
*/
public function __construct(array $section) {
$this->section = $section;
}
/**
* Returns the value of the section.
*
* @return array
* The section data.
*/
public function getValue() {
return $this->section;
}
/**
* Gets the configuration of a given block from a region.
*
* @param string $region
* The region name.
* @param string $uuid
* The UUID of the block to retrieve.
*
* @return array
* The block configuration.
*
* @throws \InvalidArgumentException
* Thrown when the expected region or UUID do not exist.
*/
public function getBlock($region, $uuid) {
if (!isset($this->section[$region])) {
throw new \InvalidArgumentException('Invalid region');
}
if (!isset($this->section[$region][$uuid])) {
throw new \InvalidArgumentException('Invalid UUID');
}
return $this->section[$region][$uuid];
}
/**
* Updates the configuration of a given block from a region.
*
* @param string $region
* The region name.
* @param string $uuid
* The UUID of the block to retrieve.
* @param array $configuration
* The block configuration.
*
* @return $this
*
* @throws \InvalidArgumentException
* Thrown when the expected region or UUID do not exist.
*/
public function updateBlock($region, $uuid, array $configuration) {
if (!isset($this->section[$region])) {
throw new \InvalidArgumentException('Invalid region');
}
if (!isset($this->section[$region][$uuid])) {
throw new \InvalidArgumentException('Invalid UUID');
}
$this->section[$region][$uuid] = $configuration;
return $this;
}
/**
* Removes a given block from a region.
*
* @param string $region
* The region name.
* @param string $uuid
* The UUID of the block to remove.
*
* @return $this
*/
public function removeBlock($region, $uuid) {
unset($this->section[$region][$uuid]);
$this->section = array_filter($this->section);
return $this;
}
/**
* Adds a block to the front of a region.
*
* @param string $region
* The region name.
* @param string $uuid
* The UUID of the block to add.
* @param array $configuration
* The block configuration.
*
* @return $this
*/
public function addBlock($region, $uuid, array $configuration) {
$this->section += [$region => []];
$this->section[$region] = array_merge([$uuid => $configuration], $this->section[$region]);
return $this;
}
/**
* Inserts a block after a specified existing block in a region.
*
* @param string $region
* The region name.
* @param string $uuid
* The UUID of the block to insert.
* @param array $configuration
* The block configuration.
* @param string $preceding_uuid
* The UUID of the existing block to insert after.
*
* @return $this
*
* @throws \InvalidArgumentException
* Thrown when the expected region does not exist.
*/
public function insertBlock($region, $uuid, array $configuration, $preceding_uuid) {
if (!isset($this->section[$region])) {
throw new \InvalidArgumentException('Invalid region');
}
$slice_id = array_search($preceding_uuid, array_keys($this->section[$region]));
if ($slice_id === FALSE) {
throw new \InvalidArgumentException('Invalid preceding UUID');
}
$before = array_slice($this->section[$region], 0, $slice_id + 1);
$after = array_slice($this->section[$region], $slice_id + 1);
$this->section[$region] = array_merge($before, [$uuid => $configuration], $after);
return $this;
}
}

View File

@ -0,0 +1,352 @@
<?php
namespace Drupal\Tests\layout_builder\Functional;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\BrowserTestBase;
/**
* Tests the rendering of a layout section field.
*
* @group layout_builder
*/
class LayoutSectionTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['layout_builder', 'node', 'block_test'];
/**
* The name of the layout section field.
*
* @var string
*/
protected $fieldName = 'layout_builder__layout';
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->createContentType([
'type' => 'bundle_with_section_field',
]);
$this->createContentType([
'type' => 'bundle_without_section_field',
]);
layout_builder_add_layout_section_field('node', 'bundle_with_section_field');
$display = EntityViewDisplay::load('node.bundle_with_section_field.default');
$display->setThirdPartySetting('layout_builder', 'allow_custom', TRUE);
$display->save();
$this->drupalLogin($this->drupalCreateUser([
'configure any layout',
], 'foobar'));
}
/**
* Provides test data for ::testLayoutSectionFormatter().
*/
public function providerTestLayoutSectionFormatter() {
$data = [];
$data['block_with_context'] = [
[
[
'layout' => 'layout_onecol',
'section' => [
'content' => [
'baz' => [
'block' => [
'id' => 'test_context_aware',
'context_mapping' => [
'user' => '@user.current_user_context:current_user',
],
],
],
],
],
],
],
[
'.layout--onecol',
'#test_context_aware--username',
],
[
'foobar',
'User context found',
],
'user',
'user:2',
'UNCACHEABLE',
];
$data['single_section_single_block'] = [
[
[
'layout' => 'layout_onecol',
'section' => [
'content' => [
'baz' => [
'block' => [
'id' => 'system_powered_by_block',
],
],
],
],
],
],
'.layout--onecol',
'Powered by',
'',
'',
'MISS',
];
$data['multiple_sections'] = [
[
[
'layout' => 'layout_onecol',
'section' => [
'content' => [
'baz' => [
'block' => [
'id' => 'system_powered_by_block',
],
],
],
],
],
[
'layout' => 'layout_twocol',
'section' => [
'first' => [
'foo' => [
'block' => [
'id' => 'test_block_instantiation',
'display_message' => 'foo text',
],
],
],
'second' => [
'bar' => [
'block' => [
'id' => 'test_block_instantiation',
'display_message' => 'bar text',
],
],
],
],
],
],
[
'.layout--onecol',
'.layout--twocol',
],
[
'Powered by',
'foo text',
'bar text',
],
'user.permissions',
'',
'MISS',
];
return $data;
}
/**
* Tests layout_section formatter output.
*
* @dataProvider providerTestLayoutSectionFormatter
*/
public function testLayoutSectionFormatter($layout_data, $expected_selector, $expected_content, $expected_cache_contexts, $expected_cache_tags, $expected_dynamic_cache) {
$node = $this->createSectionNode($layout_data);
$this->drupalGet($node->toUrl('canonical'));
$this->assertLayoutSection($expected_selector, $expected_content, $expected_cache_contexts, $expected_cache_tags, $expected_dynamic_cache);
$this->drupalGet($node->toUrl('layout-builder'));
$this->assertLayoutSection($expected_selector, $expected_content, $expected_cache_contexts, $expected_cache_tags, 'UNCACHEABLE');
}
/**
* Tests the access checking of the section formatter.
*/
public function testLayoutSectionFormatterAccess() {
$node = $this->createSectionNode([
[
'layout' => 'layout_onecol',
'section' => [
'content' => [
'baz' => [
'block' => [
'id' => 'test_access',
],
],
],
],
],
]);
// Restrict access to the block.
$this->container->get('state')->set('test_block_access', FALSE);
$this->drupalGet($node->toUrl('canonical'));
$this->assertLayoutSection('.layout--onecol', NULL, '', '', 'UNCACHEABLE');
// Ensure the block was not rendered.
$this->assertSession()->pageTextNotContains('Hello test world');
// Grant access to the block, and ensure it was rendered.
$this->container->get('state')->set('test_block_access', TRUE);
$this->drupalGet($node->toUrl('canonical'));
$this->assertLayoutSection('.layout--onecol', 'Hello test world', '', '', 'UNCACHEABLE');
}
/**
* Tests the multilingual support of the section formatter.
*/
public function testMultilingualLayoutSectionFormatter() {
$this->container->get('module_installer')->install(['content_translation']);
$this->rebuildContainer();
ConfigurableLanguage::createFromLangcode('es')->save();
$this->container->get('content_translation.manager')->setEnabled('node', 'bundle_with_section_field', TRUE);
$entity = $this->createSectionNode([
[
'layout' => 'layout_onecol',
'section' => [
'content' => [
'baz' => [
'block' => [
'id' => 'system_powered_by_block',
],
],
],
],
],
]);
$entity->addTranslation('es', [
'title' => 'Translated node title',
$this->fieldName => [
[
'layout' => 'layout_twocol',
'section' => [
'first' => [
'foo' => [
'block' => [
'id' => 'test_block_instantiation',
'display_message' => 'foo text',
],
],
],
'second' => [
'bar' => [
'block' => [
'id' => 'test_block_instantiation',
'display_message' => 'bar text',
],
],
],
],
],
],
]);
$entity->save();
$this->drupalGet($entity->toUrl('canonical'));
$this->assertLayoutSection('.layout--onecol', 'Powered by');
$this->drupalGet($entity->toUrl('canonical')->setOption('prefix', 'es/'));
$this->assertLayoutSection('.layout--twocol', ['foo text', 'bar text']);
}
/**
* Ensures that the entity title is displayed.
*/
public function testLayoutPageTitle() {
$this->drupalPlaceBlock('page_title_block');
$node = $this->createSectionNode([]);
$this->drupalGet($node->toUrl('layout-builder'));
$this->assertSession()->titleEquals('Edit layout for The node title | Drupal');
$this->assertEquals('Edit layout for The node title', $this->cssSelect('h1.page-title')[0]->getText());
}
/**
* Tests that no Layout link shows without a section field.
*/
public function testLayoutUrlNoSectionField() {
$node = $this->createNode([
'type' => 'bundle_without_section_field',
'title' => 'The node title',
'body' => [
[
'value' => 'The node body',
],
],
]);
$node->save();
$this->drupalGet($node->toUrl('layout-builder'));
$this->assertSession()->statusCodeEquals(403);
}
/**
* Asserts the output of a layout section.
*
* @param string|array $expected_selector
* A selector or list of CSS selectors to find.
* @param string|array $expected_content
* A string or list of strings to find.
* @param string $expected_cache_contexts
* A string of cache contexts to be found in the header.
* @param string $expected_cache_tags
* A string of cache tags to be found in the header.
* @param string $expected_dynamic_cache
* The expected dynamic cache header. Either 'HIT', 'MISS' or 'UNCACHEABLE'.
*/
protected function assertLayoutSection($expected_selector, $expected_content, $expected_cache_contexts = '', $expected_cache_tags = '', $expected_dynamic_cache = 'MISS') {
$assert_session = $this->assertSession();
// Find the given selector.
foreach ((array) $expected_selector as $selector) {
$element = $this->cssSelect($selector);
$this->assertNotEmpty($element);
}
// Find the given content.
foreach ((array) $expected_content as $content) {
$assert_session->pageTextContains($content);
}
if ($expected_cache_contexts) {
$assert_session->responseHeaderContains('X-Drupal-Cache-Contexts', $expected_cache_contexts);
}
if ($expected_cache_tags) {
$assert_session->responseHeaderContains('X-Drupal-Cache-Tags', $expected_cache_tags);
}
$assert_session->responseHeaderEquals('X-Drupal-Dynamic-Cache', $expected_dynamic_cache);
}
/**
* Creates a node with a section field.
*
* @param array $section_values
* An array of values for a section field.
*
* @return \Drupal\node\NodeInterface
* The node object.
*/
protected function createSectionNode(array $section_values) {
return $this->createNode([
'type' => 'bundle_with_section_field',
'title' => 'The node title',
'body' => [
[
'value' => 'The node body',
],
],
$this->fieldName => $section_values,
]);
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace Drupal\Tests\layout_builder\Kernel;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\Core\Entity\EntityInterface;
use Drupal\entity_test\Entity\EntityTestBaseFieldDisplay;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\KernelTests\KernelTestBase;
/**
* Ensures that Layout Builder and Field Layout are compatible with each other.
*
* @group layout_builder
*/
class LayoutBuilderFieldLayoutCompatibilityTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'layout_discovery',
'field_layout',
'user',
'field',
'entity_test',
'system',
'text',
'filter',
];
/**
* The entity view display.
*
* @var \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface
*/
protected $display;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('entity_test_base_field_display');
$this->installEntitySchema('user');
$this->installSchema('system', ['sequences', 'key_value']);
$this->installConfig(['field', 'filter', 'user', 'system']);
\Drupal::service('theme_handler')->install(['classy']);
$this->config('system.theme')->set('default', 'classy')->save();
$field_storage = FieldStorageConfig::create([
'entity_type' => 'entity_test_base_field_display',
'field_name' => 'test_field_display_configurable',
'type' => 'boolean',
]);
$field_storage->save();
FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'entity_test_base_field_display',
'label' => 'FieldConfig with configurable display',
])->save();
$this->display = EntityViewDisplay::create([
'targetEntityType' => 'entity_test_base_field_display',
'bundle' => 'entity_test_base_field_display',
'mode' => 'default',
'status' => TRUE,
]);
$this->display
->setComponent('test_field_display_configurable', ['region' => 'content'])
->setLayoutId('layout_twocol')
->save();
}
/**
* Tests the compatibility of Layout Builder and Field Layout.
*/
public function testCompatibility() {
// Create an entity with fields that are configurable and non-configurable.
$entity_storage = $this->container->get('entity_type.manager')->getStorage('entity_test_base_field_display');
// @todo Remove langcode workarounds after resolving
// https://www.drupal.org/node/2915034.
$entity = $entity_storage->createWithSampleValues('entity_test_base_field_display', [
'langcode' => 'en',
'langcode_default' => TRUE,
]);
$entity->save();
// Ensure that the configurable field is shown in the correct region and
// that the non-configurable field is shown outside the layout.
$original_markup = $this->renderEntity($entity);
$this->assertNotEmpty($this->cssSelect('.layout__region--first .field--name-test-display-configurable'));
$this->assertNotEmpty($this->cssSelect('.layout__region--first .field--name-test-field-display-configurable'));
$this->assertNotEmpty($this->cssSelect('.field--name-test-display-non-configurable'));
$this->assertEmpty($this->cssSelect('.layout__region .field--name-test-display-non-configurable'));
// Install the Layout Builder, configure it for this entity display, and
// reload the entity.
$this->enableModules(['layout_builder']);
$this->display->setThirdPartySetting('layout_builder', 'allow_custom', TRUE)->save();
$entity = EntityTestBaseFieldDisplay::load($entity->id());
// Without using Layout Builder for an override, the result has not changed.
$new_markup = $this->renderEntity($entity);
$this->assertSame($original_markup, $new_markup);
// Add a layout override.
/** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */
$field_list = $entity->layout_builder__layout;
$field_list->appendItem([
'layout' => 'layout_onecol',
'layout_settings' => [],
'section' => [],
]);
$entity->save();
// The rendered entity has now changed. The non-configurable field is shown
// outside the layout, the configurable field is not shown at all, and the
// layout itself is rendered (but empty).
$new_markup = $this->renderEntity($entity);
$this->assertNotSame($original_markup, $new_markup);
$this->assertEmpty($this->cssSelect('.field--name-test-display-configurable'));
$this->assertEmpty($this->cssSelect('.field--name-test-field-display-configurable'));
$this->assertNotEmpty($this->cssSelect('.field--name-test-display-non-configurable'));
$this->assertNotEmpty($this->cssSelect('.layout--onecol'));
// Removing the layout restores the original rendering of the entity.
$field_list->removeItem(0);
$entity->save();
$new_markup = $this->renderEntity($entity);
$this->assertSame($original_markup, $new_markup);
}
/**
* Renders the provided entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to render.
* @param string $view_mode
* (optional) The view mode that should be used to render the entity.
* @param string $langcode
* (optional) For which language the entity should be rendered, defaults to
* the current content language.
*
* @return string
* The rendered string output (typically HTML).
*/
protected function renderEntity(EntityInterface $entity, $view_mode = 'full', $langcode = NULL) {
$view_builder = $this->container->get('entity_type.manager')->getViewBuilder($entity->getEntityTypeId());
$build = $view_builder->view($entity, $view_mode, $langcode);
return $this->render($build);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Drupal\Tests\layout_builder\Kernel;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\layout_builder\Field\LayoutSectionItemInterface;
use Drupal\layout_builder\Field\LayoutSectionItemListInterface;
use Drupal\Tests\field\Kernel\FieldKernelTestBase;
/**
* Tests the field type for Layout Sections.
*
* @group layout_builder
*/
class LayoutSectionItemTest extends FieldKernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['layout_builder', 'layout_discovery'];
/**
* Tests using entity fields of the layout section field type.
*/
public function testLayoutSectionItem() {
layout_builder_add_layout_section_field('entity_test', 'entity_test');
$entity = EntityTest::create();
/** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */
$field_list = $entity->layout_builder__layout;
// Test sample item generation.
$field_list->generateSampleItems();
$this->entityValidateAndSave($entity);
$field = $field_list->get(0);
$this->assertInstanceOf(LayoutSectionItemInterface::class, $field);
$this->assertInstanceOf(FieldItemInterface::class, $field);
$this->assertSame('section', $field->mainPropertyName());
$this->assertSame('layout_onecol', $field->layout);
$this->assertSame([], $field->layout_settings);
$this->assertSame([], $field->section);
}
/**
* {@inheritdoc}
*/
public function testLayoutSectionItemList() {
layout_builder_add_layout_section_field('entity_test', 'entity_test');
$entity = EntityTest::create();
/** @var \Drupal\layout_builder\Field\LayoutSectionItemListInterface $field_list */
$field_list = $entity->layout_builder__layout;
$this->assertInstanceOf(LayoutSectionItemListInterface::class, $field_list);
$this->assertInstanceOf(FieldItemListInterface::class, $field_list);
$entity->save();
$field_list->appendItem(['layout' => 'layout_twocol']);
$field_list->appendItem(['layout' => 'layout_onecol']);
$field_list->appendItem(['layout' => 'layout_threecol_25_50_25']);
$this->assertSame([
['layout' => 'layout_twocol'],
['layout' => 'layout_onecol'],
['layout' => 'layout_threecol_25_50_25'],
], $field_list->getValue());
$field_list->addItem(1, ['layout' => 'layout_threecol_33_34_33']);
$this->assertSame([
['layout' => 'layout_twocol'],
['layout' => 'layout_threecol_33_34_33'],
['layout' => 'layout_onecol'],
['layout' => 'layout_threecol_25_50_25'],
], $field_list->getValue());
$field_list->addItem($field_list->count(), ['layout' => 'layout_twocol_bricks']);
$this->assertSame([
['layout' => 'layout_twocol'],
['layout' => 'layout_threecol_33_34_33'],
['layout' => 'layout_onecol'],
['layout' => 'layout_threecol_25_50_25'],
['layout' => 'layout_twocol_bricks'],
], $field_list->getValue());
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace Drupal\Tests\layout_builder\Unit;
use Drupal\layout_builder\Routing\LayoutBuilderRouteEnhancer;
use Drupal\Tests\UnitTestCase;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Route;
/**
* @coversDefaultClass \Drupal\layout_builder\Routing\LayoutBuilderRouteEnhancer
* @group layout_builder
*/
class LayoutBuilderRouteEnhancerTest extends UnitTestCase {
/**
* @covers ::applies
* @dataProvider providerTestApplies
*/
public function testApplies($defaults, $options, $expected) {
$route_enhancer = new LayoutBuilderRouteEnhancer();
$route = new Route('/some/path', $defaults, [], $options);
$reflection_method = new \ReflectionMethod($route_enhancer, 'applies');
$reflection_method->setAccessible(TRUE);
$result = $reflection_method->invoke($route_enhancer, $route);
$this->assertSame($expected, $result);
}
/**
* Provides test data for ::testApplies().
*/
public function providerTestApplies() {
$data = [];
$data['layout_builder_true'] = [
['entity_type_id' => 'the_entity_type'],
['_layout_builder' => TRUE],
TRUE,
];
$data['layout_builder_false'] = [
['entity_type_id' => 'the_entity_type'],
['_layout_builder' => FALSE],
FALSE,
];
$data['layout_builder_null'] = [
['entity_type_id' => 'the_entity_type'],
['_layout_builder' => NULL],
FALSE,
];
$data['entity_type_id_empty'] = [
['entity_type_id' => ''],
['_layout_builder' => TRUE],
FALSE,
];
$data['no_entity_type_id'] = [
[],
['_layout_builder' => TRUE],
FALSE,
];
$data['no_layout_builder'] = [
['entity_type_id' => 'the_entity_type'],
[],
FALSE,
];
$data['empty'] = [
[],
[],
FALSE,
];
return $data;
}
/**
* @covers ::enhance
*/
public function testEnhanceValidDefaults() {
$route = new Route('/the/path', ['entity_type_id' => 'the_entity_type'], [], ['_layout_builder' => TRUE]);
$route_enhancer = new LayoutBuilderRouteEnhancer();
$object = new \stdClass();
$defaults = [
'entity_type_id' => 'the_entity_type',
'the_entity_type' => $object,
RouteObjectInterface::ROUTE_NAME => 'the_route_name',
RouteObjectInterface::ROUTE_OBJECT => $route,
];
// Ensure that the 'entity' key now contains the value stored for a given
// entity type.
$expected = [
'entity_type_id' => 'the_entity_type',
'the_entity_type' => $object,
RouteObjectInterface::ROUTE_NAME => 'the_route_name',
RouteObjectInterface::ROUTE_OBJECT => $route,
'entity' => $object,
'is_rebuilding' => TRUE,
];
$result = $route_enhancer->enhance($defaults, new Request(['layout_is_rebuilding' => TRUE]));
$this->assertEquals($expected, $result);
$expected['is_rebuilding'] = FALSE;
$result = $route_enhancer->enhance($defaults, new Request());
$this->assertEquals($expected, $result);
$this->assertSame($object, $result['entity']);
// Modifying the original value updates the 'entity' copy.
$result['the_entity_type'] = 'something else';
$this->assertSame('something else', $result['entity']);
}
/**
* @covers ::enhance
*/
public function testEnhanceMissingEntity() {
$route_enhancer = new LayoutBuilderRouteEnhancer();
$route = new Route('/the/path', ['entity_type_id' => 'the_entity_type'], [], ['_layout_builder' => TRUE]);
$defaults = [
RouteObjectInterface::ROUTE_NAME => 'the_route',
RouteObjectInterface::ROUTE_OBJECT => $route,
'entity_type_id' => 'the_entity_type',
];
$this->setExpectedException(\RuntimeException::class, 'Failed to find the "the_entity_type" entity in route named the_route');
$route_enhancer->enhance($defaults, new Request());
}
/**
* Provides test data for ::testEnhanceException().
*/
public function providerTestEnhanceException() {
$data = [];
$data['missing_entity'] = [
[
RouteObjectInterface::ROUTE_NAME => 'the_route',
'entity_type_id' => 'the_entity_type',
],
'Failed to find the "the_entity_type" entity in route named the_route',
];
$data['missing_entity_type_id'] = [
[
RouteObjectInterface::ROUTE_NAME => 'the_route',
],
'Failed to find an entity type ID in route named the_route',
];
return $data;
}
}

View File

@ -0,0 +1,301 @@
<?php
namespace Drupal\Tests\layout_builder\Unit;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Block\BlockManagerInterface;
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Layout\LayoutInterface;
use Drupal\Core\Layout\LayoutPluginManagerInterface;
use Drupal\Core\Plugin\Context\ContextHandlerInterface;
use Drupal\Core\Plugin\Context\ContextRepositoryInterface;
use Drupal\Core\Plugin\ContextAwarePluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\layout_builder\LayoutSectionBuilder;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
/**
* @coversDefaultClass \Drupal\layout_builder\LayoutSectionBuilder
* @group layout_builder
*/
class LayoutSectionBuilderTest extends UnitTestCase {
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $account;
/**
* The layout plugin manager.
*
* @var \Drupal\Core\Layout\LayoutPluginManagerInterface
*/
protected $layoutPluginManager;
/**
* The block plugin manager.
*
* @var \Drupal\Core\Block\BlockManagerInterface
*/
protected $blockManager;
/**
* The plugin context handler.
*
* @var \Drupal\Core\Plugin\Context\ContextHandlerInterface
*/
protected $contextHandler;
/**
* The context manager service.
*
* @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface
*/
protected $contextRepository;
/**
* The object under test.
*
* @var \Drupal\layout_builder\LayoutSectionBuilder
*/
protected $layoutSectionBuilder;
/**
* The layout plugin.
*
* @var \Drupal\Core\Layout\LayoutInterface
*/
protected $layout;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->account = $this->prophesize(AccountInterface::class);
$this->layoutPluginManager = $this->prophesize(LayoutPluginManagerInterface::class);
$this->blockManager = $this->prophesize(BlockManagerInterface::class);
$this->contextHandler = $this->prophesize(ContextHandlerInterface::class);
$this->contextRepository = $this->prophesize(ContextRepositoryInterface::class);
$this->layoutSectionBuilder = new LayoutSectionBuilder($this->account->reveal(), $this->layoutPluginManager->reveal(), $this->blockManager->reveal(), $this->contextHandler->reveal(), $this->contextRepository->reveal());
$this->layout = $this->prophesize(LayoutInterface::class);
$this->layoutPluginManager->createInstance('layout_onecol', [])->willReturn($this->layout->reveal());
}
/**
* @covers ::buildSection
*/
public function testBuildSection() {
$block_content = ['#markup' => 'The block content.'];
$render_array = [
'#theme' => 'block',
'#weight' => 0,
'#configuration' => [],
'#plugin_id' => 'block_plugin_id',
'#base_plugin_id' => 'block_plugin_id',
'#derivative_plugin_id' => NULL,
'content' => $block_content,
];
$this->layout->build(['content' => ['some_uuid' => $render_array]])->willReturnArgument(0);
$block = $this->prophesize(BlockPluginInterface::class);
$this->blockManager->createInstance('block_plugin_id', ['id' => 'block_plugin_id'])->willReturn($block->reveal());
$access_result = AccessResult::allowed();
$block->access($this->account->reveal(), TRUE)->willReturn($access_result);
$block->build()->willReturn($block_content);
$block->getCacheContexts()->willReturn([]);
$block->getCacheTags()->willReturn([]);
$block->getCacheMaxAge()->willReturn(Cache::PERMANENT);
$block->getPluginId()->willReturn('block_plugin_id');
$block->getBaseId()->willReturn('block_plugin_id');
$block->getDerivativeId()->willReturn(NULL);
$block->getConfiguration()->willReturn([]);
$section = [
'content' => [
'some_uuid' => [
'block' => [
'id' => 'block_plugin_id',
],
],
],
];
$expected = [
'#cache' => [
'contexts' => [],
'tags' => [],
'max-age' => -1,
],
'content' => [
'some_uuid' => $render_array,
],
];
$result = $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section);
$this->assertEquals($expected, $result);
}
/**
* @covers ::buildSection
*/
public function testBuildSectionAccessDenied() {
$this->layout->build([])->willReturn([]);
$block = $this->prophesize(BlockPluginInterface::class);
$this->blockManager->createInstance('block_plugin_id', ['id' => 'block_plugin_id'])->willReturn($block->reveal());
$access_result = AccessResult::forbidden();
$block->access($this->account->reveal(), TRUE)->willReturn($access_result);
$block->build()->shouldNotBeCalled();
$section = [
'content' => [
'some_uuid' => [
'block' => [
'id' => 'block_plugin_id',
],
],
],
];
$expected = [
'#cache' => [
'contexts' => [],
'tags' => [],
'max-age' => -1,
],
];
$result = $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section);
$this->assertEquals($expected, $result);
}
/**
* @covers ::buildSection
*/
public function testBuildSectionEmpty() {
$this->layout->build([])->willReturn([]);
$section = [];
$expected = [
'#cache' => [
'contexts' => [],
'tags' => [],
'max-age' => -1,
],
];
$result = $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section);
$this->assertEquals($expected, $result);
}
/**
* @covers ::buildSection
* @covers ::getBlock
*/
public function testContextAwareBlock() {
$render_array = [
'#theme' => 'block',
'#weight' => 0,
'#configuration' => [],
'#plugin_id' => 'block_plugin_id',
'#base_plugin_id' => 'block_plugin_id',
'#derivative_plugin_id' => NULL,
'content' => [],
];
$this->layout->build(['content' => ['some_uuid' => $render_array]])->willReturnArgument(0);
$block = $this->prophesize(BlockPluginInterface::class)->willImplement(ContextAwarePluginInterface::class);
$this->blockManager->createInstance('block_plugin_id', ['id' => 'block_plugin_id'])->willReturn($block->reveal());
$access_result = AccessResult::allowed();
$block->access($this->account->reveal(), TRUE)->willReturn($access_result);
$block->build()->willReturn([]);
$block->getCacheContexts()->willReturn([]);
$block->getCacheTags()->willReturn([]);
$block->getCacheMaxAge()->willReturn(Cache::PERMANENT);
$block->getContextMapping()->willReturn([]);
$block->getPluginId()->willReturn('block_plugin_id');
$block->getBaseId()->willReturn('block_plugin_id');
$block->getDerivativeId()->willReturn(NULL);
$block->getConfiguration()->willReturn([]);
$this->contextRepository->getRuntimeContexts([])->willReturn([]);
$this->contextHandler->applyContextMapping($block->reveal(), [])->shouldBeCalled();
$section = [
'content' => [
'some_uuid' => [
'block' => [
'id' => 'block_plugin_id',
],
],
],
];
$expected = [
'#cache' => [
'contexts' => [],
'tags' => [],
'max-age' => -1,
],
'content' => [
'some_uuid' => $render_array,
],
];
$result = $this->layoutSectionBuilder->buildSection('layout_onecol', [], $section);
$this->assertEquals($expected, $result);
}
/**
* @covers ::buildSection
* @covers ::getBlock
*/
public function testBuildSectionMissingPluginId() {
$section = [
'content' => [
'some_uuid' => [
'block' => [],
],
],
];
$this->setExpectedException(PluginException::class, 'No plugin ID specified for block with "some_uuid" UUID');
$this->layoutSectionBuilder->buildSection('layout_onecol', [], $section);
}
/**
* @covers ::buildSection
*
* @dataProvider providerTestBuildSectionMalformedData
*/
public function testBuildSectionMalformedData($section, $message) {
$this->layout->build(Argument::type('array'))->willReturnArgument(0);
$this->layout->getPluginId()->willReturn('the_plugin_id');
$this->setExpectedException(\InvalidArgumentException::class, $message);
$this->layoutSectionBuilder->buildSection('layout_onecol', [], $section);
}
/**
* Provides test data for ::testBuildSectionMalformedData().
*/
public function providerTestBuildSectionMalformedData() {
$data = [];
$data['invalid_region'] = [
['content' => 'bar'],
'The "content" region in the "the_plugin_id" layout has invalid configuration',
];
$data['invalid_configuration'] = [
['content' => ['some_uuid' => 'bar']],
'The block with UUID of "some_uuid" has invalid configuration',
];
$data['invalid_blocks'] = [
['content' => ['some_uuid' => []]],
'The block with UUID of "some_uuid" has invalid configuration',
];
return $data;
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace Drupal\Tests\layout_builder\Unit;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Language\Language;
use Drupal\layout_builder\LayoutTempstoreRepository;
use Drupal\Tests\UnitTestCase;
use Drupal\user\SharedTempStore;
use Drupal\user\SharedTempStoreFactory;
/**
* @coversDefaultClass \Drupal\layout_builder\LayoutTempstoreRepository
* @group layout_builder
*/
class LayoutTempstoreRepositoryTest extends UnitTestCase {
/**
* @covers ::getFromId
* @covers ::get
* @covers ::generateTempstoreId
*/
public function testGetFromIdEmptyTempstore() {
$tempstore = $this->prophesize(SharedTempStore::class);
$tempstore->get('the_entity_id.en')->shouldBeCalled();
$tempstore_factory = $this->prophesize(SharedTempStoreFactory::class);
$tempstore_factory->get('the_entity_type_id.layout_builder__layout')->willReturn($tempstore->reveal());
$entity = $this->prophesize(EntityInterface::class);
$entity->getEntityTypeId()->willReturn('the_entity_type_id');
$entity->id()->willReturn('the_entity_id');
$entity->language()->willReturn(new Language(['id' => 'en']));
$entity_storage = $this->prophesize(EntityStorageInterface::class);
$entity_storage->loadRevision('the_entity_id')->willReturn($entity->reveal());
$entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
$entity_type_manager->getStorage('the_entity_type_id')->willReturn($entity_storage->reveal());
$repository = new LayoutTempstoreRepository($tempstore_factory->reveal(), $entity_type_manager->reveal());
$result = $repository->getFromId('the_entity_type_id', 'the_entity_id');
$this->assertSame($entity->reveal(), $result);
}
/**
* @covers ::getFromId
* @covers ::get
* @covers ::generateTempstoreId
*/
public function testGetFromIdLoadedTempstore() {
$tempstore_entity = $this->prophesize(EntityInterface::class);
$tempstore = $this->prophesize(SharedTempStore::class);
$tempstore->get('the_entity_id.en')->willReturn(['entity' => $tempstore_entity->reveal()]);
$tempstore_factory = $this->prophesize(SharedTempStoreFactory::class);
$tempstore_factory->get('the_entity_type_id.layout_builder__layout')->willReturn($tempstore->reveal());
$entity = $this->prophesize(EntityInterface::class);
$entity->getEntityTypeId()->willReturn('the_entity_type_id');
$entity->id()->willReturn('the_entity_id');
$entity->language()->willReturn(new Language(['id' => 'en']));
$entity_storage = $this->prophesize(EntityStorageInterface::class);
$entity_storage->loadRevision('the_entity_id')->willReturn($entity->reveal());
$entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
$entity_type_manager->getStorage('the_entity_type_id')->willReturn($entity_storage->reveal());
$repository = new LayoutTempstoreRepository($tempstore_factory->reveal(), $entity_type_manager->reveal());
$result = $repository->getFromId('the_entity_type_id', 'the_entity_id');
$this->assertSame($tempstore_entity->reveal(), $result);
$this->assertNotSame($entity->reveal(), $result);
}
/**
* @covers ::getFromId
* @covers ::get
* @covers ::generateTempstoreId
*/
public function testGetFromIdRevisionable() {
$tempstore = $this->prophesize(SharedTempStore::class);
$tempstore->get('the_entity_id.en.the_revision_id')->shouldBeCalled();
$tempstore_factory = $this->prophesize(SharedTempStoreFactory::class);
$tempstore_factory->get('the_entity_type_id.layout_builder__layout')->willReturn($tempstore->reveal());
$entity = $this->prophesize(EntityInterface::class)->willImplement(RevisionableInterface::class);
$entity->getEntityTypeId()->willReturn('the_entity_type_id');
$entity->id()->willReturn('the_entity_id');
$entity->language()->willReturn(new Language(['id' => 'en']));
$entity->getRevisionId()->willReturn('the_revision_id');
$entity_storage = $this->prophesize(EntityStorageInterface::class);
$entity_storage->loadRevision('the_entity_id')->willReturn($entity->reveal());
$entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
$entity_type_manager->getStorage('the_entity_type_id')->willReturn($entity_storage->reveal());
$repository = new LayoutTempstoreRepository($tempstore_factory->reveal(), $entity_type_manager->reveal());
$result = $repository->getFromId('the_entity_type_id', 'the_entity_id');
$this->assertSame($entity->reveal(), $result);
}
/**
* @covers ::get
*/
public function testGetInvalidEntity() {
$tempstore = $this->prophesize(SharedTempStore::class);
$tempstore->get('the_entity_id.en')->willReturn(['entity' => 'this_is_not_an_entity']);
$tempstore_factory = $this->prophesize(SharedTempStoreFactory::class);
$tempstore_factory->get('the_entity_type_id.layout_builder__layout')->willReturn($tempstore->reveal());
$entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
$repository = new LayoutTempstoreRepository($tempstore_factory->reveal(), $entity_type_manager->reveal());
$entity = $this->prophesize(EntityInterface::class);
$entity->language()->willReturn(new Language(['id' => 'en']));
$entity->getEntityTypeId()->willReturn('the_entity_type_id');
$entity->id()->willReturn('the_entity_id');
$this->setExpectedException(\UnexpectedValueException::class, 'The entry with entity type "the_entity_type_id" and ID "the_entity_id.en" is not a valid entity');
$repository->get($entity->reveal());
}
}

View File

@ -0,0 +1,265 @@
<?php
namespace Drupal\Tests\layout_builder\Unit;
use Drupal\layout_builder\Section;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\layout_builder\Section
* @group layout_builder
*/
class SectionTest extends UnitTestCase {
/**
* The section object to test.
*
* @var \Drupal\layout_builder\Section
*/
protected $section;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->section = new Section([
'empty-region' => [],
'some-region' => [
'existing-uuid' => [
'block' => [
'id' => 'existing-block-id',
],
],
],
'ordered-region' => [
'first-uuid' => [
'block' => [
'id' => 'first-block-id',
],
],
'second-uuid' => [
'block' => [
'id' => 'second-block-id',
],
],
],
]);
}
/**
* @covers ::__construct
* @covers ::getValue
*/
public function testGetValue() {
$expected = [
'empty-region' => [],
'some-region' => [
'existing-uuid' => [
'block' => [
'id' => 'existing-block-id',
],
],
],
'ordered-region' => [
'first-uuid' => [
'block' => [
'id' => 'first-block-id',
],
],
'second-uuid' => [
'block' => [
'id' => 'second-block-id',
],
],
],
];
$result = $this->section->getValue();
$this->assertSame($expected, $result);
}
/**
* @covers ::getBlock
*/
public function testGetBlockInvalidRegion() {
$this->setExpectedException(\InvalidArgumentException::class, 'Invalid region');
$this->section->getBlock('invalid-region', 'existing-uuid');
}
/**
* @covers ::getBlock
*/
public function testGetBlockInvalidUuid() {
$this->setExpectedException(\InvalidArgumentException::class, 'Invalid UUID');
$this->section->getBlock('some-region', 'invalid-uuid');
}
/**
* @covers ::getBlock
*/
public function testGetBlock() {
$expected = ['block' => ['id' => 'existing-block-id']];
$block = $this->section->getBlock('some-region', 'existing-uuid');
$this->assertSame($expected, $block);
}
/**
* @covers ::removeBlock
*/
public function testRemoveBlock() {
$this->section->removeBlock('some-region', 'existing-uuid');
$expected = [
'ordered-region' => [
'first-uuid' => [
'block' => [
'id' => 'first-block-id',
],
],
'second-uuid' => [
'block' => [
'id' => 'second-block-id',
],
],
],
];
$this->assertSame($expected, $this->section->getValue());
}
/**
* @covers ::addBlock
*/
public function testAddBlock() {
$this->section->addBlock('some-region', 'new-uuid', []);
$expected = [
'empty-region' => [],
'some-region' => [
'new-uuid' => [],
'existing-uuid' => [
'block' => [
'id' => 'existing-block-id',
],
],
],
'ordered-region' => [
'first-uuid' => [
'block' => [
'id' => 'first-block-id',
],
],
'second-uuid' => [
'block' => [
'id' => 'second-block-id',
],
],
],
];
$this->assertSame($expected, $this->section->getValue());
}
/**
* @covers ::insertBlock
*/
public function testInsertBlock() {
$this->section->insertBlock('ordered-region', 'new-uuid', [], 'first-uuid');
$expected = [
'empty-region' => [],
'some-region' => [
'existing-uuid' => [
'block' => [
'id' => 'existing-block-id',
],
],
],
'ordered-region' => [
'first-uuid' => [
'block' => [
'id' => 'first-block-id',
],
],
'new-uuid' => [],
'second-uuid' => [
'block' => [
'id' => 'second-block-id',
],
],
],
];
$this->assertSame($expected, $this->section->getValue());
}
/**
* @covers ::insertBlock
*/
public function testInsertBlockInvalidRegion() {
$this->setExpectedException(\InvalidArgumentException::class, 'Invalid region');
$this->section->insertBlock('invalid-region', 'new-uuid', [], 'first-uuid');
}
/**
* @covers ::insertBlock
*/
public function testInsertBlockInvalidUuid() {
$this->setExpectedException(\InvalidArgumentException::class, 'Invalid preceding UUID');
$this->section->insertBlock('ordered-region', 'new-uuid', [], 'invalid-uuid');
}
/**
* @covers ::updateBlock
*/
public function testUpdateBlock() {
$this->section->updateBlock('some-region', 'existing-uuid', [
'block' => [
'id' => 'existing-block-id',
'settings' => [
'foo' => 'bar',
],
],
]);
$expected = [
'empty-region' => [],
'some-region' => [
'existing-uuid' => [
'block' => [
'id' => 'existing-block-id',
'settings' => [
'foo' => 'bar',
],
],
],
],
'ordered-region' => [
'first-uuid' => [
'block' => [
'id' => 'first-block-id',
],
],
'second-uuid' => [
'block' => [
'id' => 'second-block-id',
],
],
],
];
$this->assertSame($expected, $this->section->getValue());
}
/**
* @covers ::updateBlock
*/
public function testUpdateBlockInvalidRegion() {
$this->setExpectedException(\InvalidArgumentException::class, 'Invalid region');
$this->section->updateBlock('invalid-region', 'new-uuid', []);
}
/**
* @covers ::updateBlock
*/
public function testUpdateBlockInvalidUuid() {
$this->setExpectedException(\InvalidArgumentException::class, 'Invalid UUID');
$this->section->updateBlock('ordered-region', 'new-uuid', []);
}
}