diff --git a/core/composer.json b/core/composer.json index 53ab8fa..c52add0 100644 --- a/core/composer.json +++ b/core/composer.json @@ -94,6 +94,7 @@ "drupal/editor": "self.version", "drupal/entity_reference": "self.version", "drupal/field": "self.version", + "drupal/field_layout": "self.version", "drupal/field_ui": "self.version", "drupal/file": "self.version", "drupal/filter": "self.version", @@ -104,6 +105,7 @@ "drupal/image": "self.version", "drupal/inline_form_errors": "self.version", "drupal/language": "self.version", + "drupal/layout_plugin": "self.version", "drupal/link": "self.version", "drupal/locale": "self.version", "drupal/minimal": "self.version", diff --git a/core/modules/field_layout/config/schema/field_layout.schema.yml b/core/modules/field_layout/config/schema/field_layout.schema.yml new file mode 100644 index 0000000..3460b05 --- /dev/null +++ b/core/modules/field_layout/config/schema/field_layout.schema.yml @@ -0,0 +1,13 @@ +core.entity_view_display.*.*.*.third_party.field_layout: + type: field_layout.third_party_settings + +core.entity_form_display.*.*.*.third_party.field_layout: + type: field_layout.third_party_settings + +field_layout.third_party_settings: + type: mapping + label: 'Per-view mode field layout settings' + mapping: + layout: + type: string + label: 'Layout' diff --git a/core/modules/field_layout/field_layout.info.yml b/core/modules/field_layout/field_layout.info.yml new file mode 100644 index 0000000..67f2ba0 --- /dev/null +++ b/core/modules/field_layout/field_layout.info.yml @@ -0,0 +1,8 @@ +name: 'Field Layout' +type: module +description: 'Adds layout capabilities to the Field UI.' +package: Core (Experimental) +version: VERSION +core: 8.x +dependencies: + - layout_plugin diff --git a/core/modules/field_layout/field_layout.install b/core/modules/field_layout/field_layout.install new file mode 100644 index 0000000..456054e --- /dev/null +++ b/core/modules/field_layout/field_layout.install @@ -0,0 +1,26 @@ +save(); + }; + array_map($entity_save, EntityViewDisplay::loadMultiple()); + array_map($entity_save, EntityFormDisplay::loadMultiple()); + + // Invalidate the render cache since all content will now have a layout. + Cache::invalidateTags(['rendered']); +} diff --git a/core/modules/field_layout/field_layout.layouts.yml b/core/modules/field_layout/field_layout.layouts.yml new file mode 100644 index 0000000..0aaa87b --- /dev/null +++ b/core/modules/field_layout/field_layout.layouts.yml @@ -0,0 +1,21 @@ +onecol: + label: 'One column' + path: layouts/onecol + theme: field_layout__onecol + category: 'Columns: 1' + default_region: content + regions: + content: + label: Content +twocol: + label: 'Two column' + path: layouts/twocol + theme: field_layout__twocol + library: field_layout/drupal.field_layout.twocol + category: 'Columns: 2' + default_region: left + regions: + left: + label: Left + right: + label: Right diff --git a/core/modules/field_layout/field_layout.libraries.yml b/core/modules/field_layout/field_layout.libraries.yml new file mode 100644 index 0000000..d87df5e --- /dev/null +++ b/core/modules/field_layout/field_layout.libraries.yml @@ -0,0 +1,5 @@ +drupal.field_layout.twocol: + version: VERSION + css: + layout: + layouts/twocol/twocol.layout.css: {} diff --git a/core/modules/field_layout/field_layout.module b/core/modules/field_layout/field_layout.module new file mode 100644 index 0000000..8ce1400 --- /dev/null +++ b/core/modules/field_layout/field_layout.module @@ -0,0 +1,95 @@ +' . t('About') . ''; + $output .= '

' . t('The Field Layout module allows you to arrange fields into regions for content forms and displays.') . '

'; + $output .= '

' . t('For more information, see the online documentation for the Field Layout module.', [':field-layout-documentation' => 'https://www.drupal.org/documentation/modules/@todo_once_module_name_is_decided_upon']) . '

'; + return $output; + } +} + +/** + * Implements hook_entity_type_alter(). + */ +function field_layout_entity_type_alter(array &$entity_types) { + /** @var $entity_types \Drupal\Core\Entity\EntityTypeInterface[] */ + $entity_types['entity_view_display']->setClass(FieldLayoutEntityViewDisplay::class); + $entity_types['entity_form_display']->setClass(FieldLayoutEntityFormDisplay::class); + + // The form classes are only needed when Field UI is installed. + if (\Drupal::moduleHandler()->moduleExists('field_ui')) { + $entity_types['entity_view_display']->setFormClass('edit', FieldLayoutEntityViewDisplayEditForm::class); + $entity_types['entity_form_display']->setFormClass('edit', FieldLayoutEntityFormDisplayEditForm::class); + } +} + +/** + * Implements hook_ENTITY_TYPE_presave() for entity_form_display entities. + */ +function field_layout_entity_form_display_presave(EntityInterface $entity) { + _field_layout_entity_display_presave($entity); +} + +/** + * Implements hook_ENTITY_TYPE_presave() for entity_view_display entities. + */ +function field_layout_entity_view_display_presave(EntityInterface $entity) { + _field_layout_entity_display_presave($entity); +} + +/** + * Ensures there is a layout set on a display entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity object. + */ +function _field_layout_entity_display_presave(EntityInterface $entity) { + if ($entity instanceof EntityDisplayWithLayoutInterface && !$entity->getLayoutId()) { + $entity->setLayoutId('onecol'); + } +} + +/** + * Implements hook_entity_view_alter(). + */ +function field_layout_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) { + if ($display instanceof EntityDisplayWithLayoutInterface) { + \Drupal::classResolver()->getInstanceFromDefinition(FieldLayoutBuilder::class) + ->build($build, $display, 'view'); + } +} + +/** + * Implements hook_form_alter(). + */ +function field_layout_form_alter(&$form, FormStateInterface $form_state, $form_id) { + $form_object = $form_state->getFormObject(); + if ($form_object instanceof ContentEntityFormInterface && $display = $form_object->getFormDisplay($form_state)) { + if ($display instanceof EntityDisplayWithLayoutInterface) { + \Drupal::classResolver()->getInstanceFromDefinition(FieldLayoutBuilder::class) + ->build($form, $display, 'form'); + } + } +} diff --git a/core/modules/field_layout/layouts/onecol/field-layout--onecol.html.twig b/core/modules/field_layout/layouts/onecol/field-layout--onecol.html.twig new file mode 100644 index 0000000..cce4893 --- /dev/null +++ b/core/modules/field_layout/layouts/onecol/field-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
. + * + * @ingroup themeable + */ +#} +{% +set classes = [ +'field-layout--onecol', +] +%} +{% if content %} + +
+ {{ 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..f98087f --- /dev/null +++ b/core/modules/field_layout/src/Display/EntityDisplayWithLayoutInterface.php @@ -0,0 +1,38 @@ +getThirdPartySetting('field_layout', 'layout'); + } + + /** + * Implements \Drupal\field_layout\Display\EntityDisplayWithLayoutInterface::setLayoutId(). + */ + public function setLayoutId($layout_id) { + $this->setThirdPartySetting('field_layout', 'layout', $layout_id); + return $this; + } + +} 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..a14ef05 --- /dev/null +++ b/core/modules/field_layout/src/Entity/FieldLayoutEntityFormDisplay.php @@ -0,0 +1,26 @@ +getDefinition($this->getLayoutId() ?: 'onecol'); + return isset($layout_definition['default_region']) ? $layout_definition['default_region'] : key($layout_definition['regions']); + } + +} 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..94d0913 --- /dev/null +++ b/core/modules/field_layout/src/Entity/FieldLayoutEntityViewDisplay.php @@ -0,0 +1,26 @@ +getDefinition($this->getLayoutId() ?: 'onecol'); + return isset($layout_definition['default_region']) ? $layout_definition['default_region'] : key($layout_definition['regions']); + } + +} diff --git a/core/modules/field_layout/src/FieldLayoutBuilder.php b/core/modules/field_layout/src/FieldLayoutBuilder.php new file mode 100644 index 0000000..d725374 --- /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(array_keys($layout_definition['regions']), $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'] = $this->layoutPluginManager->createInstance($display->getLayoutId())->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..1fb8e3b --- /dev/null +++ b/core/modules/field_layout/src/Form/FieldLayoutEntityDisplayFormTrait.php @@ -0,0 +1,115 @@ +layoutPluginManager->getDefinition($this->getEntity()->getLayoutId() ?: 'onecol'); + foreach ($layout['regions'] 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; + } + + /** + * Adds the field layout section to the form. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array + */ + protected function addFieldLayout(array $form, FormStateInterface $form_state) { + $form['#entity_builders'][] = [static::class, 'updateFieldLayout']; + + $form['field_layouts'] = [ + '#type' => 'details', + '#title' => $this->t('Layout settings'), + ]; + $layout_options = []; + foreach ($this->layoutPluginManager->getGroupedDefinitions() as $category => $layouts) { + foreach ($layouts as $name => $layout) { + $layout_options[$category][$name] = $layout['label']; + } + } + $form['field_layouts']['field_layout'] = [ + '#type' => 'select', + '#title' => $this->t('Select a layout'), + '#options' => $layout_options, + '#default_value' => $this->getEntity()->getLayoutId() ?: 'onecol', + ]; + + return $form; + } + + /** + * An #entity_builder callback to update the Field Layout settings. + * + * @param string $entity_type_id + * @param \Drupal\Core\Entity\EntityInterface $entity + * @param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + */ + public static function updateFieldLayout($entity_type_id, EntityInterface $entity, &$form, FormStateInterface $form_state) { + if ($form_state->isSubmitted() && $entity instanceof EntityDisplayWithLayoutInterface) { + $old_layout = $entity->getLayoutId(); + $new_layout = $form_state->getValue('field_layout'); + $entity->setLayoutId($new_layout); + // If the layout is changing, reset all fields. + if ($new_layout !== $old_layout) { + // @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); + } + } + } + } + } + + /** + * 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..05c85b5 --- /dev/null +++ b/core/modules/field_layout/src/Form/FieldLayoutEntityFormDisplayEditForm.php @@ -0,0 +1,54 @@ +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') + ); + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + return $this->addFieldLayout($form, $form_state); + } + +} 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..d12da7d --- /dev/null +++ b/core/modules/field_layout/src/Form/FieldLayoutEntityViewDisplayEditForm.php @@ -0,0 +1,54 @@ +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') + ); + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + return $this->addFieldLayout($form, $form_state); + } + +} 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..c3517ad --- /dev/null +++ b/core/modules/field_layout/tests/src/FunctionalJavascript/FieldLayoutTest.php @@ -0,0 +1,224 @@ + '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->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'); + $left_region_row = $this->getSession()->getPage()->find('css', '.region-right-message'); + $field_test_text_row->find('css', '.handle')->dragTo($left_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->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][type]', '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][type]', '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'); + } + + /** + * 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..c262d51 --- /dev/null +++ b/core/modules/field_layout/tests/src/Unit/FieldLayoutBuilderTest.php @@ -0,0 +1,254 @@ +layoutPluginManager = $this->prophesize(LayoutPluginManagerInterface::class); + $this->layoutPluginManager->getDefinition('unknown', FALSE)->willReturn([]); + + $twocol_definition = [ + 'library' => 'field_layout/drupal.field_layout.twocol', + 'theme' => 'field_layout__twocol', + 'regions' => [ + 'left' => [ + 'label' => 'Left', + ], + 'right' => [ + 'label' => 'Right', + ], + ], + ]; + $layout_plugin = new LayoutDefault([], 'twocol', $twocol_definition); + $this->layoutPluginManager->createInstance('twocol')->willReturn($layout_plugin); + $this->layoutPluginManager->getDefinition('twocol', FALSE)->willReturn($twocol_definition); + + $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->getLayoutId()->willReturn('twocol'); + $display->getComponents()->willReturn([ + 'test1' => [ + 'region' => 'right', + ], + 'non_configurable_field' => [ + 'region' => 'left', + ], + ]); + + $display_context = 'view'; + + $expected = [ + 'non_configurable_field' => [ + '#markup' => 'Non-configurable', + ], + 'field_layout' => [ + 'left' => [], + 'right' => [ + 'test1' => [ + '#markup' => 'Test1', + ], + ], + '#theme' => 'field_layout__twocol', + '#attached' => [ + 'library' => [ + 'field_layout/drupal.field_layout.twocol', + ], + ], + ], + ]; + $this->fieldLayoutBuilder->build($build, $display->reveal(), $display_context); + $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->getLayoutId()->willReturn('twocol'); + $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' => [ + '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'], + ], + '#theme' => 'field_layout__twocol', + '#attached' => [ + 'library' => [ + 'field_layout/drupal.field_layout.twocol', + ], + ], + ], + ]; + $this->fieldLayoutBuilder->build($build, $display->reveal(), $display_context); + $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->getLayoutId()->willReturn('twocol'); + $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->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/layout_plugin.info.yml b/core/modules/layout_plugin/layout_plugin.info.yml new file mode 100644 index 0000000..7058dca --- /dev/null +++ b/core/modules/layout_plugin/layout_plugin.info.yml @@ -0,0 +1,6 @@ +name: 'Layout Plugin' +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_plugin/layout_plugin.module b/core/modules/layout_plugin/layout_plugin.module new file mode 100644 index 0000000..661b332 --- /dev/null +++ b/core/modules/layout_plugin/layout_plugin.module @@ -0,0 +1,28 @@ +' . 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..30e3b96 --- /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', '@string_translation'] 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..4ad0405 --- /dev/null +++ b/core/modules/layout_plugin/src/Annotation/Layout.php @@ -0,0 +1,90 @@ + LayoutDefault::class, + ]; + + /** + * LayoutPluginManager constructor. + * + * @param \Traversable $namespaces + * An object that implements \Traversable which contains the root paths + * keyed by the corresponding namespace to look for plugin implementations. + * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend + * Cache backend instance to use. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + * @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler + * The theme handler. + * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation + * The string translation service. + */ + public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler, ThemeHandlerInterface $theme_handler, TranslationInterface $string_translation) { + parent::__construct('Plugin/Layout', $namespaces, $module_handler, LayoutInterface::class, Layout::class); + $this->moduleHandler = $module_handler; + $this->themeHandler = $theme_handler; + $this->stringTranslation = $string_translation; + } + + /** + * {@inheritdoc} + */ + protected function providerExists($provider) { + return $this->moduleHandler->moduleExists($provider) || $this->themeHandler->themeExists($provider); + } + + /** + * {@inheritdoc} + */ + protected function getDiscovery() { + if (!$this->discovery) { + $discovery = parent::getDiscovery(); + $this->discovery = new YamlDiscoveryDecorator($discovery, 'layouts', $this->moduleHandler->getModuleDirectories() + $this->themeHandler->getThemeDirectories()); + } + return $this->discovery; + } + + /** + * {@inheritdoc} + */ + public function processDefinition(&$definition, $plugin_id) { + parent::processDefinition($definition, $plugin_id); + + // Add the module or theme path to the 'path'. + if ($this->moduleHandler->moduleExists($definition['provider'])) { + $definition['provider_type'] = 'module'; + $base_path = $this->moduleHandler->getModule($definition['provider'])->getPath(); + } + elseif ($this->themeHandler->themeExists($definition['provider'])) { + $definition['provider_type'] = 'theme'; + $base_path = $this->themeHandler->getTheme($definition['provider'])->getPath(); + } + else { + $base_path = ''; + } + $definition['path'] = !empty($definition['path']) ? $base_path . '/' . $definition['path'] : NULL; + + // Either a theme or a template must be defined. + if (empty($definition['template']) && empty($definition['theme'])) { + throw new InvalidPluginDefinitionException($plugin_id); + } + + if (empty($definition['template'])) { + $definition['template'] = strtr($definition['theme'], '_', '-'); + } + elseif (empty($definition['theme'])) { + $definition['theme'] = strtr($definition['template'], '-', '_'); + } + } + + /** + * {@inheritdoc} + */ + public function getThemeImplementations() { + $hooks = []; + foreach ($this->getDefinitions() as $layout_definition) { + $hooks[$layout_definition['theme']] = [ + 'render element' => 'content', + 'template' => $layout_definition['template'], + 'path' => $layout_definition['path'], + ]; + } + return $hooks; + } + +} diff --git a/core/modules/layout_plugin/src/LayoutPluginManagerInterface.php b/core/modules/layout_plugin/src/LayoutPluginManagerInterface.php new file mode 100644 index 0000000..11cb9bf --- /dev/null +++ b/core/modules/layout_plugin/src/LayoutPluginManagerInterface.php @@ -0,0 +1,22 @@ +setConfiguration($configuration); + } + + /** + * {@inheritdoc} + */ + public function build(array $regions) { + $build = array_intersect_key($regions, $this->pluginDefinition['regions']); + $build['#theme'] = $this->pluginDefinition['theme']; + if (isset($this->pluginDefinition['library'])) { + $build['#attached']['library'][] = $this->pluginDefinition['library']; + } + return $build; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $this->configuration = $form_state->getValues(); + } + + /** + * {@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 []; + } + +} diff --git a/core/modules/layout_plugin/src/Plugin/LayoutDefault.php b/core/modules/layout_plugin/src/Plugin/LayoutDefault.php new file mode 100644 index 0000000..ef99ef6 --- /dev/null +++ b/core/modules/layout_plugin/src/Plugin/LayoutDefault.php @@ -0,0 +1,10 @@ +moduleHandler = $this->prophesize(ModuleHandlerInterface::class); + $this->moduleHandler->moduleExists('field_layout')->willReturn(TRUE); + $extension = new Extension('/', 'module', 'core/modules/field_layout/field_layout.info.yml'); + $this->moduleHandler->getModule('field_layout')->willReturn($extension); + $this->moduleHandler->getModuleDirectories()->willReturn(['field_layout' => $this->root . '/core/modules/field_layout']); + + $this->themeHandler = $this->prophesize(ThemeHandlerInterface::class); + $this->themeHandler->getThemeDirectories()->willReturn([]); + + $this->cacheBackend = $this->prophesize(CacheBackendInterface::class); + + $this->layoutPluginManager = new LayoutPluginManager(new \ArrayObject(), $this->cacheBackend->reveal(), $this->moduleHandler->reveal(), $this->themeHandler->reveal(), $this->getStringTranslationStub()); + } + + /** + * @covers ::getDefinitions + */ + public function testGetDefinitions() { + $expected = ['onecol', 'twocol']; + + $layout_definitions = $this->layoutPluginManager->getDefinitions(); + $this->assertEquals($expected, array_keys($layout_definitions)); + } + + /** + * @covers ::getDefinition + */ + public function testGetDefinition() { + $expected = [ + 'label' => 'Two column', + 'theme' => 'field_layout__twocol', + 'provider' => 'field_layout', + 'category' => 'Columns: 2', + 'default_region' => 'left', + 'regions' => [ + 'left' => [ + 'label' => 'Left', + ], + 'right' => [ + 'label' => 'Right', + ], + ], + 'path' => 'core/modules/field_layout/layouts/twocol', + 'template' => 'field-layout--twocol', + 'library' => 'field_layout/drupal.field_layout.twocol', + 'provider_type' => 'module', + 'id' => 'twocol', + 'class' => LayoutDefault::class, + ]; + $layout_definition = $this->layoutPluginManager->getDefinition('twocol'); + $this->assertEquals($expected, $layout_definition); + } + + /** + * @covers ::getThemeImplementations + */ + public function testGetThemeImplementations() { + $expected = [ + 'field_layout__onecol' => [ + 'render element' => 'content', + 'template' => 'field-layout--onecol', + 'path' => 'core/modules/field_layout/layouts/onecol', + ], + 'field_layout__twocol' => [ + 'render element' => 'content', + 'template' => 'field-layout--twocol', + 'path' => 'core/modules/field_layout/layouts/twocol', + ], + ]; + $theme_implementations = $this->layoutPluginManager->getThemeImplementations(); + $this->assertSame($expected, $theme_implementations); + } + +}