.
+ *
+ * @ingroup themeable
+ */
+#}
+{%
+set classes = [
+'field-layout--onecol',
+]
+%}
+{% if content %}
+
+{% endif %}
diff --git a/core/modules/field_layout/layouts/twocol/field-layout--twocol.html.twig b/core/modules/field_layout/layouts/twocol/field-layout--twocol.html.twig
new file mode 100644
index 0000000..4dffc01
--- /dev/null
+++ b/core/modules/field_layout/layouts/twocol/field-layout--twocol.html.twig
@@ -0,0 +1,28 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display a two column layout.
+ *
+ * Available variables:
+ * - content: The content for this layout.
+ * - attributes: HTML attributes for the layout
.
+ *
+ * @ingroup themeable
+ */
+#}
+{%
+set classes = [
+'field-layout--twocol',
+]
+%}
+{% if content %}
+
+
+ {{ content.left }}
+
+
+
+ {{ content.right }}
+
+
+{% endif %}
diff --git a/core/modules/field_layout/layouts/twocol/twocol.layout.css b/core/modules/field_layout/layouts/twocol/twocol.layout.css
new file mode 100644
index 0000000..8e2f623
--- /dev/null
+++ b/core/modules/field_layout/layouts/twocol/twocol.layout.css
@@ -0,0 +1,14 @@
+.field-layout--twocol {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+}
+.field-layout--twocol > .field-layout-region {
+ flex: 0 1 50%;
+ max-width: 50%;
+}
+
+.field-layout--twocol > .field-layout-region--left {
+ max-width: calc(50% - 10px);
+ margin-right: 10px;
+}
diff --git a/core/modules/field_layout/src/Display/EntityDisplayWithLayoutInterface.php b/core/modules/field_layout/src/Display/EntityDisplayWithLayoutInterface.php
new file mode 100644
index 0000000..15204ac
--- /dev/null
+++ b/core/modules/field_layout/src/Display/EntityDisplayWithLayoutInterface.php
@@ -0,0 +1,56 @@
+getDefinition($layout_id);
+ }
+
+ /**
+ * Implements \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface::getLayoutId().
+ */
+ public function getLayoutId() {
+ return $this->getThirdPartySetting('field_layout', 'id');
+ }
+
+ /**
+ * Implements \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface::getLayoutSettings().
+ */
+ public function getLayoutSettings() {
+ return $this->getThirdPartySetting('field_layout', 'settings', []);
+ }
+
+ /**
+ * Implements \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface::setLayout().
+ */
+ public function setLayout($layout_id, array $layout_settings = []) {
+ $this->setThirdPartySetting('field_layout', 'id', $layout_id);
+ $this->setThirdPartySetting('field_layout', 'settings', $layout_settings);
+ return $this;
+ }
+
+ /**
+ * Implements \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface::getLayoutPlugin().
+ */
+ public function getLayoutPlugin() {
+ return \Drupal::service('plugin.manager.layout_plugin')->createInstance($this->getLayoutId(), $this->getLayoutSettings());
+ }
+
+ /**
+ * Overrides \Drupal\Core\Entity\EntityDisplayBase::preSave().
+ */
+ public function preSave(EntityStorageInterface $storage) {
+ if (!$this->getLayoutId()) {
+ $this->setLayout('onecol');
+ }
+
+ parent::preSave($storage);
+ }
+
+}
diff --git a/core/modules/field_layout/src/Entity/FieldLayoutEntityFormDisplay.php b/core/modules/field_layout/src/Entity/FieldLayoutEntityFormDisplay.php
new file mode 100644
index 0000000..2f3111e
--- /dev/null
+++ b/core/modules/field_layout/src/Entity/FieldLayoutEntityFormDisplay.php
@@ -0,0 +1,25 @@
+getLayoutDefinition($this->getLayoutId() ?: 'onecol')->getDefaultRegion();
+ }
+
+}
diff --git a/core/modules/field_layout/src/Entity/FieldLayoutEntityViewDisplay.php b/core/modules/field_layout/src/Entity/FieldLayoutEntityViewDisplay.php
new file mode 100644
index 0000000..b527059
--- /dev/null
+++ b/core/modules/field_layout/src/Entity/FieldLayoutEntityViewDisplay.php
@@ -0,0 +1,25 @@
+getLayoutDefinition($this->getLayoutId() ?: 'onecol')->getDefaultRegion();
+ }
+
+}
diff --git a/core/modules/field_layout/src/FieldLayoutBuilder.php b/core/modules/field_layout/src/FieldLayoutBuilder.php
new file mode 100644
index 0000000..47c6de2
--- /dev/null
+++ b/core/modules/field_layout/src/FieldLayoutBuilder.php
@@ -0,0 +1,123 @@
+layoutPluginManager = $layout_plugin_manager;
+ $this->entityFieldManager = $entity_field_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('plugin.manager.layout_plugin'),
+ $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\field_layout\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'. If in a 'form' context, an
+ * alternate method will be used to render fields in their regions.
+ */
+ public function build(array &$build, EntityDisplayWithLayoutInterface $display, $display_context) {
+ $layout_definition = $this->layoutPluginManager->getDefinition($display->getLayoutId(), FALSE);
+ if ($layout_definition && $fields = $this->getFields($build, $display, $display_context)) {
+ // Add the regions to the $build in the correct order.
+ $fill = [];
+ if ($display_context === 'form') {
+ $fill['#process'][] = '\Drupal\Core\Render\Element\RenderElement::processGroup';
+ $fill['#pre_render'][] = '\Drupal\Core\Render\Element\RenderElement::preRenderGroup';
+ }
+ $regions = array_fill_keys($layout_definition->getRegionNames(), $fill);
+
+ foreach ($fields as $name => $field) {
+ // If 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 ($display_context === 'form') {
+ $build[$name]['#group'] = $field['region'];
+ }
+ // Otherwise, move the field from the top-level of $build into a
+ // region-specific section.
+ else {
+ $regions[$field['region']][$name] = $build[$name];
+ unset($build[$name]);
+ }
+ }
+ $build['field_layout'] = $display->getLayoutPlugin()->build($regions);
+ }
+ }
+
+ /**
+ * Gets the fields that need to be processed.
+ *
+ * @param array $build
+ * A renderable array representing the entity content or form.
+ * @param \Drupal\field_layout\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();
+
+ $field_definitions = $this->entityFieldManager->getFieldDefinitions($display->getTargetEntityTypeId(), $display->getTargetBundle());
+ $non_configurable_fields = array_filter($field_definitions, function (FieldDefinitionInterface $field_definition) use ($display_context) {
+ return !$field_definition->isDisplayConfigurable($display_context);
+ });
+ // Remove non-configurable fields.
+ $components = array_diff_key($components, $non_configurable_fields);
+
+ // Only include fields present in the build.
+ $components = array_intersect_key($components, $build);
+
+ return $components;
+ }
+
+}
diff --git a/core/modules/field_layout/src/Form/FieldLayoutEntityDisplayFormTrait.php b/core/modules/field_layout/src/Form/FieldLayoutEntityDisplayFormTrait.php
new file mode 100644
index 0000000..e9f923c
--- /dev/null
+++ b/core/modules/field_layout/src/Form/FieldLayoutEntityDisplayFormTrait.php
@@ -0,0 +1,205 @@
+layoutPluginManager->getDefinition($this->getEntity()->getLayoutId() ?: 'onecol');
+ 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;
+ }
+
+ /**
+ * Overrides \Drupal\field_ui\Form\EntityDisplayFormBase::form().
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ $form = parent::form($form, $form_state);
+
+ $form['field_layouts'] = [
+ '#type' => 'details',
+ '#title' => $this->t('Layout settings'),
+ ];
+
+ $layout_plugin = $this->getLayoutPlugin($this->getEntity(), $form_state);
+
+ $form['field_layouts']['field_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' => 'field_layout_change'],
+ ],
+ ];
+ $form['field_layouts']['submit'] = [
+ '#type' => 'submit',
+ '#name' => 'field_layout_change',
+ '#value' => $this->t('Change layout'),
+ '#submit' => ['::settingsAjaxSubmit'],
+ '#attributes' => ['class' => ['js-hide']],
+ '#ajax' => [
+ 'callback' => '::settingsAjax',
+ 'wrapper' => 'field-layout-settings-wrapper',
+ ],
+ ];
+
+ $form['field_layouts']['settings_wrapper'] = [
+ '#type' => 'container',
+ '#id' => 'field-layout-settings-wrapper',
+ '#tree' => TRUE,
+ ];
+
+ if ($layout_plugin instanceof PluginFormInterface) {
+ $form['field_layouts']['settings_wrapper']['layout_settings'] = [];
+ $subform_state = SubformState::createForSubform($form['field_layouts']['settings_wrapper']['layout_settings'], $form, $form_state);
+ $form['field_layouts']['settings_wrapper']['layout_settings'] = $layout_plugin->buildConfigurationForm($form['field_layouts']['settings_wrapper']['layout_settings'], $subform_state);
+ }
+
+ return $form;
+ }
+
+ /**
+ * Gets the layout plugin for the currently selected field layout.
+ *
+ * @param \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface $entity
+ * The current form entity.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ *
+ * @return \Drupal\layout_plugin\Plugin\Layout\LayoutInterface
+ * The layout plugin.
+ */
+ protected function getLayoutPlugin(EntityDisplayWithLayoutInterface $entity, FormStateInterface $form_state) {
+ if (!$layout_plugin = $form_state->get('layout_plugin')) {
+ $stored_layout_id = $entity->getLayoutId();
+ // If a new field layout was selected, use that. Otherwise try to use the
+ // stored layout. Finally, fall back to the one column layout.
+ $layout_id = $form_state->getValue('field_layout') ?: ($stored_layout_id ?: 'onecol');
+ // 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['field_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();
+ }
+
+ /**
+ * Overrides \Drupal\field_ui\Form\EntityDisplayFormBase::validateForm().
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+
+ $layout_plugin = $this->getLayoutPlugin($this->getEntity(), $form_state);
+ if ($layout_plugin instanceof PluginFormInterface) {
+ $subform_state = SubformState::createForSubform($form['field_layouts']['settings_wrapper']['layout_settings'], $form, $form_state);
+ $layout_plugin->validateConfigurationForm($form['field_layouts']['settings_wrapper']['layout_settings'], $subform_state);
+ }
+ }
+
+ /**
+ * Overrides \Drupal\field_ui\Form\EntityDisplayFormBase::submitForm().
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ parent::submitForm($form, $form_state);
+
+ $entity = $this->getEntity();
+ $layout_plugin = $this->getLayoutPlugin($entity, $form_state);
+ if ($layout_plugin instanceof PluginFormInterface) {
+ $layout_plugin->submitConfigurationForm($form['field_layouts']['settings_wrapper']['layout_settings'], SubformState::createForSubform($form['field_layouts']['settings_wrapper']['layout_settings'], $form, $form_state));
+ }
+
+ // If the layout is changing, reset all fields.
+ if ($this->updateLayout($entity, $form_state)) {
+ // @todo Devise a mechanism for mapping old regions to new ones in
+ // https://www.drupal.org/node/2796877.
+ $new_region = $entity->getDefaultRegion();
+ foreach ($form_state->getValue('fields') as $field_name => $values) {
+ if (($component = $entity->getComponent($field_name)) && $new_region !== 'hidden') {
+ $component['region'] = $new_region;
+ $entity->setComponent($field_name, $component);
+ }
+ else {
+ $entity->removeComponent($field_name);
+ }
+ }
+ }
+ }
+
+ /**
+ * Updates the entity with a new layout.
+ *
+ * @param \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface $entity
+ * The display entity.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current form state.
+ *
+ * @return bool
+ * Returns TRUE if the layout has changed, FALSE if it is the same.
+ */
+ protected function updateLayout(EntityDisplayWithLayoutInterface $entity, FormStateInterface $form_state) {
+ $old_layout = $entity->getLayoutId();
+ $new_layout = $form_state->getValue('field_layout');
+ $entity->setLayout($new_layout, $this->getLayoutPlugin($entity, $form_state)->getConfiguration());
+ return $old_layout !== $new_layout;
+ }
+
+ /**
+ * Gets the form entity.
+ *
+ * @return \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface
+ * The current form entity.
+ */
+ abstract public function getEntity();
+
+}
diff --git a/core/modules/field_layout/src/Form/FieldLayoutEntityFormDisplayEditForm.php b/core/modules/field_layout/src/Form/FieldLayoutEntityFormDisplayEditForm.php
new file mode 100644
index 0000000..20719c3
--- /dev/null
+++ b/core/modules/field_layout/src/Form/FieldLayoutEntityFormDisplayEditForm.php
@@ -0,0 +1,46 @@
+layoutPluginManager = $field_layout_plugin_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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.layout_plugin')
+ );
+ }
+
+}
diff --git a/core/modules/field_layout/src/Form/FieldLayoutEntityViewDisplayEditForm.php b/core/modules/field_layout/src/Form/FieldLayoutEntityViewDisplayEditForm.php
new file mode 100644
index 0000000..f3da650
--- /dev/null
+++ b/core/modules/field_layout/src/Form/FieldLayoutEntityViewDisplayEditForm.php
@@ -0,0 +1,46 @@
+layoutPluginManager = $field_layout_plugin_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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.layout_plugin')
+ );
+ }
+
+}
diff --git a/core/modules/field_layout/tests/modules/field_layout_test/field_layout_test.info.yml b/core/modules/field_layout/tests/modules/field_layout_test/field_layout_test.info.yml
new file mode 100644
index 0000000..4d699e4
--- /dev/null
+++ b/core/modules/field_layout/tests/modules/field_layout_test/field_layout_test.info.yml
@@ -0,0 +1,8 @@
+name: 'Field Layout test'
+type: module
+description: 'Support module for Field Layout tests.'
+core: 8.x
+package: Testing
+version: VERSION
+dependencies:
+ - entity_test
diff --git a/core/modules/field_layout/tests/modules/field_layout_test/field_layout_test.routing.yml b/core/modules/field_layout/tests/modules/field_layout_test/field_layout_test.routing.yml
new file mode 100644
index 0000000..bcea288
--- /dev/null
+++ b/core/modules/field_layout/tests/modules/field_layout_test/field_layout_test.routing.yml
@@ -0,0 +1,7 @@
+entity.entity_test.test_view_mode:
+ path: '/entity_test/{entity_test}/test'
+ defaults:
+ _entity_view: 'entity_test.test'
+ _title: 'Test test view mode'
+ requirements:
+ _entity_access: 'entity_test.view'
diff --git a/core/modules/field_layout/tests/src/Functional/FieldLayoutTest.php b/core/modules/field_layout/tests/src/Functional/FieldLayoutTest.php
new file mode 100644
index 0000000..35b4bdd
--- /dev/null
+++ b/core/modules/field_layout/tests/src/Functional/FieldLayoutTest.php
@@ -0,0 +1,76 @@
+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',
+ ]));
+ }
+
+ /**
+ * 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', '.field-layout--onecol');
+ $this->assertSession()->elementExists('css', '.field-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_layout/tests/src/FunctionalJavascript/FieldLayoutTest.php b/core/modules/field_layout/tests/src/FunctionalJavascript/FieldLayoutTest.php
new file mode 100644
index 0000000..b7d2646
--- /dev/null
+++ b/core/modules/field_layout/tests/src/FunctionalJavascript/FieldLayoutTest.php
@@ -0,0 +1,266 @@
+ 'The name for this entity',
+ 'field_test_text' => [[
+ 'value' => 'The field test text value',
+ ]],
+ ]);
+ $entity->save();
+ $this->drupalLogin($this->drupalCreateUser([
+ 'access administration pages',
+ 'view test entity',
+ 'administer entity_test content',
+ 'administer entity_test fields',
+ 'administer entity_test display',
+ 'administer entity_test form display',
+ 'view the administration theme',
+ ]));
+ }
+
+ /**
+ * 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', '.field-layout-region--content .field--name-field-test-text');
+ $this->drupalGet('entity_test/1');
+ $this->assertSession()->elementNotExists('css', '.field-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', '.field-layout-region--content .field--name-field-test-text');
+ $this->drupalGet('entity_test/1');
+ $this->assertSession()->elementNotExists('css', '.field-layout-region--content .field--name-field-test-text');
+ }
+
+ /**
+ * Tests the use of field layout for entity form displays.
+ */
+ public function testEntityForm() {
+ // 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('field_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', '.field-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', '.field-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 testEntityView() {
+ // 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('field_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', '.field-layout--twocol');
+ $this->assertSession()->elementNotExists('css', '.field-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', '.field-layout--twocol');
+ $this->assertSession()->elementExists('css', '.field-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', '.field-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', '.field-layout--twocol');
+ $this->assertSession()->elementExists('css', '.field-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('field_layout', 'layout_test_plugin');
+ $this->assertSession()->assertWaitOnAjaxRequest();
+ $this->assertSession()->fieldExists('settings_wrapper[layout_settings][setting_1]');
+
+ $this->getSession()->getPage()->selectFieldOption('field_layout', 'layout_test_2col');
+ $this->assertSession()->assertWaitOnAjaxRequest();
+ $this->assertSession()->fieldNotExists('settings_wrapper[layout_settings][setting_1]');
+
+ $this->getSession()->getPage()->selectFieldOption('field_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', ".field-layout-region--$region_name");
+ $this->assertNotNull($region_element);
+ $this->assertSession()->fieldExists($field_selector, $region_element);
+ }
+
+}
diff --git a/core/modules/field_layout/tests/src/Unit/FieldLayoutBuilderTest.php b/core/modules/field_layout/tests/src/Unit/FieldLayoutBuilderTest.php
new file mode 100644
index 0000000..03112a5
--- /dev/null
+++ b/core/modules/field_layout/tests/src/Unit/FieldLayoutBuilderTest.php
@@ -0,0 +1,283 @@
+pluginDefinition = new LayoutDefinition([
+ 'library' => 'field_layout/drupal.field_layout.twocol',
+ 'theme_hook' => 'field_layout__twocol',
+ 'regions' => [
+ 'left' => [
+ 'label' => 'Left',
+ ],
+ 'right' => [
+ 'label' => 'Right',
+ ],
+ ],
+ ]);
+ $this->layoutPlugin = new LayoutDefault([], 'twocol', $this->pluginDefinition);
+
+ $this->layoutPluginManager = $this->prophesize(LayoutPluginManagerInterface::class);
+ $this->layoutPluginManager->getDefinition('unknown', FALSE)->willReturn(NULL);
+ $this->layoutPluginManager->getDefinition('twocol', FALSE)->willReturn($this->pluginDefinition);
+
+ $this->entityFieldManager = $this->prophesize(EntityFieldManagerInterface::class);
+
+ $this->fieldLayoutBuilder = new FieldLayoutBuilder($this->layoutPluginManager->reveal(), $this->entityFieldManager->reveal());
+ }
+
+ /**
+ * @covers ::build
+ * @covers ::getFields
+ */
+ public function testBuildView() {
+ $definitions = [];
+ $non_configurable_field_definition = $this->prophesize(FieldDefinitionInterface::class);
+ $non_configurable_field_definition->isDisplayConfigurable('view')->willReturn(FALSE);
+ $definitions['non_configurable_field'] = $non_configurable_field_definition->reveal();
+ $this->entityFieldManager->getFieldDefinitions('the_entity_type_id', 'the_entity_type_bundle')->willReturn($definitions);
+
+ $build = [
+ 'test1' => [
+ '#markup' => 'Test1',
+ ],
+ 'non_configurable_field' => [
+ '#markup' => 'Non-configurable',
+ ],
+ ];
+
+ $display = $this->prophesize(EntityDisplayWithLayoutInterface::class);
+ $display->getTargetEntityTypeId()->willReturn('the_entity_type_id');
+ $display->getTargetBundle()->willReturn('the_entity_type_bundle');
+ $display->getLayoutPlugin()->willReturn($this->layoutPlugin);
+ $display->getLayoutId()->willReturn('twocol');
+ $display->getLayoutSettings()->willReturn([]);
+ $display->getComponents()->willReturn([
+ 'test1' => [
+ 'region' => 'right',
+ ],
+ 'non_configurable_field' => [
+ 'region' => 'left',
+ ],
+ ]);
+
+ $display_context = 'view';
+
+ $expected = [
+ 'non_configurable_field' => [
+ '#markup' => 'Non-configurable',
+ ],
+ 'field_layout' => [
+ '#content' => [
+ 'left' => [],
+ 'right' => [
+ 'test1' => [
+ '#markup' => 'Test1',
+ ],
+ ],
+ ],
+ '#process' => [[LayoutDefault::class, 'processContent']],
+ '#settings' => [],
+ '#layout' => $this->pluginDefinition,
+ '#theme' => 'field_layout__twocol',
+ '#attached' => [
+ 'library' => [
+ 'field_layout/drupal.field_layout.twocol',
+ ],
+ ],
+ ],
+ ];
+ $this->fieldLayoutBuilder->build($build, $display->reveal(), $display_context);
+ $this->assertEquals($expected, $build);
+ $this->assertSame($expected, $build);
+ }
+
+ /**
+ * @covers ::build
+ * @covers ::getFields
+ */
+ public function testBuildForm() {
+ $definitions = [];
+ $non_configurable_field_definition = $this->prophesize(FieldDefinitionInterface::class);
+ $non_configurable_field_definition->isDisplayConfigurable('form')->willReturn(FALSE);
+ $definitions['non_configurable_field'] = $non_configurable_field_definition->reveal();
+ $this->entityFieldManager->getFieldDefinitions('the_entity_type_id', 'the_entity_type_bundle')->willReturn($definitions);
+
+ $build = [
+ 'test1' => [
+ '#markup' => 'Test1',
+ ],
+ 'non_configurable_field' => [
+ '#markup' => 'Non-configurable',
+ ],
+ ];
+
+ $display = $this->prophesize(EntityDisplayWithLayoutInterface::class);
+ $display->getTargetEntityTypeId()->willReturn('the_entity_type_id');
+ $display->getTargetBundle()->willReturn('the_entity_type_bundle');
+ $display->getLayoutPlugin()->willReturn($this->layoutPlugin);
+ $display->getLayoutId()->willReturn('twocol');
+ $display->getLayoutSettings()->willReturn([]);
+ $display->getComponents()->willReturn([
+ 'test1' => [
+ 'region' => 'right',
+ ],
+ 'non_configurable_field' => [
+ 'region' => 'left',
+ ],
+ ]);
+
+ $display_context = 'form';
+
+ $expected = [
+ 'test1' => [
+ '#markup' => 'Test1',
+ '#group' => 'right',
+ ],
+ 'non_configurable_field' => [
+ '#markup' => 'Non-configurable',
+ ],
+ 'field_layout' => [
+ '#content' => [
+ 'left' => [
+ '#process' => ['\Drupal\Core\Render\Element\RenderElement::processGroup'],
+ '#pre_render' => ['\Drupal\Core\Render\Element\RenderElement::preRenderGroup'],
+ ],
+ 'right' => [
+ '#process' => ['\Drupal\Core\Render\Element\RenderElement::processGroup'],
+ '#pre_render' => ['\Drupal\Core\Render\Element\RenderElement::preRenderGroup'],
+ ],
+ ],
+ '#process' => [[LayoutDefault::class, 'processContent']],
+ '#settings' => [],
+ '#layout' => $this->pluginDefinition,
+ '#theme' => 'field_layout__twocol',
+ '#attached' => [
+ 'library' => [
+ 'field_layout/drupal.field_layout.twocol',
+ ],
+ ],
+ ],
+ ];
+ $this->fieldLayoutBuilder->build($build, $display->reveal(), $display_context);
+ $this->assertEquals($expected, $build);
+ $this->assertSame($expected, $build);
+ }
+
+ /**
+ * @covers ::build
+ */
+ public function testBuildEmpty() {
+ $definitions = [];
+ $non_configurable_field_definition = $this->prophesize(FieldDefinitionInterface::class);
+ $non_configurable_field_definition->isDisplayConfigurable('form')->willReturn(FALSE);
+ $definitions['non_configurable_field'] = $non_configurable_field_definition->reveal();
+ $this->entityFieldManager->getFieldDefinitions('the_entity_type_id', 'the_entity_type_bundle')->willReturn($definitions);
+
+ $build = [
+ 'non_configurable_field' => [
+ '#markup' => 'Non-configurable',
+ ],
+ ];
+
+ $display = $this->prophesize(EntityDisplayWithLayoutInterface::class);
+ $display->getTargetEntityTypeId()->willReturn('the_entity_type_id');
+ $display->getTargetBundle()->willReturn('the_entity_type_bundle');
+ $display->getLayoutPlugin()->willReturn($this->layoutPlugin);
+ $display->getLayoutId()->willReturn('twocol');
+ $display->getLayoutSettings()->willReturn([]);
+ $display->getComponents()->willReturn([
+ 'test1' => [
+ 'region' => 'right',
+ ],
+ 'non_configurable_field' => [
+ 'region' => 'left',
+ ],
+ ]);
+
+ $display_context = 'form';
+
+ $expected = [
+ 'non_configurable_field' => [
+ '#markup' => 'Non-configurable',
+ ],
+ ];
+ $this->fieldLayoutBuilder->build($build, $display->reveal(), $display_context);
+ $this->assertSame($expected, $build);
+ }
+
+ /**
+ * @covers ::build
+ */
+ public function testBuildNoLayout() {
+ $this->entityFieldManager->getFieldDefinitions(Argument::any(), Argument::any())->shouldNotBeCalled();
+
+ $build = [
+ 'test1' => [
+ '#markup' => 'Test1',
+ ],
+ ];
+
+ $display = $this->prophesize(EntityDisplayWithLayoutInterface::class);
+ $display->getLayoutId()->willReturn('unknown');
+ $display->getLayoutSettings()->willReturn([]);
+ $display->getComponents()->shouldNotBeCalled();
+
+ $display_context = 'form';
+
+ $expected = [
+ 'test1' => [
+ '#markup' => 'Test1',
+ ],
+ ];
+ $this->fieldLayoutBuilder->build($build, $display->reveal(), $display_context);
+ $this->assertSame($expected, $build);
+ }
+
+}
diff --git a/core/modules/layout_plugin/config/schema/layout_plugin.schema.yml b/core/modules/layout_plugin/config/schema/layout_plugin.schema.yml
new file mode 100644
index 0000000..f850db3
--- /dev/null
+++ b/core/modules/layout_plugin/config/schema/layout_plugin.schema.yml
@@ -0,0 +1,6 @@
+layout_plugin.settings:
+ type: mapping
+ label: 'Layout settings'
+
+layout_plugin.settings.*:
+ type: layout_plugin.settings
diff --git a/core/modules/layout_plugin/layout_plugin.api.php b/core/modules/layout_plugin/layout_plugin.api.php
new file mode 100644
index 0000000..e77fd9f
--- /dev/null
+++ b/core/modules/layout_plugin/layout_plugin.api.php
@@ -0,0 +1,26 @@
+' . t('About') . '';
+ $output .= '
' . t('Layout Plugin 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 Plugin module.', [':layout-plugin-documentation' => 'https://www.drupal.org/node/2619128']) . '
';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function layout_plugin_theme($existing, $type, $theme, $path) {
+ return \Drupal::service('plugin.manager.layout_plugin')->getThemeImplementations();
+}
diff --git a/core/modules/layout_plugin/layout_plugin.services.yml b/core/modules/layout_plugin/layout_plugin.services.yml
new file mode 100644
index 0000000..74225de
--- /dev/null
+++ b/core/modules/layout_plugin/layout_plugin.services.yml
@@ -0,0 +1,4 @@
+services:
+ plugin.manager.layout_plugin:
+ class: Drupal\layout_plugin\LayoutPluginManager
+ arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@theme_handler']
diff --git a/core/modules/layout_plugin/src/Annotation/Layout.php b/core/modules/layout_plugin/src/Annotation/Layout.php
new file mode 100644
index 0000000..3a615da
--- /dev/null
+++ b/core/modules/layout_plugin/src/Annotation/Layout.php
@@ -0,0 +1,150 @@
+definition);
+ }
+
+}
diff --git a/core/modules/layout_plugin/src/DerivablePluginDefinitionInterface.php b/core/modules/layout_plugin/src/DerivablePluginDefinitionInterface.php
new file mode 100644
index 0000000..1818685
--- /dev/null
+++ b/core/modules/layout_plugin/src/DerivablePluginDefinitionInterface.php
@@ -0,0 +1,37 @@
+themeHandler = $theme_handler;
+
+ $this->setCacheBackend($cache_backend, 'layout');
+ $this->alterInfo('layout');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function providerExists($provider) {
+ return $this->moduleHandler->moduleExists($provider) || $this->themeHandler->themeExists($provider);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getDiscovery() {
+ if (!$this->discovery) {
+ $discovery = new AnnotatedClassDiscovery($this->subdir, $this->namespaces, $this->pluginDefinitionAnnotationName, $this->additionalAnnotationNamespaces);
+ $discovery = new YamlDiscoveryDecorator($discovery, 'layouts', $this->moduleHandler->getModuleDirectories() + $this->themeHandler->getThemeDirectories());
+ $discovery = new ObjectDefinitionDiscoveryDecorator($discovery, $this->pluginDefinitionAnnotationName);
+ $discovery = new ObjectDefinitionContainerDerivativeDiscoveryDecorator($discovery);
+ $this->discovery = $discovery;
+ }
+ return $this->discovery;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function processDefinition(&$definition, $plugin_id) {
+ parent::processDefinition($definition, $plugin_id);
+
+ if (!$definition instanceof LayoutDefinition) {
+ throw new InvalidPluginDefinitionException($plugin_id, sprintf('The "%s" layout definition must extend %s', $plugin_id, LayoutDefinition::class));
+ }
+
+ // Keep class definitions standard with no leading slash.
+ $definition->setClass(ltrim($definition->getClass(), '\\'));
+
+ // Add the module or theme path to the 'path'.
+ $provider = $definition->getProvider();
+ if ($this->moduleHandler->moduleExists($provider)) {
+ $base_path = $this->moduleHandler->getModule($provider)->getPath();
+ }
+ elseif ($this->themeHandler->themeExists($provider)) {
+ $base_path = $this->themeHandler->getTheme($provider)->getPath();
+ }
+ else {
+ $base_path = '';
+ }
+
+ $path = $definition->getPath();
+ $path = !empty($path) ? $base_path . '/' . $path : $base_path;
+ $definition->setPath($path);
+
+ // Add the base path to the icon path.
+ if ($icon_path = $definition->getIconPath()) {
+ $definition->setIconPath($path . '/' . $icon_path);
+ }
+
+ // Add a dependency on the provider of the library.
+ if ($library = $definition->getLibrary()) {
+ $config_dependencies = $definition->getConfigDependencies();
+ list($library_provider) = explode('/', $library, 2);
+ if ($this->moduleHandler->moduleExists($library_provider)) {
+ $config_dependencies['module'][] = $library_provider;
+ }
+ elseif ($this->themeHandler->themeExists($library_provider)) {
+ $config_dependencies['theme'][] = $library_provider;
+ }
+ $definition->setConfigDependencies($config_dependencies);
+ }
+
+ // If 'template' is set, then we'll derive 'template_path' and 'theme_hook'.
+ $template = $definition->getTemplate();
+ if (!empty($template)) {
+ $template_parts = explode('/', $template);
+
+ $template = array_pop($template_parts);
+ $template_path = $path;
+ if (count($template_parts) > 0) {
+ $template_path .= '/' . implode('/', $template_parts);
+ }
+ $definition->setTemplate($template);
+ $definition->setThemeHook(strtr($template, '-', '_'));
+ $definition->setTemplatePath($template_path);
+ }
+
+ if (!$definition->getDefaultRegion()) {
+ $definition->setDefaultRegion(key($definition->getRegions()));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getThemeImplementations() {
+ $hooks = [];
+ /** @var \Drupal\layout_plugin\Plugin\Layout\LayoutDefinition[] $definitions */
+ $definitions = $this->getDefinitions();
+ foreach ($definitions as $definition) {
+ if ($template = $definition->getTemplate()) {
+ $hooks[$definition->getThemeHook()] = [
+ 'variables' => [
+ 'content' => [],
+ 'settings' => [],
+ 'layout' => [],
+ ],
+ 'template' => $template,
+ 'path' => $definition->getTemplatePath(),
+ ];
+ }
+ }
+ return $hooks;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCategories() {
+ // Fetch all categories from definitions and remove duplicates.
+ $categories = array_unique(array_values(array_map(function (LayoutDefinition $definition) {
+ return $definition->getCategory();
+ }, $this->getDefinitions())));
+ natcasesort($categories);
+ return $categories;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @return \Drupal\layout_plugin\Plugin\Layout\LayoutDefinition[]
+ */
+ public function getSortedDefinitions(array $definitions = NULL, $label_key = 'label') {
+ // Sort the plugins first by category, then by label.
+ $definitions = isset($definitions) ? $definitions : $this->getDefinitions();
+ // Suppress errors because PHPUnit will indirectly modify the contents,
+ // triggering https://bugs.php.net/bug.php?id=50688.
+ @uasort($definitions, function (LayoutDefinition $a, LayoutDefinition $b) {
+ if ($a->getCategory() != $b->getCategory()) {
+ return strnatcasecmp($a->getCategory(), $b->getCategory());
+ }
+ return strnatcasecmp($a->getLabel(), $b->getLabel());
+ });
+ return $definitions;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @return \Drupal\layout_plugin\Plugin\Layout\LayoutDefinition[][]
+ */
+ public function getGroupedDefinitions(array $definitions = NULL, $label_key = 'label') {
+ $definitions = $this->getSortedDefinitions(isset($definitions) ? $definitions : $this->getDefinitions(), $label_key);
+ $grouped_definitions = [];
+ foreach ($definitions as $id => $definition) {
+ $grouped_definitions[(string) $definition->getCategory()][$id] = $definition;
+ }
+ return $grouped_definitions;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getLayoutOptions() {
+ $layout_options = [];
+ foreach ($this->getGroupedDefinitions() as $category => $layout_definitions) {
+ foreach ($layout_definitions as $name => $layout_definition) {
+ $layout_options[$category][$name] = $layout_definition->getLabel();
+ }
+ }
+ return $layout_options;
+ }
+
+}
diff --git a/core/modules/layout_plugin/src/LayoutPluginManagerInterface.php b/core/modules/layout_plugin/src/LayoutPluginManagerInterface.php
new file mode 100644
index 0000000..3f0a084
--- /dev/null
+++ b/core/modules/layout_plugin/src/LayoutPluginManagerInterface.php
@@ -0,0 +1,65 @@
+getDeriver()) {
+ if (!class_exists($class)) {
+ throw new InvalidDeriverException(sprintf('Plugin (%s) deriver "%s" does not exist.', $base_definition['id'], $class));
+ }
+ if (!is_subclass_of($class, '\Drupal\Component\Plugin\Derivative\DeriverInterface')) {
+ throw new InvalidDeriverException(sprintf('Plugin (%s) deriver "%s" must implement \Drupal\Component\Plugin\Derivative\DeriverInterface.', $base_definition['id'], $class));
+ }
+ }
+ return $class;
+ }
+
+}
diff --git a/core/modules/layout_plugin/src/ObjectDefinitionDiscoveryDecorator.php b/core/modules/layout_plugin/src/ObjectDefinitionDiscoveryDecorator.php
new file mode 100644
index 0000000..013f753
--- /dev/null
+++ b/core/modules/layout_plugin/src/ObjectDefinitionDiscoveryDecorator.php
@@ -0,0 +1,73 @@
+decorated = $decorated;
+ $this->pluginDefinitionAnnotationName = $plugin_definition_annotation_name;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDefinitions() {
+ $definitions = $this->decorated->getDefinitions();
+ foreach ($definitions as $id => $definition) {
+ if (is_array($definition)) {
+ $definitions[$id] = (new $this->pluginDefinitionAnnotationName($definition))->get();
+ }
+ }
+ return $definitions;
+ }
+
+ /**
+ * Passes through all unknown calls onto the decorated object.
+ *
+ * @param string $method
+ * The method to call on the decorated plugin discovery.
+ * @param array $args
+ * The arguments to send to the method.
+ *
+ * @return mixed
+ * The method result.
+ */
+ public function __call($method, $args) {
+ return call_user_func_array([$this->decorated, $method], $args);
+ }
+
+}
diff --git a/core/modules/layout_plugin/src/Plugin/Layout/LayoutBase.php b/core/modules/layout_plugin/src/Plugin/Layout/LayoutBase.php
new file mode 100644
index 0000000..b6cf6ed
--- /dev/null
+++ b/core/modules/layout_plugin/src/Plugin/Layout/LayoutBase.php
@@ -0,0 +1,99 @@
+setConfiguration($configuration);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function build(array $regions) {
+ $build['#content'] = array_intersect_key($regions, $this->pluginDefinition->getRegions());
+ $build['#process'][] = [static::class, 'processContent'];
+ $build['#settings'] = $this->getConfiguration();
+ $build['#layout'] = $this->pluginDefinition;
+ $build['#theme'] = $this->pluginDefinition->getThemeHook();
+ if ($library = $this->pluginDefinition->getLibrary()) {
+ $build['#attached']['library'][] = $library;
+ }
+ return $build;
+ }
+
+ /**
+ * Moves the content so that it can be processed properly by the form builder.
+ */
+ public static function processContent($element) {
+ $element['#pre_render'][] = [static::class, 'preRenderContent'];
+ $element['content'] = $element['#content'];
+ unset($element['#content']);
+ return $element;
+ }
+
+ /**
+ * Moves the content back to the location expected by the #theme hook.
+ */
+ public static function preRenderContent($element) {
+ $element['#content'] = $element['content'];
+ unset($element['content']);
+ return $element;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfiguration() {
+ return $this->configuration;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setConfiguration(array $configuration) {
+ $this->configuration = NestedArray::mergeDeep($this->defaultConfiguration(), $configuration);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function defaultConfiguration() {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function calculateDependencies() {
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @return \Drupal\layout_plugin\Plugin\Layout\LayoutDefinition
+ */
+ public function getPluginDefinition() {
+ return parent::getPluginDefinition();
+ }
+
+}
diff --git a/core/modules/layout_plugin/src/Plugin/Layout/LayoutDefault.php b/core/modules/layout_plugin/src/Plugin/Layout/LayoutDefault.php
new file mode 100644
index 0000000..462b68d
--- /dev/null
+++ b/core/modules/layout_plugin/src/Plugin/Layout/LayoutDefault.php
@@ -0,0 +1,10 @@
+ $value) {
+ $this->set($property, $value);
+ }
+ }
+
+ /**
+ * Gets any arbitrary property.
+ *
+ * @param string $property
+ * The property to retrieve.
+ *
+ * @return mixed
+ * The value for that property, or NULL if the property does not exist.
+ */
+ public function get($property) {
+ if (property_exists($this, $property)) {
+ $value = isset($this->{$property}) ? $this->{$property} : NULL;
+ }
+ else {
+ $value = isset($this->additional[$property]) ? $this->additional[$property] : NULL;
+ }
+ return $value;
+ }
+
+ /**
+ * Sets a value to an arbitrary property.
+ *
+ * @param string $property
+ * The property to use for the value.
+ * @param mixed $value
+ * The value to set.
+ *
+ * @return $this
+ */
+ public function set($property, $value) {
+ if (property_exists($this, $property)) {
+ $this->{$property} = $value;
+ }
+ else {
+ $this->additional[$property] = $value;
+ }
+ return $this;
+ }
+
+ /**
+ * Gets the unique identifier of the layout definition.
+ *
+ * @return string
+ * The unique identifier of the layout definition.
+ */
+ public function id() {
+ return $this->id;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getClass() {
+ return $this->class;
+ }
+
+ /**
+ * Gets the name of the original layout class.
+ *
+ * In case the class name was changed with setClass(), this will return
+ * the initial value.
+ *
+ * @return string
+ * The name of the original layout class.
+ */
+ public function getOriginalClass() {
+ return $this->originalClass ?: $this->class;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setClass($class) {
+ if (!$this->originalClass && $this->class) {
+ // If the original class is currently not set, set it to the current
+ // class, assume that is the original class name.
+ $this->originalClass = $this->class;
+ }
+ $this->class = $class;
+ return $this;
+ }
+
+ /**
+ * Gets the human-readable name of the layout definition.
+ *
+ * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup
+ * The human-readable name of the layout definition.
+ */
+ public function getLabel() {
+ return $this->label;
+ }
+
+ /**
+ * Sets the human-readable name of the layout definition.
+ *
+ * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $label
+ * The human-readable name of the layout definition.
+ *
+ * @return $this
+ */
+ public function setLabel($label) {
+ $this->label = $label;
+ return $this;
+ }
+
+ /**
+ * Gets the description of the layout definition.
+ *
+ * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup
+ * The description of the layout definition.
+ */
+ public function getDescription() {
+ return $this->description;
+ }
+
+ /**
+ * Sets the description of the layout definition.
+ *
+ * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $description
+ * The description of the layout definition.
+ *
+ * @return $this
+ */
+ public function setDescription($description) {
+ $this->description = $description;
+ return $this;
+ }
+
+ /**
+ * Gets the human-readable category of the layout definition.
+ *
+ * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup
+ * The human-readable category of the layout definition.
+ */
+ public function getCategory() {
+ return $this->category;
+ }
+
+ /**
+ * Sets the human-readable category of the layout definition.
+ *
+ * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $category
+ * The human-readable category of the layout definition.
+ *
+ * @return $this
+ */
+ public function setCategory($category) {
+ $this->category = $category;
+ return $this;
+ }
+
+ /**
+ * Gets the template name.
+ *
+ * @return string|null
+ * The template name, if it exists.
+ */
+ public function getTemplate() {
+ return $this->template;
+ }
+
+ /**
+ * Sets the template name.
+ *
+ * @param string|null $template
+ * The template name.
+ *
+ * @return $this
+ */
+ public function setTemplate($template) {
+ $this->template = $template;
+ return $this;
+ }
+
+ /**
+ * Gets the template path.
+ *
+ * @return string
+ * The template path.
+ */
+ public function getTemplatePath() {
+ return $this->templatePath;
+ }
+
+ /**
+ * Sets the template path.
+ *
+ * @param string $template_path
+ * The template path.
+ *
+ * @return $this
+ */
+ public function setTemplatePath($template_path) {
+ $this->templatePath = $template_path;
+ return $this;
+ }
+
+ /**
+ * Gets the theme hook.
+ *
+ * @return string|null
+ * The theme hook, if it exists.
+ */
+ public function getThemeHook() {
+ return $this->theme_hook;
+ }
+
+ /**
+ * Sets the theme hook.
+ *
+ * @param string $theme_hook
+ * The theme hook.
+ *
+ * @return $this
+ */
+ public function setThemeHook($theme_hook) {
+ $this->theme_hook = $theme_hook;
+ return $this;
+ }
+
+ /**
+ * Gets the base path for this layout definition.
+ *
+ * @return string
+ * The base path.
+ */
+ public function getPath() {
+ return $this->path;
+ }
+
+ /**
+ * Sets the base path for this layout definition.
+ *
+ * @param string $path
+ * The base path.
+ *
+ * @return $this
+ */
+ public function setPath($path) {
+ $this->path = $path;
+ return $this;
+ }
+
+ /**
+ * Gets the asset library for this layout definition.
+ *
+ * @return string|null
+ * The asset library, if it exists.
+ */
+ public function getLibrary() {
+ return $this->library;
+ }
+
+ /**
+ * Sets the asset library for this layout definition.
+ *
+ * @param string|null $library
+ * The asset library.
+ *
+ * @return $this
+ */
+ public function setLibrary($library) {
+ $this->library = $library;
+ return $this;
+ }
+
+ /**
+ * Gets the icon path for this layout definition.
+ *
+ * @return string|null
+ * The icon path, if it exists.
+ */
+ public function getIconPath() {
+ return $this->icon;
+ }
+
+ /**
+ * Sets the icon path for this layout definition.
+ *
+ * @param string|null $icon
+ * The icon path.
+ *
+ * @return $this
+ */
+ public function setIconPath($icon) {
+ $this->icon = $icon;
+ return $this;
+ }
+
+ /**
+ * Gets the regions for this layout definition.
+ *
+ * @return array[]
+ * The layout regions. The keys of the array are the machine names of the
+ * regions, and the values are an associative array with the following
+ * keys:
+ * - label: (string) The human-readable name of the region.
+ * Any remaining keys may have special meaning for the given layout plugin,
+ * but are undefined here.
+ */
+ public function getRegions() {
+ return $this->regions;
+ }
+
+ /**
+ * Sets the regions for this layout definition.
+ *
+ * @param array[] $regions
+ * An array of regions, see ::getRegions() for the format.
+ *
+ * @return $this
+ */
+ public function setRegions(array $regions) {
+ $this->regions = $regions;
+ return $this;
+ }
+
+ /**
+ * Gets the machine-readable region names.
+ *
+ * @return string[]
+ * An array of machine-readable region names.
+ */
+ public function getRegionNames() {
+ return array_keys($this->getRegions());
+ }
+
+ /**
+ * Gets the human-readable region labels.
+ *
+ * @return string[]
+ * An array of human-readable region labels.
+ */
+ public function getRegionLabels() {
+ $regions = $this->getRegions();
+ return array_combine(array_keys($regions), array_column($regions, 'label'));
+ }
+
+ /**
+ * Gets the default region.
+ *
+ * @return string
+ * The machine-readable name of the default region.
+ */
+ public function getDefaultRegion() {
+ return $this->default_region;
+ }
+
+ /**
+ * Sets the default region.
+ *
+ * @param string $default_region
+ * The machine-readable name of the default region.
+ *
+ * @return $this
+ */
+ public function setDefaultRegion($default_region) {
+ $this->default_region = $default_region;
+ return $this;
+ }
+
+ /**
+ * Gets the name of the provider of this layout definition.
+ *
+ * @return string
+ * The name of the provider of this layout definition.
+ */
+ public function getProvider() {
+ return $this->provider;
+ }
+
+ /**
+ * Gets the config dependencies of this layout definition.
+ *
+ * @return array
+ * An array of config dependencies.
+ *
+ * @see \Drupal\Core\Plugin\PluginDependencyTrait::calculatePluginDependencies()
+ */
+ public function getConfigDependencies() {
+ return $this->config_dependencies;
+ }
+
+ /**
+ * Sets the config dependencies of this layout definition.
+ *
+ * @param array $config_dependencies
+ * An array of config dependencies.
+ *
+ * @return $this
+ */
+ public function setConfigDependencies(array $config_dependencies) {
+ $this->config_dependencies = $config_dependencies;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDeriver() {
+ return $this->deriver;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setDeriver($deriver) {
+ $this->deriver = $deriver;
+ return $this;
+ }
+
+}
diff --git a/core/modules/layout_plugin/src/Plugin/Layout/LayoutInterface.php b/core/modules/layout_plugin/src/Plugin/Layout/LayoutInterface.php
new file mode 100644
index 0000000..ccfd68e
--- /dev/null
+++ b/core/modules/layout_plugin/src/Plugin/Layout/LayoutInterface.php
@@ -0,0 +1,33 @@
+ 'Default',
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+ $form['setting_1'] = [
+ '#type' => 'textfield',
+ '#title' => 'Blah',
+ '#default_value' => $this->configuration['setting_1'],
+ ];
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+ $this->configuration['setting_1'] = $form_state->getValue('setting_1');
+ }
+
+}
diff --git a/core/modules/layout_plugin/tests/modules/layout_test/templates/layout-test-1col.html.twig b/core/modules/layout_plugin/tests/modules/layout_test/templates/layout-test-1col.html.twig
new file mode 100644
index 0000000..e7a7eb5
--- /dev/null
+++ b/core/modules/layout_plugin/tests/modules/layout_test/templates/layout-test-1col.html.twig
@@ -0,0 +1,14 @@
+{#
+/**
+ * @file
+ * Template for an example 1 column layout.
+ */
+#}
+
+
+ {{ content.top }}
+
+
+ {{ content.bottom }}
+
+
diff --git a/core/modules/layout_plugin/tests/modules/layout_test/templates/layout-test-2col.html.twig b/core/modules/layout_plugin/tests/modules/layout_test/templates/layout-test-2col.html.twig
new file mode 100644
index 0000000..11433ee
--- /dev/null
+++ b/core/modules/layout_plugin/tests/modules/layout_test/templates/layout-test-2col.html.twig
@@ -0,0 +1,14 @@
+{#
+/**
+ * @file
+ * Template for an example 2 column layout.
+ */
+#}
+
+
+ {{ content.left }}
+
+
+ {{ content.right }}
+
+
diff --git a/core/modules/layout_plugin/tests/modules/layout_test/templates/layout-test-plugin.html.twig b/core/modules/layout_plugin/tests/modules/layout_test/templates/layout-test-plugin.html.twig
new file mode 100644
index 0000000..e49942c
--- /dev/null
+++ b/core/modules/layout_plugin/tests/modules/layout_test/templates/layout-test-plugin.html.twig
@@ -0,0 +1,15 @@
+{#
+/**
+ * @file
+ * Template for layout_test_plugin layout.
+ */
+#}
+
+
+ Blah:
+ {{ settings.setting_1 }}
+
+
+ {{ content.main }}
+
+
diff --git a/core/modules/layout_plugin/tests/src/Kernel/LayoutTest.php b/core/modules/layout_plugin/tests/src/Kernel/LayoutTest.php
new file mode 100644
index 0000000..b255015
--- /dev/null
+++ b/core/modules/layout_plugin/tests/src/Kernel/LayoutTest.php
@@ -0,0 +1,167 @@
+layoutPluginManager = $this->container->get('plugin.manager.layout_plugin');
+ }
+
+ /**
+ * Test rendering a layout.
+ *
+ * @dataProvider renderLayoutData
+ */
+ public function testRenderLayout($layout_id, $config, $regions, $html) {
+ $layout = $this->layoutPluginManager->createInstance($layout_id, $config);
+ $built = $layout->build($regions);
+
+ // Assume each layout is contained by a form, in order to ensure the
+ // building of the layout does not interfere with form processing.
+ $form_state = new FormState();
+ $form_builder = $this->container->get('form_builder');
+ $form_builder->prepareForm('the_form_id', $built, $form_state);
+ $form_builder->processForm('the_form_id', $built, $form_state);
+
+ $this->render($built);
+ $this->assertRaw($html);
+ }
+
+ /**
+ * Data provider for testRenderLayout().
+ */
+ public function renderLayoutData() {
+ $data['layout_test_1col_with_form'] = [
+ 'layout_test_1col',
+ [],
+ [
+ 'top' => [
+ '#process' => [[static::class, 'processCallback']],
+ ],
+ 'bottom' => [
+ '#markup' => 'This is the bottom',
+ ],
+ ],
+ ];
+
+ $data['layout_test_1col'] = [
+ 'layout_test_1col',
+ [],
+ [
+ 'top' => [
+ '#markup' => 'This is the top',
+ ],
+ 'bottom' => [
+ '#markup' => 'This is the bottom',
+ ],
+ ],
+ ];
+
+ $data['layout_test_2col'] = [
+ 'layout_test_2col',
+ [],
+ [
+ 'left' => [
+ '#markup' => 'This is the left',
+ ],
+ 'right' => [
+ '#markup' => 'This is the right',
+ ],
+ ],
+ ];
+
+ $data['layout_test_plugin'] = [
+ 'layout_test_plugin',
+ [
+ 'setting_1' => 'Config value',
+ ],
+ [
+ 'main' => [
+ '#markup' => 'Main region',
+ ],
+ ],
+ ];
+
+ $data['layout_test_1col_with_form'][] = <<<'EOD'
+
+
+ This string added by #process.
+
+
+ This is the bottom
+
+
+EOD;
+
+ $data['layout_test_1col'][] = <<<'EOD'
+
+
+ This is the top
+
+
+ This is the bottom
+
+
+EOD;
+
+ $data['layout_test_2col'][] = <<<'EOD'
+
+
+ This is the left
+
+
+ This is the right
+
+
+EOD;
+
+ $data['layout_test_plugin'][] = <<<'EOD'
+
+
+ Blah:
+ Config value
+
+
+ Main region
+
+
+EOD;
+
+ return $data;
+ }
+
+ /**
+ * Provides a test #process callback.
+ */
+ public static function processCallback($element) {
+ $element['#markup'] = 'This string added by #process.';
+ return $element;
+ }
+
+}
diff --git a/core/modules/layout_plugin/tests/src/Unit/LayoutPluginManagerTest.php b/core/modules/layout_plugin/tests/src/Unit/LayoutPluginManagerTest.php
new file mode 100644
index 0000000..ee4978f
--- /dev/null
+++ b/core/modules/layout_plugin/tests/src/Unit/LayoutPluginManagerTest.php
@@ -0,0 +1,392 @@
+setUpFilesystem();
+
+ $container = new ContainerBuilder();
+ $container->set('string_translation', $this->getStringTranslationStub());
+ \Drupal::setContainer($container);
+
+ $this->moduleHandler = $this->prophesize(ModuleHandlerInterface::class);
+
+ $this->moduleHandler->moduleExists('module_a')->willReturn(TRUE);
+ $this->moduleHandler->moduleExists('theme_a')->willReturn(FALSE);
+ $this->moduleHandler->moduleExists('core')->willReturn(FALSE);
+ $this->moduleHandler->moduleExists('invalid_provider')->willReturn(FALSE);
+
+ $module_a = new Extension('/', 'module', vfsStream::url('root/modules/module_a/module_a.layouts.yml'));
+ $this->moduleHandler->getModule('module_a')->willReturn($module_a);
+ $this->moduleHandler->getModuleDirectories()->willReturn(['module_a' => vfsStream::url('root/modules/module_a')]);
+ $this->moduleHandler->alter('layout', Argument::type('array'))->shouldBeCalled();
+
+ $this->themeHandler = $this->prophesize(ThemeHandlerInterface::class);
+
+ $this->themeHandler->themeExists('theme_a')->willReturn(TRUE);
+ $this->themeHandler->themeExists('core')->willReturn(FALSE);
+ $this->themeHandler->themeExists('invalid_provider')->willReturn(FALSE);
+
+ $theme_a = new Extension('/', 'theme', vfsStream::url('root/themes/theme_a/theme_a.layouts.yml'));
+ $this->themeHandler->getTheme('theme_a')->willReturn($theme_a);
+ $this->themeHandler->getThemeDirectories()->willReturn(['theme_a' => vfsStream::url('root/themes/theme_a')]);
+
+ $this->cacheBackend = $this->prophesize(CacheBackendInterface::class);
+
+ $namespaces = new \ArrayObject(['Drupal\Core' => vfsStream::url('root/core/lib/Drupal/Core')]);
+ $this->layoutPluginManager = new LayoutPluginManager($namespaces, $this->cacheBackend->reveal(), $this->moduleHandler->reveal(), $this->themeHandler->reveal(), $this->getStringTranslationStub());
+ }
+
+ /**
+ * @covers ::getDefinitions
+ * @covers ::providerExists
+ */
+ public function testGetDefinitions() {
+ $expected = [
+ 'module_a_provided_layout',
+ 'theme_a_provided_layout',
+ 'plugin_provided_layout',
+ ];
+
+ $layout_definitions = $this->layoutPluginManager->getDefinitions();
+ $this->assertEquals($expected, array_keys($layout_definitions));
+ $this->assertContainsOnlyInstancesOf(LayoutDefinition::class, $layout_definitions);
+ }
+
+ /**
+ * @covers ::getDefinition
+ * @covers ::processDefinition
+ */
+ public function testGetDefinition() {
+ $theme_a_path = vfsStream::url('root/themes/theme_a');
+ $layout_definition = $this->layoutPluginManager->getDefinition('theme_a_provided_layout');
+ $this->assertSame('theme_a_provided_layout', $layout_definition->id());
+ $this->assertSame('2 column layout', $layout_definition->getLabel());
+ $this->assertSame('Columns: 2', $layout_definition->getCategory());
+ $this->assertSame('twocol', $layout_definition->getTemplate());
+ $this->assertSame("$theme_a_path/templates", $layout_definition->getPath());
+ $this->assertSame('theme_a/twocol', $layout_definition->getLibrary());
+ $this->assertSame('twocol', $layout_definition->getThemeHook());
+ $this->assertSame("$theme_a_path/templates", $layout_definition->getTemplatePath());
+ $this->assertSame('theme_a', $layout_definition->getProvider());
+ $this->assertSame('right', $layout_definition->getDefaultRegion());
+ $this->assertSame(LayoutDefault::class, $layout_definition->getClass());
+ $expected_regions = [
+ 'left' => [
+ 'label' => 'Left region',
+ ],
+ 'right' => [
+ 'label' => 'Right region',
+ ],
+ ];
+ $this->assertSame($expected_regions, $layout_definition->getRegions());
+
+ $module_a_path = vfsStream::url('root/modules/module_a');
+ $layout_definition = $this->layoutPluginManager->getDefinition('module_a_provided_layout');
+ $this->assertSame('module_a_provided_layout', $layout_definition->id());
+ $this->assertSame('1 column layout', $layout_definition->getLabel());
+ $this->assertSame('Columns: 1', $layout_definition->getCategory());
+ $this->assertSame(NULL, $layout_definition->getTemplate());
+ $this->assertSame("$module_a_path/layouts", $layout_definition->getPath());
+ $this->assertSame('module_a/onecol', $layout_definition->getLibrary());
+ $this->assertSame('onecol', $layout_definition->getThemeHook());
+ $this->assertSame(NULL, $layout_definition->getTemplatePath());
+ $this->assertSame('module_a', $layout_definition->getProvider());
+ $this->assertSame('top', $layout_definition->getDefaultRegion());
+ $this->assertSame(LayoutDefault::class, $layout_definition->getClass());
+ $expected_regions = [
+ 'top' => [
+ 'label' => 'Top region',
+ ],
+ 'bottom' => [
+ 'label' => 'Bottom region',
+ ],
+ ];
+ $this->assertSame($expected_regions, $layout_definition->getRegions());
+
+ $core_path = '/core/lib/Drupal/Core';
+ $layout_definition = $this->layoutPluginManager->getDefinition('plugin_provided_layout');
+ $this->assertSame('plugin_provided_layout', $layout_definition->id());
+ $this->assertEquals('Layout plugin', $layout_definition->getLabel());
+ $this->assertEquals('Columns: 1', $layout_definition->getCategory());
+ $this->assertSame('plugin-provided-layout', $layout_definition->getTemplate());
+ $this->assertSame($core_path, $layout_definition->getPath());
+ $this->assertSame(NULL, $layout_definition->getLibrary());
+ $this->assertSame('plugin_provided_layout', $layout_definition->getThemeHook());
+ $this->assertSame("$core_path/templates", $layout_definition->getTemplatePath());
+ $this->assertSame('core', $layout_definition->getProvider());
+ $this->assertSame('main', $layout_definition->getDefaultRegion());
+ $this->assertSame('Drupal\Core\Plugin\Layout\TestLayout', $layout_definition->getClass());
+ $expected_regions = [
+ 'main' => [
+ 'label' => 'Main Region',
+ ],
+ ];
+ $this->assertEquals($expected_regions, $layout_definition->getRegions());
+ }
+
+ /**
+ * @covers ::processDefinition
+ */
+ public function testProcessDefinition() {
+ $this->moduleHandler->alter('layout', Argument::type('array'))->shouldNotBeCalled();
+ $this->setExpectedException(InvalidPluginDefinitionException::class, 'The "module_a_derived_layout:array_based" layout definition must extend ' . LayoutDefinition::class);
+ $module_a_provided_layout = <<<'EOS'
+module_a_derived_layout:
+ deriver: \Drupal\Tests\layout_plugin\Unit\LayoutDeriver
+ array_based: true
+EOS;
+ vfsStream::create([
+ 'modules' => [
+ 'module_a' => [
+ 'module_a.layouts.yml' => $module_a_provided_layout,
+ ],
+ ],
+ ]);
+ $this->layoutPluginManager->getDefinitions();
+ }
+
+ /**
+ * @covers ::getThemeImplementations
+ */
+ public function testGetThemeImplementations() {
+ $core_path = '/core/lib/Drupal/Core';
+ $theme_a_path = vfsStream::url('root/themes/theme_a');
+ $expected = [
+ 'twocol' => [
+ 'variables' => [
+ 'content' => [],
+ 'settings' => [],
+ 'layout' => [],
+ ],
+ 'template' => 'twocol',
+ 'path' => "$theme_a_path/templates",
+ ],
+ 'plugin_provided_layout' => [
+ 'variables' => [
+ 'content' => [],
+ 'settings' => [],
+ 'layout' => [],
+ ],
+ 'template' => 'plugin-provided-layout',
+ 'path' => "$core_path/templates",
+ ],
+ ];
+ $theme_implementations = $this->layoutPluginManager->getThemeImplementations();
+ $this->assertEquals($expected, $theme_implementations);
+ }
+
+ /**
+ * @covers ::getCategories
+ */
+ public function testGetCategories() {
+ $expected = [
+ 'Columns: 1',
+ 'Columns: 2',
+ ];
+ $categories = $this->layoutPluginManager->getCategories();
+ $this->assertEquals($expected, $categories);
+ }
+
+ /**
+ * @covers ::getSortedDefinitions
+ */
+ public function testGetSortedDefinitions() {
+ $expected = [
+ 'module_a_provided_layout',
+ 'plugin_provided_layout',
+ 'theme_a_provided_layout',
+ ];
+
+ $layout_definitions = $this->layoutPluginManager->getSortedDefinitions();
+ $this->assertEquals($expected, array_keys($layout_definitions));
+ $this->assertContainsOnlyInstancesOf(LayoutDefinition::class, $layout_definitions);
+ }
+
+ /**
+ * @covers ::getGroupedDefinitions
+ */
+ public function testGetGroupedDefinitions() {
+ $category_expected = [
+ 'Columns: 1' => [
+ 'module_a_provided_layout',
+ 'plugin_provided_layout',
+ ],
+ 'Columns: 2' => [
+ 'theme_a_provided_layout',
+ ],
+ ];
+
+ $definitions = $this->layoutPluginManager->getGroupedDefinitions();
+ $this->assertEquals(array_keys($category_expected), array_keys($definitions));
+ foreach ($category_expected as $category => $expected) {
+ $this->assertArrayHasKey($category, $definitions);
+ $this->assertEquals($expected, array_keys($definitions[$category]));
+ $this->assertContainsOnlyInstancesOf(LayoutDefinition::class, $definitions[$category]);
+ }
+ }
+
+ /**
+ * Sets up the filesystem with YAML files and annotated plugins.
+ */
+ protected function setUpFilesystem() {
+ $module_a_provided_layout = <<<'EOS'
+module_a_provided_layout:
+ label: 1 column layout
+ category: 'Columns: 1'
+ theme_hook: onecol
+ path: layouts
+ library: module_a/onecol
+ regions:
+ top:
+ label: Top region
+ bottom:
+ label: Bottom region
+module_a_derived_layout:
+ deriver: \Drupal\Tests\layout_plugin\Unit\LayoutDeriver
+ invalid_provider: true
+EOS;
+ $theme_a_provided_layout = <<<'EOS'
+theme_a_provided_layout:
+ class: '\Drupal\layout_plugin\Plugin\Layout\LayoutDefault'
+ label: 2 column layout
+ category: 'Columns: 2'
+ template: twocol
+ path: templates
+ library: theme_a/twocol
+ default_region: right
+ regions:
+ left:
+ label: Left region
+ right:
+ label: Right region
+EOS;
+ $plugin_provided_layout = <<<'EOS'
+ [
+ 'module_a' => [
+ 'module_a.layouts.yml' => $module_a_provided_layout,
+ ],
+ ],
+ ]);
+ vfsStream::create([
+ 'themes' => [
+ 'theme_a' => [
+ 'theme_a.layouts.yml' => $theme_a_provided_layout,
+ ],
+ ],
+ ]);
+ vfsStream::create([
+ 'core' => [
+ 'lib' => [
+ 'Drupal' => [
+ 'Core' => [
+ 'Plugin' => [
+ 'Layout' => [
+ 'TestLayout.php' => $plugin_provided_layout,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+}
+/**
+ * Provides a dynamic layout deriver for the test.
+ */
+class LayoutDeriver extends DeriverBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDerivativeDefinitions($base_plugin_definition) {
+ if ($base_plugin_definition->get('array_based')) {
+ $this->derivatives['array_based'] = [];
+ }
+ if ($base_plugin_definition->get('invalid_provider')) {
+ $this->derivatives['invalid_provider'] = new LayoutDefinition([
+ 'id' => 'invalid_provider',
+ 'provider' => 'invalid_provider',
+ ]);
+ }
+ return $this->derivatives;
+ }
+
+}