diff --git a/core/composer.json b/core/composer.json index 71f07d0..72f5327 100644 --- a/core/composer.json +++ b/core/composer.json @@ -105,7 +105,6 @@ "drupal/image": "self.version", "drupal/inline_form_errors": "self.version", "drupal/language": "self.version", - "drupal/layout_discovery": "self.version", "drupal/link": "self.version", "drupal/locale": "self.version", "drupal/minimal": "self.version", diff --git a/core/config/schema/core.entity.schema.yml b/core/config/schema/core.entity.schema.yml index 711ca0f..e05a71e 100644 --- a/core/config/schema/core.entity.schema.yml +++ b/core/config/schema/core.entity.schema.yml @@ -84,6 +84,12 @@ core.entity_view_display.*.*.*: sequence: type: boolean label: 'Value' + layout_id: + type: string + label: 'Layout ID' + layout_settings: + type: layout_plugin.settings.[%parent.layout_id] + label: 'Layout settings' # Overview configuration information for form mode displays. core.entity_form_display.*.*.*: @@ -135,6 +141,12 @@ core.entity_form_display.*.*.*: sequence: type: boolean label: 'Component' + layout_id: + type: string + label: 'Layout ID' + layout_settings: + type: layout_plugin.settings.[%parent.layout_id] + label: 'Layout settings' # Default schema for entity display field with undefined type. field.formatter.settings.*: diff --git a/core/core.services.yml b/core/core.services.yml index 8bce755..1b753cb 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -640,6 +640,9 @@ services: plugin.manager.display_variant: class: Drupal\Core\Display\VariantManager parent: default_plugin_manager + plugin.manager.core.layout: + class: Drupal\Core\Layout\LayoutPluginManager + arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@theme_handler'] plugin.manager.queue_worker: class: Drupal\Core\Queue\QueueWorkerManager parent: default_plugin_manager diff --git a/core/includes/theme.inc b/core/includes/theme.inc index f180cbd..b0fed54 100644 --- a/core/includes/theme.inc +++ b/core/includes/theme.inc @@ -1697,6 +1697,19 @@ function template_preprocess_breadcrumb(&$variables) { } /** + * Prepares variables for layout templates. + * + * @param array &$variables + * An associative array containing: + * - content: An associative array containing the properties of the element. + * Properties used: #settings, #layout. + */ +function template_preprocess_layout(&$variables) { + $variables['settings'] = isset($variables['content']['#settings']) ? $variables['content']['#settings'] : []; + $variables['layout'] = isset($variables['content']['#layout']) ? $variables['content']['#layout'] : []; +} + +/** * Callback for usort() within template_preprocess_field_multiple_value_form(). * * Sorts using ['_weight']['#value'] diff --git a/core/lib/Drupal/Core/Entity/Display/EntityDisplayWithLayoutInterface.php b/core/lib/Drupal/Core/Entity/Display/EntityDisplayWithLayoutInterface.php new file mode 100644 index 0000000..8c14d63 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Display/EntityDisplayWithLayoutInterface.php @@ -0,0 +1,66 @@ +layoutPluginManager = $layout_plugin_manager; + $this->entityFieldManager = $entity_field_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('plugin.manager.core.layout'), + $container->get('entity_field.manager') + ); + } + + /** + * Applies the layout to an entity build. + * + * @param array $build + * A renderable array representing the entity content or form. + * @param \Drupal\Core\Entity\Display\EntityDisplayWithLayoutInterface $display + * The entity display holding the display options configured for the entity + * components. + */ + public function buildView(array &$build, EntityDisplayWithLayoutInterface $display) { + $layout_definition = $this->layoutPluginManager->getDefinition($display->getLayoutId(), FALSE); + if ($layout_definition && $fields = $this->getFields($build, $display, 'view')) { + // Add the regions to the $build in the correct order. + $regions = array_fill_keys($layout_definition->getRegionNames(), []); + + foreach ($fields as $name => $field) { + // Move the field from the top-level of $build into a region-specific + // section. + // @todo Ideally the array structure would remain unchanged, see + // https://www.drupal.org/node/2846393. + $regions[$field['region']][$name] = $build[$name]; + unset($build[$name]); + } + $build['layout'] = $display->getLayout()->build($regions); + } + } + + /** + * Applies the layout to an entity form. + * + * @param array $build + * A renderable array representing the entity content or form. + * @param \Drupal\Core\Entity\Display\EntityDisplayWithLayoutInterface $display + * The entity display holding the display options configured for the entity + * components. + */ + public function buildForm(array &$build, EntityDisplayWithLayoutInterface $display) { + $layout_definition = $this->layoutPluginManager->getDefinition($display->getLayoutId(), FALSE); + if ($layout_definition && $fields = $this->getFields($build, $display, 'form')) { + $fill = []; + $fill['#process'][] = '\Drupal\Core\Render\Element\RenderElement::processGroup'; + $fill['#pre_render'][] = '\Drupal\Core\Render\Element\RenderElement::preRenderGroup'; + // Add the regions to the $build in the correct order. + $regions = array_fill_keys($layout_definition->getRegionNames(), $fill); + + foreach ($fields as $name => $field) { + // As this is a form, #group can be used to relocate the fields. This + // avoids breaking hook_form_alter() implementations by not actually + // moving the field in the form structure. If a #group is already set, + // do not overwrite it. + if (!isset($build[$name]['#group'])) { + $build[$name]['#group'] = $field['region']; + } + } + $build['layout'] = $display->getLayout()->build($regions); + } + } + + /** + * Gets the fields that need to be processed. + * + * @param array $build + * A renderable array representing the entity content or form. + * @param \Drupal\Core\Entity\Display\EntityDisplayWithLayoutInterface $display + * The entity display holding the display options configured for the entity + * components. + * @param string $display_context + * The display context, either 'form' or 'view'. + * + * @return array + * An array of configurable fields present in the build. + */ + protected function getFields(array $build, EntityDisplayWithLayoutInterface $display, $display_context) { + $components = $display->getComponents(); + + // Ignore any extra fields from the list of field definitions. Field + // definitions can have a non-configurable display, but all extra fields are + // always displayed. + $field_definitions = array_diff_key( + $this->entityFieldManager->getFieldDefinitions($display->getTargetEntityTypeId(), $display->getTargetBundle()), + $this->entityFieldManager->getExtraFields($display->getTargetEntityTypeId(), $display->getTargetBundle()) + ); + + $fields_to_exclude = array_filter($field_definitions, function (FieldDefinitionInterface $field_definition) use ($display_context) { + // Remove fields with a non-configurable display. + return !$field_definition->isDisplayConfigurable($display_context); + }); + $components = array_diff_key($components, $fields_to_exclude); + + // Only include fields present in the build. + $components = array_intersect_key($components, $build); + + return $components; + } + +} diff --git a/core/lib/Drupal/Core/Entity/Entity/EntityFormDisplay.php b/core/lib/Drupal/Core/Entity/Entity/EntityFormDisplay.php index cd90ecc..b9f4daf 100644 --- a/core/lib/Drupal/Core/Entity/Entity/EntityFormDisplay.php +++ b/core/lib/Drupal/Core/Entity/Entity/EntityFormDisplay.php @@ -30,6 +30,8 @@ * "mode", * "content", * "hidden", + * "layout_id", + * "layout_settings", * } * ) */ diff --git a/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php b/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php index a6b83cf..13f16fe 100644 --- a/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php +++ b/core/lib/Drupal/Core/Entity/Entity/EntityViewDisplay.php @@ -28,6 +28,8 @@ * "mode", * "content", * "hidden", + * "layout_id", + * "layout_settings", * } * ) */ diff --git a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php index b6c73e5..3a719a1 100644 --- a/core/lib/Drupal/Core/Entity/EntityDisplayBase.php +++ b/core/lib/Drupal/Core/Entity/EntityDisplayBase.php @@ -4,13 +4,15 @@ use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\Config\Entity\ConfigEntityInterface; +use Drupal\Core\Entity\Display\EntityDisplayWithLayoutInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Entity\Display\EntityDisplayInterface; +use Drupal\Core\Layout\LayoutInterface; /** * Provides a common base class for entity view and form displays. */ -abstract class EntityDisplayBase extends ConfigEntityBase implements EntityDisplayInterface { +abstract class EntityDisplayBase extends ConfigEntityBase implements EntityDisplayInterface, EntityDisplayWithLayoutInterface { /** * The 'mode' for runtime EntityDisplay objects used to render entities with @@ -79,6 +81,20 @@ protected $hidden = array(); /** + * The layout ID. + * + * @var string + */ + protected $layout_id = 'layout_onecol'; + + /** + * The layout settings. + * + * @var array + */ + protected $layout_settings = []; + + /** * The original view or form mode that was requested (case of view/form modes * being configured to fall back to the 'default' display). * @@ -264,6 +280,15 @@ public function preSave(EntityStorageInterface $storage, $update = TRUE) { ksort($this->content); ksort($this->hidden); parent::preSave($storage, $update); + + // @fixme + // Ensure the plugin configuration is updated. Once layouts are no longer + // stored as third party settings, this will be handled by the code in + // \Drupal\Core\Config\Entity\ConfigEntityBase::preSave() that handles + // \Drupal\Core\Entity\EntityWithPluginCollectionInterface. + if ($this->getLayoutId()) { + $this->setLayout($this->getLayout()); + } } /** @@ -313,6 +338,24 @@ public function calculateDependencies() { $mode_entity = $this->entityManager()->getStorage('entity_' . $this->displayContext . '_mode')->load($target_entity_type->id() . '.' . $this->mode); $this->addDependency('config', $mode_entity->getConfigDependencyName()); } + + // @todo Remove once https://www.drupal.org/node/2821191 is resolved. + // This can be called during uninstallation, so check for a valid ID first. + if ($this->getLayoutId()) { + /** @var \Drupal\Core\Layout\LayoutInterface $layout */ + $layout = $this->getLayout(); + $definition = $layout->getPluginDefinition(); + + if (!in_array($definition->getProvider(), ['core', 'component'])) { + $this->addDependency('module', $definition->getProvider()); + } + if ($config_dependencies = $definition->getConfigDependencies()) { + $this->addDependencies($config_dependencies); + } + if ($layout_dependencies = $layout->calculateDependencies()) { + $this->addDependencies($layout_dependencies); + } + } return $this; } @@ -550,8 +593,93 @@ protected function getPluginRemovedDependencies(array $plugin_dependencies, arra * @return string * The default region for this display. */ - protected function getDefaultRegion() { - return 'content'; + public function getDefaultRegion() { + return $this->getLayoutDefinition($this->getLayoutId())->getDefaultRegion(); + } + + /** + * Gets a layout definition. + * + * @param string $layout_id + * The layout ID. + * + * @return \Drupal\Core\Layout\LayoutDefinition + * The layout definition. + */ + protected function getLayoutDefinition($layout_id) { + return \Drupal::service('plugin.manager.core.layout')->getDefinition($layout_id); + } + + /** + * {@inheritdoc} + */ + public function getLayoutId() { + return $this->layout_id; + } + + /** + * {@inheritdoc} + */ + public function getLayoutSettings() { + return $this->layout_settings; + } + + /** + * {@inheritdoc} + */ + public function setLayoutId($layout_id, array $layout_settings = []) { + if ($this->getLayoutId() !== $layout_id) { + // @todo Devise a mechanism for mapping old regions to new ones in + // https://www.drupal.org/node/2796877. + $layout_definition = $this->getLayoutDefinition($layout_id); + $new_region = $layout_definition->getDefaultRegion(); + $layout_regions = $layout_definition->getRegions(); + foreach ($this->getComponents() as $name => $component) { + if (isset($component['region']) && !isset($layout_regions[$component['region']])) { + $component['region'] = $new_region; + $this->setComponent($name, $component); + } + } + } + $this->layout_id = $layout_id; + // Instantiate the plugin and consult it for the updated plugin + // configuration. Once layouts are no longer stored as third party settings, + // this will be handled by the code in + // \Drupal\Core\Config\Entity\ConfigEntityBase::set() that handles + // \Drupal\Core\Entity\EntityWithPluginCollectionInterface. + $layout_settings = $this->doGetLayout($layout_id, $layout_settings)->getConfiguration(); + $this->layout_settings = $layout_settings; + return $this; + } + + /** + * {@inheritdoc} + */ + public function setLayout(LayoutInterface $layout) { + $this->setLayoutId($layout->getPluginId(), $layout->getConfiguration()); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getLayout() { + return $this->doGetLayout($this->getLayoutId(), $this->getLayoutSettings()); + } + + /** + * Gets the layout plugin. + * + * @param string $layout_id + * A layout plugin ID. + * @param array $layout_settings + * An array of settings. + * + * @return \Drupal\Core\Layout\LayoutInterface + * The layout plugin. + */ + protected function doGetLayout($layout_id, array $layout_settings) { + return \Drupal::service('plugin.manager.core.layout')->createInstance($layout_id, $layout_settings); } /** diff --git a/core/lib/Drupal/Core/Layout/Annotation/Layout.php b/core/lib/Drupal/Core/Layout/Annotation/Layout.php index 1cb5ff0..4f30834 100644 --- a/core/lib/Drupal/Core/Layout/Annotation/Layout.php +++ b/core/lib/Drupal/Core/Layout/Annotation/Layout.php @@ -72,9 +72,9 @@ class Layout extends Plugin { /** * The template file to render this layout (relative to the 'path' given). * - * If specified, then the layout_discovery module will register the template - * with hook_theme() and the module or theme registering this layout does not - * need to do it. + * If specified, then the system module will register the template with + * hook_theme() and the module or theme registering this layout does not need + * to do it. * * @var string optional * diff --git a/core/modules/aggregator/config/install/core.entity_view_display.aggregator_feed.aggregator_feed.default.yml b/core/modules/aggregator/config/install/core.entity_view_display.aggregator_feed.aggregator_feed.default.yml index e0232cf..9c94288 100644 --- a/core/modules/aggregator/config/install/core.entity_view_display.aggregator_feed.aggregator_feed.default.yml +++ b/core/modules/aggregator/config/install/core.entity_view_display.aggregator_feed.aggregator_feed.default.yml @@ -36,3 +36,5 @@ content: label: inline hidden: more_link: true +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/aggregator/config/install/core.entity_view_display.aggregator_feed.aggregator_feed.summary.yml b/core/modules/aggregator/config/install/core.entity_view_display.aggregator_feed.aggregator_feed.summary.yml index 5e5e468..6767ecf 100644 --- a/core/modules/aggregator/config/install/core.entity_view_display.aggregator_feed.aggregator_feed.summary.yml +++ b/core/modules/aggregator/config/install/core.entity_view_display.aggregator_feed.aggregator_feed.summary.yml @@ -22,3 +22,5 @@ hidden: feed_icon: true image: true link: true +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/aggregator/config/install/core.entity_view_display.aggregator_item.aggregator_item.summary.yml b/core/modules/aggregator/config/install/core.entity_view_display.aggregator_item.aggregator_item.summary.yml index 8e29395..2c49c11 100644 --- a/core/modules/aggregator/config/install/core.entity_view_display.aggregator_item.aggregator_item.summary.yml +++ b/core/modules/aggregator/config/install/core.entity_view_display.aggregator_item.aggregator_item.summary.yml @@ -18,3 +18,5 @@ hidden: description: true feed: true link: true +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/book/config/optional/core.entity_form_display.node.book.default.yml b/core/modules/book/config/optional/core.entity_form_display.node.book.default.yml index 58aba45..9311e06 100644 --- a/core/modules/book/config/optional/core.entity_form_display.node.book.default.yml +++ b/core/modules/book/config/optional/core.entity_form_display.node.book.default.yml @@ -58,3 +58,5 @@ content: placeholder: '' third_party_settings: { } hidden: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/book/config/optional/core.entity_view_display.node.book.default.yml b/core/modules/book/config/optional/core.entity_view_display.node.book.default.yml index d6ef64d..f4528f5 100644 --- a/core/modules/book/config/optional/core.entity_view_display.node.book.default.yml +++ b/core/modules/book/config/optional/core.entity_view_display.node.book.default.yml @@ -23,3 +23,5 @@ content: weight: 101 region: content hidden: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/book/config/optional/core.entity_view_display.node.book.teaser.yml b/core/modules/book/config/optional/core.entity_view_display.node.book.teaser.yml index 77a62c3..9000525 100644 --- a/core/modules/book/config/optional/core.entity_view_display.node.book.teaser.yml +++ b/core/modules/book/config/optional/core.entity_view_display.node.book.teaser.yml @@ -25,3 +25,5 @@ content: weight: 101 region: content hidden: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/field_ui/src/Form/EntityDisplayFormBase.php b/core/modules/field_ui/src/Form/EntityDisplayFormBase.php index 8908f65..faf0872 100644 --- a/core/modules/field_ui/src/Form/EntityDisplayFormBase.php +++ b/core/modules/field_ui/src/Form/EntityDisplayFormBase.php @@ -11,7 +11,11 @@ use Drupal\Core\Field\FieldTypePluginManagerInterface; use Drupal\Core\Field\PluginSettingsInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Form\SubformState; +use Drupal\Core\Layout\LayoutPluginManagerInterface; +use Drupal\Core\Plugin\PluginFormInterface; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Entity\Display\EntityDisplayWithLayoutInterface; use Drupal\field_ui\Element\FieldUiTable; use Drupal\field_ui\FieldUI; @@ -35,6 +39,13 @@ protected $pluginManager; /** + * The field layout plugin manager. + * + * @var \Drupal\Core\Layout\LayoutPluginManagerInterface + */ + protected $layoutPluginManager; + + /** * A list of field types. * * @var array @@ -55,10 +66,13 @@ * The field type manager. * @param \Drupal\Component\Plugin\PluginManagerBase $plugin_manager * The widget or formatter plugin manager. + * @param \Drupal\Core\Layout\LayoutPluginManagerInterface $layout_plugin_manager + * The layout plugin manager. */ - public function __construct(FieldTypePluginManagerInterface $field_type_manager, PluginManagerBase $plugin_manager) { + public function __construct(FieldTypePluginManagerInterface $field_type_manager, PluginManagerBase $plugin_manager, LayoutPluginManagerInterface $layout_plugin_manager) { $this->fieldTypes = $field_type_manager->getDefinitions(); $this->pluginManager = $plugin_manager; + $this->layoutPluginManager = $layout_plugin_manager; } /** @@ -90,17 +104,22 @@ public function getEntityFromRouteMatch(RouteMatchInterface $route_match, $entit * @endcode */ public function getRegions() { - return array( - 'content' => array( - 'title' => $this->t('Content'), - 'invisible' => TRUE, - 'message' => $this->t('No field is displayed.') - ), - 'hidden' => array( - 'title' => $this->t('Disabled', array(), array('context' => 'Plural')), - 'message' => $this->t('No field is hidden.') - ), - ); + $regions = []; + + $layout_definition = $this->layoutPluginManager->getDefinition($this->getEntity()->getLayoutId()); + foreach ($layout_definition->getRegions() as $name => $region) { + $regions[$name] = [ + 'title' => $region['label'], + 'message' => $this->t('No field is displayed.'), + ]; + } + + $regions['hidden'] = [ + 'title' => $this->t('Disabled', [], ['context' => 'Plural']), + 'message' => $this->t('No field is hidden.'), + ]; + + return $regions; } /** @@ -244,6 +263,48 @@ public function form(array $form, FormStateInterface $form_state) { '#attributes' => array('class' => array('visually-hidden')) ); + $form['layouts'] = [ + '#type' => 'details', + '#title' => $this->t('Layout settings'), + ]; + + $layout_plugin = $this->getLayout($this->getEntity(), $form_state); + + $form['layouts']['layout'] = [ + '#type' => 'select', + '#title' => $this->t('Select a layout'), + '#options' => $this->layoutPluginManager->getLayoutOptions(), + '#default_value' => $layout_plugin->getPluginId(), + '#ajax' => [ + 'callback' => '::settingsAjax', + 'wrapper' => 'field-layout-settings-wrapper', + 'trigger_as' => ['name' => 'layout_change'], + ], + ]; + $form['layouts']['submit'] = [ + '#type' => 'submit', + '#name' => 'layout_change', + '#value' => $this->t('Change layout'), + '#submit' => ['::settingsAjaxSubmit'], + '#attributes' => ['class' => ['js-hide']], + '#ajax' => [ + 'callback' => '::settingsAjax', + 'wrapper' => 'field-layout-settings-wrapper', + ], + ]; + + $form['layouts']['settings_wrapper'] = [ + '#type' => 'container', + '#id' => 'field-layout-settings-wrapper', + '#tree' => TRUE, + ]; + + if ($layout_plugin instanceof PluginFormInterface) { + $form['layouts']['settings_wrapper']['layout_settings'] = []; + $subform_state = SubformState::createForSubform($form['layouts']['settings_wrapper']['layout_settings'], $form, $form_state); + $form['layouts']['settings_wrapper']['layout_settings'] = $layout_plugin->buildConfigurationForm($form['layouts']['settings_wrapper']['layout_settings'], $subform_state); + } + $form['actions'] = array('#type' => 'actions'); $form['actions']['submit'] = array( '#type' => 'submit', @@ -508,6 +569,19 @@ protected function buildExtraFieldRow($field_id, $extra_field) { /** * {@inheritdoc} */ + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + + $layout_plugin = $this->getLayout($this->getEntity(), $form_state); + if ($layout_plugin instanceof PluginFormInterface) { + $subform_state = SubformState::createForSubform($form['layouts']['settings_wrapper']['layout_settings'], $form, $form_state); + $layout_plugin->validateConfigurationForm($form['layouts']['settings_wrapper']['layout_settings'], $subform_state); + } + } + + /** + * {@inheritdoc} + */ public function submitForm(array &$form, FormStateInterface $form_state) { // If the main "Save" button was submitted while a field settings subform // was being edited, update the new incoming settings when rebuilding the @@ -517,6 +591,16 @@ public function submitForm(array &$form, FormStateInterface $form_state) { } parent::submitForm($form, $form_state); + + $entity = $this->getEntity(); + $layout_plugin = $this->getLayout($entity, $form_state); + if ($layout_plugin instanceof PluginFormInterface) { + $subform_state = SubformState::createForSubform($form['layouts']['settings_wrapper']['layout_settings'], $form, $form_state); + $layout_plugin->submitConfigurationForm($form['layouts']['settings_wrapper']['layout_settings'], $subform_state); + } + + $entity->setLayout($layout_plugin); + $form_values = $form_state->getValues(); // Handle the 'display modes' checkboxes if present. @@ -684,6 +768,47 @@ public function multistepAjax($form, FormStateInterface $form_state) { } /** + * Gets the layout plugin for the currently selected field layout. + * + * @param \Drupal\Core\Entity\Display\EntityDisplayWithLayoutInterface $entity + * The current form entity. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\Core\Layout\LayoutInterface + * The layout plugin. + */ + protected function getLayout(EntityDisplayWithLayoutInterface $entity, FormStateInterface $form_state) { + if (!$layout_plugin = $form_state->get('layout_plugin')) { + $stored_layout_id = $entity->getLayoutId(); + // Use selected layout if it exists, falling back to the stored layout. + $layout_id = $form_state->getValue('layout', $stored_layout_id); + // If the current layout is the stored layout, use the stored layout + // settings. Otherwise leave the settings empty. + $layout_settings = $layout_id === $stored_layout_id ? $entity->getLayoutSettings() : []; + + $layout_plugin = $this->layoutPluginManager->createInstance($layout_id, $layout_settings); + $form_state->set('layout_plugin', $layout_plugin); + } + return $layout_plugin; + } + + /** + * Ajax callback for the field layout settings form. + */ + public static function settingsAjax($form, FormStateInterface $form_state) { + return $form['layouts']['settings_wrapper']; + } + + /** + * Submit handler for the non-JS case. + */ + public function settingsAjaxSubmit($form, FormStateInterface $form_state) { + $form_state->set('layout_plugin', NULL); + $form_state->setRebuild(); + } + + /** * Performs pre-render tasks on field_ui_table elements. * * @param array $elements diff --git a/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php b/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php index af8e2ed..59520a7 100644 --- a/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php +++ b/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php @@ -25,7 +25,8 @@ class EntityFormDisplayEditForm extends EntityDisplayFormBase { public static function create(ContainerInterface $container) { return new static( $container->get('plugin.manager.field.field_type'), - $container->get('plugin.manager.field.widget') + $container->get('plugin.manager.field.widget'), + $container->get('plugin.manager.core.layout') ); } diff --git a/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php b/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php index 174726f..066cdf8 100644 --- a/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php +++ b/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php @@ -25,7 +25,8 @@ class EntityViewDisplayEditForm extends EntityDisplayFormBase { public static function create(ContainerInterface $container) { return new static( $container->get('plugin.manager.field.field_type'), - $container->get('plugin.manager.field.formatter') + $container->get('plugin.manager.field.formatter'), + $container->get('plugin.manager.core.layout') ); } diff --git a/core/modules/field_ui/tests/src/Functional/EntityDisplayTest.php b/core/modules/field_ui/tests/src/Functional/EntityDisplayTest.php index c9cfc05..921c699 100644 --- a/core/modules/field_ui/tests/src/Functional/EntityDisplayTest.php +++ b/core/modules/field_ui/tests/src/Functional/EntityDisplayTest.php @@ -14,7 +14,7 @@ class EntityDisplayTest extends BrowserTestBase { /** * {@inheritdoc} */ - public static $modules = ['field_ui', 'entity_test']; + public static $modules = ['field_ui', 'entity_test', 'node']; /** * {@inheritdoc} @@ -25,6 +25,26 @@ protected function setUp() { $this->drupalLogin($this->drupalCreateUser([ 'administer entity_test display', ])); + + $this->createContentType([ + 'type' => 'article', + ]); + $this->createNode([ + 'type' => 'article', + 'title' => 'The node title', + 'body' => [[ + 'value' => 'The node body', + ]], + ]); + $this->drupalLogin($this->drupalCreateUser([ + 'access administration pages', + 'administer content types', + 'administer nodes', + 'administer node fields', + 'administer node display', + 'administer node form display', + 'view the administration theme', + ])); } /** @@ -43,4 +63,34 @@ public function testEntityView() { $this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'content')->isSelected()); } + /** + * Tests an entity type that has fields shown by default. + */ + public function testNodeView() { + // By default, the one-column layout is used. + $this->drupalGet('node/1'); + $this->assertSession()->elementExists('css', '.layout--onecol'); + $this->assertSession()->elementExists('css', '.layout-region--content .field--name-body'); + + $this->drupalGet('admin/structure/types/manage/article/display'); + $this->assertEquals(['Content', 'Disabled'], $this->getRegionTitles()); + $this->assertSession()->optionExists('fields[body][region]', 'content'); + } + + /** + * Gets the region titles on the page. + * + * @return string[] + * An array of region titles. + */ + protected function getRegionTitles() { + $region_titles = []; + $region_title_elements = $this->getSession()->getPage()->findAll('css', '.region-title td'); + /** @var \Behat\Mink\Element\NodeElement[] $region_title_elements */ + foreach ($region_title_elements as $region_title_element) { + $region_titles[] = $region_title_element->getText(); + } + return $region_titles; + } + } diff --git a/core/modules/field_ui/tests/src/FunctionalJavascript/EntityDisplayTest.php b/core/modules/field_ui/tests/src/FunctionalJavascript/EntityDisplayTest.php index 319e8df..9e1a5a8 100644 --- a/core/modules/field_ui/tests/src/FunctionalJavascript/EntityDisplayTest.php +++ b/core/modules/field_ui/tests/src/FunctionalJavascript/EntityDisplayTest.php @@ -15,7 +15,7 @@ class EntityDisplayTest extends JavascriptTestBase { /** * {@inheritdoc} */ - public static $modules = ['field_ui', 'entity_test']; + public static $modules = ['field_ui', 'entity_test', 'field_layout_test', 'layout_test']; /** * {@inheritdoc} @@ -86,4 +86,226 @@ public function testEntityView() { $this->assertSession()->elementExists('css', '.field--name-field-test-text'); } + /** + * Tests that layouts are unique per view mode. + */ + public function testEntityViewModes() { + // By default, the field is not visible. + $this->drupalGet('entity_test/1/test'); + $this->assertSession()->elementNotExists('css', '.layout-region--content .field--name-field-test-text'); + $this->drupalGet('entity_test/1'); + $this->assertSession()->elementNotExists('css', '.layout-region--content .field--name-field-test-text'); + + // Change the layout for the "test" view mode. See + // core.entity_view_mode.entity_test.test.yml. + $this->drupalGet('entity_test/structure/entity_test/display'); + $this->click('#edit-modes'); + $this->getSession()->getPage()->checkField('display_modes_custom[test]'); + $this->submitForm([], 'Save'); + $this->clickLink('configure them'); + $this->getSession()->getPage()->pressButton('Show row weights'); + $this->getSession()->getPage()->selectFieldOption('fields[field_test_text][region]', 'content'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->submitForm([], 'Save'); + + // Each view mode has a different layout. + $this->drupalGet('entity_test/1/test'); + $this->assertSession()->elementExists('css', '.layout-region--content .field--name-field-test-text'); + $this->drupalGet('entity_test/1'); + $this->assertSession()->elementNotExists('css', '.layout-region--content .field--name-field-test-text'); + } + + /** + * Tests the use of field layout for entity form displays. + */ + public function testLayoutEntityForm() { + // By default, the one-column layout is used. + $this->drupalGet('entity_test/manage/1/edit'); + $this->assertFieldInRegion('field_test_text[0][value]', 'content'); + + // The one-column layout is in use. + $this->drupalGet('entity_test/structure/entity_test/form-display'); + $this->assertEquals(['Content', 'Disabled'], $this->getRegionTitles()); + + // Switch the layout to two columns. + $this->click('#edit-field-layouts'); + $this->getSession()->getPage()->selectFieldOption('layout', 'layout_twocol'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->submitForm([], 'Save'); + + // The field is moved to the default region for the new layout. + $this->assertSession()->pageTextContains('Your settings have been saved.'); + $this->assertEquals(['Left', 'Right', 'Disabled'], $this->getRegionTitles()); + + $this->drupalGet('entity_test/manage/1/edit'); + // No fields are visible, and the regions don't display when empty. + $this->assertFieldInRegion('field_test_text[0][value]', 'left'); + $this->assertSession()->elementExists('css', '.layout-region--left .field--name-field-test-text'); + + // After a refresh the new regions are still there. + $this->drupalGet('entity_test/structure/entity_test/form-display'); + $this->assertEquals(['Left', 'Right', 'Disabled'], $this->getRegionTitles()); + + // Drag the field to the right region. + $field_test_text_row = $this->getSession()->getPage()->find('css', '#field-test-text'); + $right_region_row = $this->getSession()->getPage()->find('css', '.region-right-message'); + $field_test_text_row->find('css', '.handle')->dragTo($right_region_row); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->submitForm([], 'Save'); + $this->assertSession()->pageTextContains('Your settings have been saved.'); + + // The new layout is used. + $this->drupalGet('entity_test/manage/1/edit'); + $this->assertSession()->elementExists('css', '.layout-region--right .field--name-field-test-text'); + $this->assertFieldInRegion('field_test_text[0][value]', 'right'); + + // Move the field to the right region without tabledrag. + $this->drupalGet('entity_test/structure/entity_test/form-display'); + $this->getSession()->getPage()->pressButton('Show row weights'); + $this->getSession()->getPage()->selectFieldOption('fields[field_test_text][region]', 'right'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->submitForm([], 'Save'); + $this->assertSession()->pageTextContains('Your settings have been saved.'); + + // The updated region is used. + $this->drupalGet('entity_test/manage/1/edit'); + $this->assertFieldInRegion('field_test_text[0][value]', 'right'); + + // The layout is still in use without Field UI. + $this->container->get('module_installer')->uninstall(['field_ui']); + $this->drupalGet('entity_test/manage/1/edit'); + $this->assertFieldInRegion('field_test_text[0][value]', 'right'); + } + + /** + * Tests the use of field layout for entity view displays. + */ + public function testLayoutEntityView() { + // The one-column layout is in use. + $this->drupalGet('entity_test/structure/entity_test/display'); + $this->assertEquals(['Content', 'Disabled'], $this->getRegionTitles()); + + // Switch the layout to two columns. + $this->click('#edit-field-layouts'); + $this->getSession()->getPage()->selectFieldOption('layout', 'layout_twocol'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->submitForm([], 'Save'); + + $this->assertSession()->pageTextContains('Your settings have been saved.'); + $this->assertEquals(['Left', 'Right', 'Disabled'], $this->getRegionTitles()); + + $this->drupalGet('entity_test/1'); + // No fields are visible, and the regions don't display when empty. + $this->assertSession()->elementNotExists('css', '.layout--twocol'); + $this->assertSession()->elementNotExists('css', '.layout-region'); + $this->assertSession()->elementNotExists('css', '.field--name-field-test-text'); + + // After a refresh the new regions are still there. + $this->drupalGet('entity_test/structure/entity_test/display'); + $this->assertEquals(['Left', 'Right', 'Disabled'], $this->getRegionTitles()); + + // Drag the field to the left region. + $this->assertTrue($this->assertSession()->optionExists('fields[field_test_text][region]', 'hidden')->isSelected()); + $field_test_text_row = $this->getSession()->getPage()->find('css', '#field-test-text'); + $left_region_row = $this->getSession()->getPage()->find('css', '.region-left-message'); + $field_test_text_row->find('css', '.handle')->dragTo($left_region_row); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertFalse($this->assertSession()->optionExists('fields[field_test_text][region]', 'hidden')->isSelected()); + $this->submitForm([], 'Save'); + $this->assertSession()->pageTextContains('Your settings have been saved.'); + + // The new layout is used. + $this->drupalGet('entity_test/1'); + $this->assertSession()->elementExists('css', '.layout--twocol'); + $this->assertSession()->elementExists('css', '.layout-region--left .field--name-field-test-text'); + + // Move the field to the right region without tabledrag. + $this->drupalGet('entity_test/structure/entity_test/display'); + $this->getSession()->getPage()->pressButton('Show row weights'); + $this->getSession()->getPage()->selectFieldOption('fields[field_test_text][region]', 'right'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->submitForm([], 'Save'); + $this->assertSession()->pageTextContains('Your settings have been saved.'); + + // The updated region is used. + $this->drupalGet('entity_test/1'); + $this->assertSession()->elementExists('css', '.layout-region--right .field--name-field-test-text'); + + // The layout is still in use without Field UI. + $this->container->get('module_installer')->uninstall(['field_ui']); + $this->drupalGet('entity_test/1'); + $this->assertSession()->elementExists('css', '.layout--twocol'); + $this->assertSession()->elementExists('css', '.layout-region--right .field--name-field-test-text'); + } + + /** + * Tests layout plugins with forms. + */ + public function testLayoutForms() { + $this->drupalGet('entity_test/structure/entity_test/display'); + // Switch to a field layout with settings. + $this->click('#edit-field-layouts'); + + // Test switching between layouts with and without forms. + $this->getSession()->getPage()->selectFieldOption('layout', 'layout_test_plugin'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertSession()->fieldExists('settings_wrapper[layout_settings][setting_1]'); + + $this->getSession()->getPage()->selectFieldOption('layout', 'layout_test_2col'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertSession()->fieldNotExists('settings_wrapper[layout_settings][setting_1]'); + + $this->getSession()->getPage()->selectFieldOption('layout', 'layout_test_plugin'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertSession()->fieldExists('settings_wrapper[layout_settings][setting_1]'); + + // Move the test field to the content region. + $this->getSession()->getPage()->pressButton('Show row weights'); + $this->getSession()->getPage()->selectFieldOption('fields[field_test_text][region]', 'content'); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->submitForm([], 'Save'); + + $this->drupalGet('entity_test/1'); + $this->assertSession()->pageTextContains('Blah: Default'); + + // Update the field layout settings. + $this->drupalGet('entity_test/structure/entity_test/display'); + $this->click('#edit-field-layouts'); + $this->getSession()->getPage()->fillField('settings_wrapper[layout_settings][setting_1]', 'Test text'); + $this->submitForm([], 'Save'); + + $this->drupalGet('entity_test/1'); + $this->assertSession()->pageTextContains('Blah: Test text'); + } + + /** + * Gets the region titles on the page. + * + * @return string[] + * An array of region titles. + */ + protected function getRegionTitles() { + $region_titles = []; + $region_title_elements = $this->getSession()->getPage()->findAll('css', '.region-title td'); + /** @var \Behat\Mink\Element\NodeElement[] $region_title_elements */ + foreach ($region_title_elements as $region_title_element) { + $region_titles[] = $region_title_element->getText(); + } + return $region_titles; + } + + /** + * Asserts that a field exists in a given region. + * + * @param string $field_selector + * The field selector, one of field id|name|label|value. + * @param string $region_name + * The machine name of the region. + */ + protected function assertFieldInRegion($field_selector, $region_name) { + $region_element = $this->getSession()->getPage()->find('css', ".layout-region--$region_name"); + $this->assertNotNull($region_element); + $this->assertSession()->fieldExists($field_selector, $region_element); + } + } diff --git a/core/modules/forum/config/optional/core.entity_form_display.comment.comment_forum.default.yml b/core/modules/forum/config/optional/core.entity_form_display.comment.comment_forum.default.yml index 4738bbb..a18fc89 100644 --- a/core/modules/forum/config/optional/core.entity_form_display.comment.comment_forum.default.yml +++ b/core/modules/forum/config/optional/core.entity_form_display.comment.comment_forum.default.yml @@ -31,3 +31,5 @@ content: placeholder: '' third_party_settings: { } hidden: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/forum/config/optional/core.entity_form_display.node.forum.default.yml b/core/modules/forum/config/optional/core.entity_form_display.node.forum.default.yml index 6773d32..91ae40a 100644 --- a/core/modules/forum/config/optional/core.entity_form_display.node.forum.default.yml +++ b/core/modules/forum/config/optional/core.entity_form_display.node.forum.default.yml @@ -73,3 +73,5 @@ content: placeholder: '' third_party_settings: { } hidden: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/forum/config/optional/core.entity_form_display.taxonomy_term.forums.default.yml b/core/modules/forum/config/optional/core.entity_form_display.taxonomy_term.forums.default.yml index 50df98a..77d658e 100644 --- a/core/modules/forum/config/optional/core.entity_form_display.taxonomy_term.forums.default.yml +++ b/core/modules/forum/config/optional/core.entity_form_display.taxonomy_term.forums.default.yml @@ -27,3 +27,5 @@ content: third_party_settings: { } hidden: forum_container: true +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/forum/config/optional/core.entity_view_display.comment.comment_forum.default.yml b/core/modules/forum/config/optional/core.entity_view_display.comment.comment_forum.default.yml index befeba8..d112cf1 100644 --- a/core/modules/forum/config/optional/core.entity_view_display.comment.comment_forum.default.yml +++ b/core/modules/forum/config/optional/core.entity_view_display.comment.comment_forum.default.yml @@ -22,3 +22,5 @@ content: weight: 100 region: content hidden: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/forum/config/optional/core.entity_view_display.node.forum.default.yml b/core/modules/forum/config/optional/core.entity_view_display.node.forum.default.yml index f3e8c5c..6967140 100644 --- a/core/modules/forum/config/optional/core.entity_view_display.node.forum.default.yml +++ b/core/modules/forum/config/optional/core.entity_view_display.node.forum.default.yml @@ -44,3 +44,5 @@ content: link: true third_party_settings: { } hidden: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/forum/config/optional/core.entity_view_display.node.forum.teaser.yml b/core/modules/forum/config/optional/core.entity_view_display.node.forum.teaser.yml index 7b174f4..21bce8c 100644 --- a/core/modules/forum/config/optional/core.entity_view_display.node.forum.teaser.yml +++ b/core/modules/forum/config/optional/core.entity_view_display.node.forum.teaser.yml @@ -36,3 +36,5 @@ content: third_party_settings: { } hidden: comment_forum: true +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/forum/config/optional/core.entity_view_display.taxonomy_term.forums.default.yml b/core/modules/forum/config/optional/core.entity_view_display.taxonomy_term.forums.default.yml index b326039..eceec30 100644 --- a/core/modules/forum/config/optional/core.entity_view_display.taxonomy_term.forums.default.yml +++ b/core/modules/forum/config/optional/core.entity_view_display.taxonomy_term.forums.default.yml @@ -20,3 +20,5 @@ content: label: above hidden: forum_container: true +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/layout_discovery/layout_discovery.info.yml b/core/modules/layout_discovery/layout_discovery.info.yml deleted file mode 100644 index a9a4139..0000000 --- a/core/modules/layout_discovery/layout_discovery.info.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: 'Layout Discovery' -type: module -description: 'Provides a way for modules or themes to register layouts.' -package: Core (Experimental) -version: VERSION -core: 8.x diff --git a/core/modules/layout_discovery/layout_discovery.install b/core/modules/layout_discovery/layout_discovery.install deleted file mode 100644 index e6c99e0..0000000 --- a/core/modules/layout_discovery/layout_discovery.install +++ /dev/null @@ -1,22 +0,0 @@ -moduleExists('layout_plugin')) { - $requirements['layout_discovery'] = [ - 'description' => t('Layout Discovery cannot be installed because the Layout Plugin module is installed and incompatible.'), - 'severity' => REQUIREMENT_ERROR, - ]; - } - } - return $requirements; -} diff --git a/core/modules/layout_discovery/layout_discovery.module b/core/modules/layout_discovery/layout_discovery.module deleted file mode 100644 index 3cffee9..0000000 --- a/core/modules/layout_discovery/layout_discovery.module +++ /dev/null @@ -1,39 +0,0 @@ -' . t('About') . ''; - $output .= '
' . t('Layout Discovery allows modules or themes to register layouts, and for other modules to list the available layouts and render them.') . '
'; - $output .= '' . t('For more information, see the online documentation for the Layout Discovery module.', [':layout-discovery-documentation' => 'https://www.drupal.org/node/2619128']) . '
'; - return $output; - } -} - -/** - * Implements hook_theme(). - */ -function layout_discovery_theme() { - return \Drupal::service('plugin.manager.core.layout')->getThemeImplementations(); -} - -/** - * Prepares variables for layout templates. - * - * @param array &$variables - * An associative array containing: - * - content: An associative array containing the properties of the element. - * Properties used: #settings, #layout. - */ -function template_preprocess_layout(&$variables) { - $variables['settings'] = isset($variables['content']['#settings']) ? $variables['content']['#settings'] : []; - $variables['layout'] = isset($variables['content']['#layout']) ? $variables['content']['#layout'] : []; -} diff --git a/core/modules/layout_discovery/layout_discovery.services.yml b/core/modules/layout_discovery/layout_discovery.services.yml deleted file mode 100644 index 1e24db4..0000000 --- a/core/modules/layout_discovery/layout_discovery.services.yml +++ /dev/null @@ -1,4 +0,0 @@ -services: - plugin.manager.core.layout: - class: Drupal\Core\Layout\LayoutPluginManager - arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@theme_handler'] diff --git a/core/modules/options/tests/options_config_install_test/config/install/core.entity_form_display.node.options_install_test.default.yml b/core/modules/options/tests/options_config_install_test/config/install/core.entity_form_display.node.options_install_test.default.yml index ff5f0ec..3f6a8fc 100644 --- a/core/modules/options/tests/options_config_install_test/config/install/core.entity_form_display.node.options_install_test.default.yml +++ b/core/modules/options/tests/options_config_install_test/config/install/core.entity_form_display.node.options_install_test.default.yml @@ -59,3 +59,5 @@ content: third_party_settings: { } hidden: { } third_party_settings: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/options/tests/options_config_install_test/config/install/core.entity_view_display.node.options_install_test.default.yml b/core/modules/options/tests/options_config_install_test/config/install/core.entity_view_display.node.options_install_test.default.yml index aaea1cb..c218048 100644 --- a/core/modules/options/tests/options_config_install_test/config/install/core.entity_view_display.node.options_install_test.default.yml +++ b/core/modules/options/tests/options_config_install_test/config/install/core.entity_view_display.node.options_install_test.default.yml @@ -25,3 +25,5 @@ content: hidden: langcode: true third_party_settings: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/options/tests/options_config_install_test/config/install/core.entity_view_display.node.options_install_test.teaser.yml b/core/modules/options/tests/options_config_install_test/config/install/core.entity_view_display.node.options_install_test.teaser.yml index 6e79af9..782461a 100644 --- a/core/modules/options/tests/options_config_install_test/config/install/core.entity_view_display.node.options_install_test.teaser.yml +++ b/core/modules/options/tests/options_config_install_test/config/install/core.entity_view_display.node.options_install_test.teaser.yml @@ -27,3 +27,5 @@ content: hidden: langcode: true third_party_settings: { } +layout_id: layout_onecol +layout_settings: { } diff --git a/core/modules/system/layouts/onecol/layout--onecol.html.twig b/core/modules/system/layouts/onecol/layout--onecol.html.twig new file mode 100644 index 0000000..69fed03 --- /dev/null +++ b/core/modules/system/layouts/onecol/layout--onecol.html.twig @@ -0,0 +1,24 @@ +{# +/** + * @file + * Default theme implementation to display a one-column layout. + * + * Available variables: + * - content: The content for this layout. + * - attributes: HTML attributes for the layout