From a73bf047ed1b1594e3fd865e9720e459ed956326 Mon Sep 17 00:00:00 2001
From: webchick <drupal@webchick.net>
Date: Fri, 22 Mar 2019 14:20:24 -0700
Subject: [PATCH] Issue #2995689 by tedbow, bnjmnm, lauriii, bendeguz.csirmaz,
 andrewmacpherson, xjm, tim.plunkett, KarenS, alwaysworking: Allow reordering
 blocks without a pointer device

---
 .../layout_builder/css/layout-builder.css     |  44 +++
 .../layout_builder/js/layout-builder.es6.js   |  23 +-
 .../layout_builder/js/layout-builder.js       |   9 +
 .../layout_builder.links.contextual.yml       |  10 +
 .../layout_builder.post_update.php            |   7 +
 .../layout_builder/layout_builder.routing.yml |  15 +
 .../src/Element/LayoutBuilder.php             |  38 +-
 .../layout_builder/src/Form/MoveBlockForm.php | 333 ++++++++++++++++++
 core/modules/layout_builder/src/Section.php   |   2 +-
 .../MoveBlockFormTest.php                     | 266 ++++++++++++++
 10 files changed, 744 insertions(+), 3 deletions(-)
 create mode 100644 core/modules/layout_builder/src/Form/MoveBlockForm.php
 create mode 100644 core/modules/layout_builder/tests/src/FunctionalJavascript/MoveBlockFormTest.php

diff --git a/core/modules/layout_builder/css/layout-builder.css b/core/modules/layout_builder/css/layout-builder.css
index 29f9a2b2055e..9d3a3e62b165 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 6b25f4e4e876..b95e12028e8d 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 13b6b09aa627..590177b2ce54 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 bcf2a9cf0693..4bbfcc9e64de 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 68b3bc846b79..e439587e4a1d 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 b4360553786a..a45d923d3c80 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 9c98847e8907..d767899de9b5 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 000000000000..d8205f95255c
--- /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 26f597b3fd5c..c78692f42c8f 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 000000000000..24a097d24ab8
--- /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);
+  }
+
+}
-- 
GitLab