diff --git a/core/modules/layout_builder/css/layout-builder.css b/core/modules/layout_builder/css/layout-builder.css index 29f9a2b2055e8d4027c364ed0d0b3f1edbde0017..9d3a3e62b165dbd2ba6c9f00f8a62267f50f520b 100644 --- a/core/modules/layout_builder/css/layout-builder.css +++ b/core/modules/layout_builder/css/layout-builder.css @@ -141,3 +141,47 @@ .layout-builder-block.is-layout-builder-highlighted { border: 4px solid #000; } + +/* Highlight the active block in the Move Block dialog. */ +#drupal-off-canvas .layout-builder-components-table__block-label--current { + border-left: solid 5px; + padding-left: 17px; +} + +/** + * @todo remove in https://www.drupal.org/project/drupal/issues/3042127 + * This rule ensures the row weight dropdowns in the Move Block dialog + * maintain the background color of their container when they are hovered + * over or are inside the active row. + */ +#drupal-off-canvas .layout-builder-components-table__row .form-item { + background-color: transparent; +} + +.layout-builder__region-label, +.layout-builder__section-label { + display: none; +} + +.layout-builder--move-blocks-active .layout-builder__region-label { + display: block; +} + +.layout-builder--move-blocks-active .layout-builder__section-label { + display: inline; +} + +.layout__region-info { + padding: 0.5em; + text-align: center; + border-bottom: 2px dashed #979797; +} + +/** + * Remove "You have unsaved changes" warning because Layout Builder always has + * unsaved changes until "Save layout" is submitted. + * @todo create issue for todo. + */ +.layout-builder-components-table .tabledrag-changed-warning { + display: none !important; +} diff --git a/core/modules/layout_builder/js/layout-builder.es6.js b/core/modules/layout_builder/js/layout-builder.es6.js index 6b25f4e4e876dd21678cbaf4e1070d5c3dd8efd4..b95e12028e8d5319538771eb63235e091c4d3101 100644 --- a/core/modules/layout_builder/js/layout-builder.es6.js +++ b/core/modules/layout_builder/js/layout-builder.es6.js @@ -227,6 +227,24 @@ 'is-layout-builder-highlighted', ); } + + // Remove wrapper class added by move block form. + $('#layout-builder').removeClass('layout-builder--move-blocks-active'); + + /** + * If dialog has a data-add-layout-builder-wrapper attribute, get the + * value and add it as a class to the Layout Builder UI wrapper. + * + * Currently, only the move block form uses + * data-add-layout-builder-wrapper, but any dialog can use this attribute + * to add a class to the Layout Builder UI while opened. + */ + const layoutBuilderWrapperValue = $element + .find('[data-add-layout-builder-wrapper]') + .attr('data-add-layout-builder-wrapper'); + if (layoutBuilderWrapperValue) { + $('#layout-builder').addClass(layoutBuilderWrapperValue); + } } }); @@ -275,12 +293,15 @@ }); } - // When a dialog closes, remove the highlight from all elements. $(window).on('dialog:afterclose', (event, dialog, $element) => { if (Drupal.offCanvas.isOffCanvas($element)) { + // Remove the highlight from all elements. $('.is-layout-builder-highlighted').removeClass( 'is-layout-builder-highlighted', ); + + // Remove wrapper class added by move block form. + $('#layout-builder').removeClass('layout-builder--move-blocks-active'); } }); })(jQuery, Drupal); diff --git a/core/modules/layout_builder/js/layout-builder.js b/core/modules/layout_builder/js/layout-builder.js index 13b6b09aa627899f4efcbf547a9f71078ba99401..590177b2ce54cadb98400a90a184978a9e336dc3 100644 --- a/core/modules/layout_builder/js/layout-builder.js +++ b/core/modules/layout_builder/js/layout-builder.js @@ -102,6 +102,13 @@ if (id) { $('[data-layout-builder-highlight-id="' + id + '"]').addClass('is-layout-builder-highlighted'); } + + $('#layout-builder').removeClass('layout-builder--move-blocks-active'); + + var layoutBuilderWrapperValue = $element.find('[data-add-layout-builder-wrapper]').attr('data-add-layout-builder-wrapper'); + if (layoutBuilderWrapperValue) { + $('#layout-builder').addClass(layoutBuilderWrapperValue); + } } }); @@ -138,6 +145,8 @@ $(window).on('dialog:afterclose', function (event, dialog, $element) { if (Drupal.offCanvas.isOffCanvas($element)) { $('.is-layout-builder-highlighted').removeClass('is-layout-builder-highlighted'); + + $('#layout-builder').removeClass('layout-builder--move-blocks-active'); } }); })(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/layout_builder/layout_builder.links.contextual.yml b/core/modules/layout_builder/layout_builder.links.contextual.yml index bcf2a9cf06938805d5f60574af1376cda30698a3..4bbfcc9e64deeff5814dd42c68b5c4cc316eabc1 100644 --- a/core/modules/layout_builder/layout_builder.links.contextual.yml +++ b/core/modules/layout_builder/layout_builder.links.contextual.yml @@ -8,6 +8,16 @@ layout_builder_block_update: data-dialog-type: dialog data-dialog-renderer: off_canvas +layout_builder_block_move: + title: 'Move' + route_name: 'layout_builder.move_block_form' + group: 'layout_builder_block' + options: + attributes: + class: ['use-ajax'] + data-dialog-type: dialog + data-dialog-renderer: off_canvas + layout_builder_block_remove: title: 'Remove block' route_name: 'layout_builder.remove_block' diff --git a/core/modules/layout_builder/layout_builder.post_update.php b/core/modules/layout_builder/layout_builder.post_update.php index 68b3bc846b798b1dc4313a0f0db698e9d269fb7c..e439587e4a1d4baef25276ec8476383ca7582d42 100644 --- a/core/modules/layout_builder/layout_builder.post_update.php +++ b/core/modules/layout_builder/layout_builder.post_update.php @@ -109,6 +109,13 @@ function layout_builder_post_update_routing_defaults() { // Empty post-update hook. } +/** + * Clear caches due to new link added to Layout Builder's contextual links. + */ +function layout_builder_post_update_discover_new_contextual_links() { + // Empty post-update hook. +} + /** * Fix Layout Builder tempstore keys of existing entries. */ diff --git a/core/modules/layout_builder/layout_builder.routing.yml b/core/modules/layout_builder/layout_builder.routing.yml index b4360553786abaae44b9b34d9fab8bbbe5a09e8e..a45d923d3c803cc69a365a8b3b359d7cfa1b004c 100644 --- a/core/modules/layout_builder/layout_builder.routing.yml +++ b/core/modules/layout_builder/layout_builder.routing.yml @@ -110,6 +110,21 @@ layout_builder.update_block: section_storage: layout_builder_tempstore: TRUE +layout_builder.move_block_form: + path: '/layout_builder/move/block/{section_storage_type}/{section_storage}/{delta}/{region}/{uuid}' + defaults: + _title_callback: '\Drupal\layout_builder\Form\MoveBlockForm::title' + _form: '\Drupal\layout_builder\Form\MoveBlockForm' + requirements: + # @todo revisit in https://www.drupal.org/node/2914486 + _permission: 'configure any layout' + _layout_builder_access: 'view' + options: + _admin_route: TRUE + parameters: + section_storage: + layout_builder_tempstore: TRUE + layout_builder.remove_block: path: '/layout_builder/remove/block/{section_storage_type}/{section_storage}/{delta}/{region}/{uuid}' defaults: diff --git a/core/modules/layout_builder/src/Element/LayoutBuilder.php b/core/modules/layout_builder/src/Element/LayoutBuilder.php index 9c98847e8907662e3d5b274c3ec5ad2c386493a4..d767899de9b5b9fbc48e1277f9967162e51b1359 100644 --- a/core/modules/layout_builder/src/Element/LayoutBuilder.php +++ b/core/modules/layout_builder/src/Element/LayoutBuilder.php @@ -255,6 +255,13 @@ protected function buildAdministrativeSection(SectionStorageInterface $section_s 'region' => $region, 'uuid' => $uuid, ], + // Add metadata about the current operations available in + // contextual links. This will invalidate the client-side cache of + // links that were cached before the 'move' link was added. + // @see layout_builder.links.contextual.yml + 'metadata' => [ + 'operations' => 'move:update:remove', + ], ], ]; } @@ -297,6 +304,25 @@ protected function buildAdministrativeSection(SectionStorageInterface $section_s '@region' => $info['label'], '@section' => $delta + 1, ]); + + // Get weights of all children for use by the region label. + $weights = array_map(function ($a) { + return isset($a['#weight']) ? $a['#weight'] : 0; + }, $build[$region]); + + // The region label is made visible when the move block dialog is open. + $build[$region]['region_label'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['layout__region-info', 'layout-builder__region-label'], + // A more detailed version of this information is already read by + // screen readers, so this label can be hidden from them. + 'aria-hidden' => TRUE, + ], + '#markup' => $this->t('Region: @region', ['@region' => $info['label']]), + // Ensures the region label is displayed first. + '#weight' => min($weights) - 1, + ]; } $build['#attributes']['data-layout-update-url'] = Url::fromRoute('layout_builder.move_block', [ @@ -333,9 +359,19 @@ protected function buildAdministrativeSection(SectionStorageInterface $section_s 'data-dialog-renderer' => 'off_canvas', ], ], + // The section label is added to sections without a "Configure Section" + // link, and is only visible when the move block dialog is open. + 'section_label' => [ + '#markup' => $this->t('<span class="layout-builder__section-label" aria-hidden="true">Section @section</span>', ['@section' => $delta + 1]), + '#access' => !$layout instanceof PluginFormInterface, + ], 'configure' => [ '#type' => 'link', - '#title' => $this->t('Configure section <span class="visually-hidden">@section</span>', ['@section' => $delta + 1]), + // There are two instances of @section, the one wrapped in + // .visually-hidden is for screen readers. The one wrapped in + // .layout-builder__section-label is only visible when the + // move block dialog is open and it is not seen by screen readers. + '#title' => $this->t('Configure section <span class="visually-hidden">@section</span><span aria-hidden="true" class="layout-builder__section-label">@section</span>', ['@section' => $delta + 1]), '#access' => $layout instanceof PluginFormInterface, '#url' => Url::fromRoute('layout_builder.configure_section', [ 'section_storage_type' => $storage_type, diff --git a/core/modules/layout_builder/src/Form/MoveBlockForm.php b/core/modules/layout_builder/src/Form/MoveBlockForm.php new file mode 100644 index 0000000000000000000000000000000000000000..d8205f95255c7c1e92532fcdca1e9dcebbcb9cc9 --- /dev/null +++ b/core/modules/layout_builder/src/Form/MoveBlockForm.php @@ -0,0 +1,333 @@ +<?php + +namespace Drupal\layout_builder\Form; + +use Drupal\Core\Ajax\AjaxFormHelperTrait; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\layout_builder\Controller\LayoutRebuildTrait; +use Drupal\layout_builder\LayoutBuilderHighlightTrait; +use Drupal\layout_builder\LayoutTempstoreRepositoryInterface; +use Drupal\layout_builder\SectionStorageInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a form for moving a block. + */ +class MoveBlockForm extends FormBase { + + use AjaxFormHelperTrait; + use LayoutBuilderHighlightTrait; + use LayoutRebuildTrait; + + /** + * The section storage. + * + * @var \Drupal\layout_builder\SectionStorageInterface + */ + protected $sectionStorage; + + /** + * The section delta. + * + * @var int + */ + protected $delta; + + /** + * The region name. + * + * @var string + */ + protected $region; + + /** + * The component uuid. + * + * @var string + */ + protected $uuid; + + /** + * The Layout Tempstore. + * + * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface + */ + protected $layoutTempstore; + + /** + * Constructs a new MoveBlockForm. + * + * @param \Drupal\layout_builder\LayoutTempstoreRepositoryInterface $layout_tempstore_repository + * The layout tempstore. + */ + public function __construct(LayoutTempstoreRepositoryInterface $layout_tempstore_repository) { + $this->layoutTempstore = $layout_tempstore_repository; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('layout_builder.tempstore_repository') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'layout_builder_block_move'; + } + + /** + * Builds the move block form. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage being configured. + * @param int $delta + * The original delta of the section. + * @param string $region + * The original region of the block. + * @param string $uuid + * The UUID of the block being updated. + * + * @return array + * The form array. + */ + public function buildForm(array $form, FormStateInterface $form_state, SectionStorageInterface $section_storage = NULL, $delta = NULL, $region = NULL, $uuid = NULL) { + $parameters = array_slice(func_get_args(), 2); + foreach ($parameters as $parameter) { + if (is_null($parameter)) { + throw new \InvalidArgumentException('MoveBlockForm requires all parameters.'); + } + } + + $this->sectionStorage = $section_storage; + $this->delta = $delta; + $this->uuid = $uuid; + $this->region = $region; + + $form['#attributes']['data-layout-builder-target-highlight-id'] = $this->blockUpdateHighlightId($uuid); + + $sections = $section_storage->getSections(); + $region_options = []; + foreach ($sections as $section_delta => $section) { + $layout = $section->getLayout(); + $layout_definition = $layout->getPluginDefinition(); + $section_label = $this->t('Section: @delta', ['@delta' => $section_delta + 1])->render(); + foreach ($layout_definition->getRegions() as $region_name => $region_info) { + // Group regions by section. + $region_options[$section_label]["$section_delta:$region_name"] = $this->t( + 'Section: @delta, Region: @region', + ['@delta' => $section_delta + 1, '@region' => $region_info['label']] + ); + } + } + + // $this->region and $this->delta are where the block is currently placed. + // $selected_region and $selected_delta are the values from this form + // specifying where the block should be moved to. + $selected_region = $this->getSelectedRegion($form_state); + $selected_delta = $this->getSelectedDelta($form_state); + $form['region'] = [ + '#type' => 'select', + '#options' => $region_options, + '#title' => $this->t('Region'), + '#default_value' => "$selected_delta:$selected_region", + '#ajax' => [ + 'wrapper' => 'layout-builder-components-table', + 'callback' => '::getComponentsWrapper', + ], + ]; + $current_section = $sections[$selected_delta]; + + $aria_label = $this->t('Blocks in Section: @section, Region: @region', ['@section' => $selected_delta + 1, '@region' => $selected_region]); + + $form['components_wrapper']['components'] = [ + '#type' => 'table', + '#header' => [ + $this->t('Block Label'), + $this->t('Weight'), + ], + '#tabledrag' => [ + [ + 'action' => 'order', + 'relationship' => 'sibling', + 'group' => 'table-sort-weight', + ], + ], + // Create a wrapping element so that the Ajax update also replaces the + // 'Show block weights' link. + '#theme_wrappers' => [ + 'container' => [ + '#attributes' => [ + 'id' => 'layout-builder-components-table', + 'class' => ['layout-builder-components-table'], + 'aria-label' => $aria_label, + ], + ], + ], + ]; + + /** @var \Drupal\layout_builder\SectionComponent[] $components */ + $components = $current_section->getComponentsByRegion($selected_region); + + // If the component is not in this region, add it to the listed components. + if (!isset($components[$uuid])) { + $components[$uuid] = $sections[$delta]->getComponent($uuid); + } + foreach ($components as $component_uuid => $component) { + /** @var \Drupal\Core\Block\BlockPluginInterface $plugin */ + $plugin = $component->getPlugin(); + $is_current_block = $component_uuid === $uuid; + $row_classes = [ + 'draggable', + 'layout-builder-components-table__row', + ]; + + $label['#wrapper_attributes']['class'] = ['layout-builder-components-table__block-label']; + + if ($is_current_block) { + // Highlight the current block. + $label['#markup'] = $this->t('@label (current)', ['@label' => $plugin->label()]); + $label['#wrapper_attributes']['class'][] = 'layout-builder-components-table__block-label--current'; + $row_classes[] = 'layout-builder-components-table__row--current'; + } + else { + $label['#markup'] = $plugin->label(); + } + + $form['components_wrapper']['components'][$component_uuid] = [ + '#attributes' => ['class' => $row_classes], + 'label' => $label, + 'weight' => [ + '#type' => 'weight', + '#default_value' => $component->getWeight(), + '#title' => $this->t('Weight for @block block', ['@block' => $plugin->label()]), + '#title_display' => 'invisible', + '#attributes' => [ + 'class' => ['table-sort-weight'], + ], + ], + ]; + } + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Move'), + '#button_type' => 'primary', + ]; + + $form['#attributes']['data-add-layout-builder-wrapper'] = 'layout-builder--move-blocks-active'; + + if ($this->isAjax()) { + $form['actions']['submit']['#ajax']['callback'] = '::ajaxSubmit'; + } + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $region = $this->getSelectedRegion($form_state); + $delta = $this->getSelectedDelta($form_state); + $original_section = $this->sectionStorage->getSection($this->delta); + $component = $original_section->getComponent($this->uuid); + $section = $this->sectionStorage->getSection($delta); + if ($delta !== $this->delta) { + // Remove component from old section and add it to the new section. + $original_section->removeComponent($this->uuid); + $section->insertComponent(0, $component); + } + $component->setRegion($region); + foreach ($form_state->getValue('components') as $uuid => $component_info) { + $section->getComponent($uuid)->setWeight($component_info['weight']); + } + $this->layoutTempstore->set($this->sectionStorage); + } + + /** + * Ajax callback for the region select element. + * + * @param array $form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The components wrapper render array. + */ + public function getComponentsWrapper(array $form, FormStateInterface $form_state) { + return $form['components_wrapper']; + } + + /** + * {@inheritdoc} + */ + protected function successfulAjaxSubmit(array $form, FormStateInterface $form_state) { + return $this->rebuildAndClose($this->sectionStorage); + } + + /** + * Gets the selected region. + * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return string + * The current region name. + */ + protected function getSelectedRegion(FormStateInterface $form_state) { + if ($form_state->hasValue('region')) { + return explode(':', $form_state->getValue('region'), 2)[1]; + } + return $this->region; + } + + /** + * Gets the selected delta. + * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return int + * The section delta. + */ + protected function getSelectedDelta(FormStateInterface $form_state) { + if ($form_state->hasValue('region')) { + return (int) explode(':', $form_state->getValue('region'))[0]; + } + return (int) $this->delta; + } + + /** + * Provides a title callback. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage. + * @param int $delta + * The original delta of the section. + * @param string $uuid + * The UUID of the block being updated. + * + * @return string + * The title for the move block form. + */ + public function title(SectionStorageInterface $section_storage, $delta, $uuid) { + $block_label = $section_storage + ->getSection($delta) + ->getComponent($uuid) + ->getPlugin() + ->label(); + + return $this->t('Move the @block_label block', ['@block_label' => $block_label]); + } + +} diff --git a/core/modules/layout_builder/src/Section.php b/core/modules/layout_builder/src/Section.php index 26f597b3fd5c0c917ad0ea4f2b7579db18e4cc7f..c78692f42c8f30e5e64a2317dfe69b7cb6fb56ea 100644 --- a/core/modules/layout_builder/src/Section.php +++ b/core/modules/layout_builder/src/Section.php @@ -254,7 +254,7 @@ protected function getNextHighestWeight($region) { * @return \Drupal\layout_builder\SectionComponent[] * An array of components in the specified region, sorted by weight. */ - protected function getComponentsByRegion($region) { + public function getComponentsByRegion($region) { $components = array_filter($this->getComponents(), function (SectionComponent $component) use ($region) { return $component->getRegion() === $region; }); diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/MoveBlockFormTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/MoveBlockFormTest.php new file mode 100644 index 0000000000000000000000000000000000000000..24a097d24ab84ab00101011e20a8b553bf4d3b12 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/MoveBlockFormTest.php @@ -0,0 +1,266 @@ +<?php + +namespace Drupal\Tests\layout_builder\FunctionalJavascript; + +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait; + +/** + * Tests moving blocks via the form. + * + * @group layout_builder + */ +class MoveBlockFormTest extends WebDriverTestBase { + + use ContextualLinkClickTrait; + + /** + * Path prefix for the field UI for the test bundle. + * + * @var string + */ + const FIELD_UI_PREFIX = 'admin/structure/types/manage/bundle_with_section_field'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'layout_builder', + 'block', + 'node', + 'contextual', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + // @todo The Layout Builder UI relies on local tasks; fix in + // https://www.drupal.org/project/drupal/issues/2917777. + $this->drupalPlaceBlock('local_tasks_block'); + + $this->createContentType(['type' => 'bundle_with_section_field']); + + $this->drupalLogin($this->drupalCreateUser([ + 'configure any layout', + 'administer node display', + 'administer node fields', + 'access contextual links', + ])); + + // Enable layout builder. + $this->drupalPostForm( + static::FIELD_UI_PREFIX . '/display/default', + ['layout[enabled]' => TRUE], + 'Save' + ); + $page->clickLink('Manage layout'); + $assert_session->addressEquals(static::FIELD_UI_PREFIX . '/display/default/layout'); + + $expected_block_order = [ + '.block-extra-field-blocknodebundle-with-section-fieldlinks', + '.block-field-blocknodebundle-with-section-fieldbody', + ]; + $this->assertRegionBlocksOrder(0, 'content', $expected_block_order); + + // Add a top section using the Two column layout. + $page->clickLink('Add Section'); + $assert_session->waitForElementVisible('css', '#drupal-off-canvas'); + $assert_session->assertWaitOnAjaxRequest(); + $page->clickLink('Two column'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'input[value="Add section"]')); + $page->pressButton('Add section'); + $this->assertRegionBlocksOrder(1, 'content', $expected_block_order); + + // Add a 'Powered by Drupal' block in the 'first' region of the new section. + $first_region_block_locator = '[data-layout-delta="0"].layout--twocol-section [data-region="first"] [data-layout-block-uuid]'; + $assert_session->elementNotExists('css', $first_region_block_locator); + $assert_session->elementExists('css', '[data-layout-delta="0"].layout--twocol-section [data-region="first"] .layout-builder__add-block')->click(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas a:contains("Powered by Drupal")')); + $assert_session->assertWaitOnAjaxRequest(); + $page->clickLink('Powered by Drupal'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', 'input[value="Add Block"]')); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Add Block'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', $first_region_block_locator)); + + // Ensure the request has completed before the test starts. + $this->waitForNoElement('#drupal-off-canvas'); + $assert_session->assertWaitOnAjaxRequest(); + } + + /** + * Tests moving a block. + */ + public function testMoveBlock() { + $page = $this->getSession()->getPage(); + + // Reorder body field in current region. + $this->openBodyMoveForm(1, 'content', ['Links', 'Body (current)']); + $this->moveBlockWithKeyboard('up', 'Body (current)', ['Body (current)*', 'Links']); + $page->pressButton('Move'); + $expected_block_order = [ + '.block-field-blocknodebundle-with-section-fieldbody', + '.block-extra-field-blocknodebundle-with-section-fieldlinks', + ]; + $this->assertRegionBlocksOrder(1, 'content', $expected_block_order); + $page->pressButton('Save layout'); + $page->clickLink('Manage layout'); + $this->assertRegionBlocksOrder(1, 'content', $expected_block_order); + + // Move the body block into the first region above existing block. + $this->openBodyMoveForm(1, 'content', ['Body (current)', 'Links']); + $page->selectFieldOption('Region', '0:first'); + $this->assertBlockTable(['Powered by Drupal', 'Body (current)']); + $this->moveBlockWithKeyboard('up', 'Body', ['Body (current)*', 'Powered by Drupal']); + $page->pressButton('Move'); + $expected_block_order = [ + '.block-field-blocknodebundle-with-section-fieldbody', + '.block-system-powered-by-block', + ]; + $this->assertRegionBlocksOrder(0, 'first', $expected_block_order); + + // Ensure the body block is no longer in the content region. + $this->assertRegionBlocksOrder(1, 'content', ['.block-extra-field-blocknodebundle-with-section-fieldlinks']); + $page->pressButton('Save layout'); + $page->clickLink('Manage layout'); + $this->assertRegionBlocksOrder(0, 'first', $expected_block_order); + + // Move into the second region that has no existing blocks. + $this->openBodyMoveForm(0, 'first', ['Body (current)', 'Powered by Drupal']); + $page->selectFieldOption('Region', '0:second'); + $this->assertBlockTable(['Body (current)']); + $page->pressButton('Move'); + $this->assertRegionBlocksOrder(0, 'second', ['.block-field-blocknodebundle-with-section-fieldbody']); + } + + /** + * Asserts the correct block labels appear in the draggable tables. + * + * @param string[] $expected_block_labels + * The expected block labels. + */ + protected function assertBlockTable(array $expected_block_labels) { + $page = $this->getSession()->getPage(); + $this->assertSession()->assertWaitOnAjaxRequest(); + $block_tds = $page->findAll('css', '.layout-builder-components-table__block-label'); + $this->assertCount(count($block_tds), $expected_block_labels); + /** @var \Behat\Mink\Element\NodeElement $block_td */ + foreach ($block_tds as $block_td) { + $this->assertSame(array_shift($expected_block_labels), trim($block_td->getText())); + } + } + + /** + * Waits for an element to be removed from the page. + * + * @param string $selector + * CSS selector. + * @param int $timeout + * (optional) Timeout in milliseconds, defaults to 10000. + * + * @todo Remove in https://www.drupal.org/node/2892440. + */ + protected function waitForNoElement($selector, $timeout = 10000) { + $condition = "(typeof jQuery !== 'undefined' && jQuery('$selector').length === 0)"; + $this->assertJsCondition($condition, $timeout); + } + + /** + * Moves a block in the draggable table. + * + * @param string $direction + * The direction to move the block in the table. + * @param string $block_label + * The block label. + * @param array $updated_blocks + * The updated blocks order. + */ + protected function moveBlockWithKeyboard($direction, $block_label, array $updated_blocks) { + $keys = [ + 'up' => 38, + 'down' => 40, + ]; + $key = $keys[$direction]; + $handle = $this->findRowHandle($block_label); + + $handle->keyDown($key); + $handle->keyUp($key); + + $handle->blur(); + $this->assertBlockTable($updated_blocks); + } + + /** + * Finds the row handle for a block in the draggable table. + * + * @param string $block_label + * The block label. + * + * @return \Behat\Mink\Element\NodeElement + * The row handle element. + */ + protected function findRowHandle($block_label) { + $assert_session = $this->assertSession(); + return $assert_session->elementExists('css', "[data-drupal-selector=\"edit-components\"] td:contains(\"$block_label\") a.tabledrag-handle"); + } + + /** + * Asserts that blocks are in the correct order for a region. + * + * @param int $section_delta + * The section delta. + * @param string $region + * The region. + * @param array $expected_block_selectors + * The block selectors. + */ + protected function assertRegionBlocksOrder($section_delta, $region, array $expected_block_selectors) { + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + $assert_session->assertWaitOnAjaxRequest(); + $this->waitForNoElement('#drupal-off-canvas'); + + $region_selector = "[data-layout-delta=\"$section_delta\"] [data-region=\"$region\"]"; + + // Get all blocks currently in the region. + $blocks = $page->findAll('css', "$region_selector [data-layout-block-uuid]"); + $this->assertCount(count($expected_block_selectors), $blocks); + + /** @var \Behat\Mink\Element\NodeElement $block */ + foreach ($blocks as $block) { + $block_selector = array_shift($expected_block_selectors); + $assert_session->elementsCount('css', "$region_selector $block_selector", 1); + $expected_block = $page->find('css', "$region_selector $block_selector"); + $this->assertSame($expected_block->getAttribute('data-layout-block-uuid'), $block->getAttribute('data-layout-block-uuid')); + } + } + + /** + * Open block for the body field. + * + * @param int $delta + * The section delta where the field should be. + * @param string $region + * The region where the field should be. + * @param array $initial_blocks + * The initial blocks that should be shown in the draggable table. + */ + protected function openBodyMoveForm($delta, $region, array $initial_blocks) { + $assert_session = $this->assertSession(); + + $body_field_locator = "[data-layout-delta=\"$delta\"] [data-region=\"$region\"] .block-field-blocknodebundle-with-section-fieldbody"; + $this->clickContextualLink($body_field_locator, 'Move'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('named', ['select', 'Region'])); + $assert_session->fieldValueEquals('Region', "$delta:$region"); + $this->assertBlockTable($initial_blocks); + } + +}