diff --git a/core/misc/tabledrag.js b/core/misc/tabledrag.js
index c50de1ff3b..72047ac26e 100644
--- a/core/misc/tabledrag.js
+++ b/core/misc/tabledrag.js
@@ -998,7 +998,7 @@
Drupal.elementIsHidden(row) &&
Drupal.elementIsHidden($row.prev('tr')[0])
) {
- $row = $row.prev('tr:first-of-type');
+ $row = $row.prev('tr');
row = $row.get(0);
}
return row;
@@ -1045,9 +1045,9 @@
}
// Siblings are easy, check previous and next rows.
else if (rowSettings.relationship === 'sibling') {
- $previousRow = $changedRow.prev('tr:first-of-type');
+ $previousRow = $changedRow.prev('tr');
previousRow = $previousRow.get(0);
- const $nextRow = $changedRow.next('tr:first-of-type');
+ const $nextRow = $changedRow.next('tr');
const nextRow = $nextRow.get(0);
sourceRow = changedRow;
if (
@@ -1104,7 +1104,7 @@
// Use the first row in the table as source, because it's guaranteed to
// be at the root level. Find the first item, then compare this row
// against it as a sibling.
- sourceRow = $(this.table).find('tr.draggable:first-of-type').get(0);
+ sourceRow = $(this.table).find('tr.draggable').get(0);
if (sourceRow === this.rowObject.element) {
sourceRow = $(this.rowObject.group[this.rowObject.group.length - 1])
.next('tr.draggable')
diff --git a/core/modules/system/tests/modules/tabledrag_test/css/tabledrag_test.css b/core/modules/system/tests/modules/tabledrag_test/css/tabledrag_test.css
new file mode 100644
index 0000000000..d61d0b7a90
--- /dev/null
+++ b/core/modules/system/tests/modules/tabledrag_test/css/tabledrag_test.css
@@ -0,0 +1,16 @@
+/**
+ * Ensure all rows are equal height to avoid overlap and discontinuities in drop
+ * zones and make the drop location more predictable.
+ */
+#tabledrag-test-table tr {
+ height: 4rem;
+}
+
+/**
+ * When dragging a row up to be a child of another, the drag should finish below
+ * the target row.
+ */
+[data-drop-target-child-up] {
+ position: relative;
+ top: 4rem;
+}
diff --git a/core/modules/system/tests/modules/tabledrag_test/src/Form/TableDragSubgroupTestForm.php b/core/modules/system/tests/modules/tabledrag_test/src/Form/TableDragSubgroupTestForm.php
new file mode 100644
index 0000000000..8ad6bfa004
--- /dev/null
+++ b/core/modules/system/tests/modules/tabledrag_test/src/Form/TableDragSubgroupTestForm.php
@@ -0,0 +1,228 @@
+state = $state;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static($container->get('state'));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'tabledrag_test_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $form['table'] = [
+ '#type' => 'table',
+ '#header' => [
+ [
+ 'data' => $this->t('Text'),
+ 'colspan' => 5,
+ ],
+ $this->t('Weight'),
+ ],
+ '#attributes' => ['id' => 'tabledrag-test-table'],
+ '#attached' => ['library' => ['tabledrag_test/tabledrag']],
+ ];
+
+ $groups = [
+ 'a' => $this->t('Group A'),
+ 'b' => $this->t('Group B'),
+ ];
+
+ // Provide a default set of five rows.
+ $rows = $this->state->get('tabledrag_test_subgroup_table', array_flip(range(1, 5)));
+
+ foreach ($rows as $id => $row) {
+ if (!is_array($row)) {
+ $rows[$id] = [];
+ }
+ // Set defaults
+ $rows[$id] += [
+ 'parent' => '',
+ 'weight' => 0,
+ 'depth' => 0,
+ 'classes' => [],
+ 'draggable' => TRUE,
+ 'group' => $id < 3 ? 'a' : 'b',
+ ];
+ }
+
+ // Organize by group.
+ $by_group = [];
+ foreach ($rows as $id => $row) {
+ $by_group[$row['group']][$id] = $row;
+ }
+
+ // When dragging a row to be a child of another, the target to drag to is
+ // different depending on whether you're dragging down or up. Create a
+ // target span for both situations and style them into the appropriate
+ // position.
+ $drop_targets = [
+ ' ',
+ ' ',
+ ];
+
+ foreach ($by_group as $group => $group_rows) {
+ $form['table']['#tabledrag'][] = [
+ 'action' => 'order',
+ 'relationship' => 'sibling',
+ 'group' => 'tabledrag-test-weight',
+ 'subgroup' => 'tabledrag-test-weight-' . $group,
+ ];
+ $form['table']['#tabledrag'][] = [
+ 'action' => 'match',
+ 'relationship' => 'parent',
+ 'group' => 'tabledrag-test-parent',
+ 'subgroup' => 'tabledrag-test-parent-' . $group,
+ 'source' => 'tabledrag-test-id',
+ 'hidden' => TRUE,
+ 'limit' => 2,
+ ];
+ $form['table']['#tabledrag'][] = [
+ 'action' => 'depth',
+ 'relationship' => 'group',
+ 'group' => 'tabledrag-test-depth',
+ 'subgroup' => 'tabledrag-test-depth-' . $group,
+ 'hidden' => TRUE,
+ ];
+ $form['table']['#tabledrag'][] = [
+ 'action' => 'match',
+ 'relationship' => 'sibling',
+ 'group' => 'tabledrag-test-group',
+ 'subgroup' => 'tabledrag-test-group-' . $group,
+ ];
+
+ $form['table']['group-' . $group]['title'] = [
+ '#plain_text' => $groups[$group],
+ '#wrapper_attributes' => [
+ 'colspan' => 6,
+ ],
+ ];
+ foreach ($group_rows as $id => $row) {
+ if (!is_array($row)) {
+ $row = [];
+ }
+
+ if (!empty($row['draggable'])) {
+ $row['classes'][] = 'draggable';
+ }
+
+ $form['table'][$id] = [
+ 'title' => [
+ 'indentation' => [
+ '#theme' => 'indentation',
+ '#size' => $row['depth'],
+ ],
+ '#plain_text' => "Row with id $id",
+ // Add the drag & drop targets.
+ '#prefix' => implode('', $drop_targets),
+ ],
+ 'id' => [
+ '#type' => 'hidden',
+ '#value' => $id,
+ '#attributes' => ['class' => ['tabledrag-test-id']],
+ ],
+ 'parent' => [
+ '#type' => 'hidden',
+ '#default_value' => $row['parent'],
+ '#parents' => ['table', $id, 'parent'],
+ '#attributes' => [
+ 'class' => [
+ 'tabledrag-test-parent', 'tabledrag-test-parent-' . $group,
+ ],
+ ],
+ ],
+ 'depth' => [
+ '#type' => 'hidden',
+ '#default_value' => $row['depth'],
+ '#attributes' => [
+ 'class' => [
+ 'tabledrag-test-depth', 'tabledrag-test-depth-' . $group,
+ ],
+ ],
+ ],
+ 'group' => [
+ '#type' => 'hidden',
+ '#default_value' => $row['group'],
+ '#attributes' => [
+ 'class' => [
+ 'tabledrag-test-group', 'tabledrag-test-group-' . $group,
+ ],
+ ],
+ ],
+ 'weight' => [
+ '#type' => 'weight',
+ '#default_value' => $row['weight'],
+ '#attributes' => [
+ 'class' => [
+ 'tabledrag-test-weight', 'tabledrag-test-weight-' . $group,
+ ],
+ ],
+ ],
+ '#attributes' => ['class' => $row['classes']],
+ ];
+ }
+ }
+
+ $form['save'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Save'),
+ ];
+
+ return $form;
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $test_table = [];
+ foreach ($form_state->getValue('table') as $row) {
+ $test_table[$row['id']] = $row;
+ }
+ $this->state->set('tabledrag_test_subgroup_table', $test_table);
+ }
+
+}
diff --git a/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.libraries.yml b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.libraries.yml
index 87876c3c2b..2a31dd78d8 100644
--- a/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.libraries.yml
+++ b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.libraries.yml
@@ -2,5 +2,8 @@ tabledrag:
version: VERSION
js:
js/tabledrag_test.js: {}
+ css:
+ theme:
+ css/tabledrag_test.css: {}
dependencies:
- core/drupal.tabledrag
diff --git a/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.routing.yml b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.routing.yml
index cc9cf59b83..9a738f6450 100644
--- a/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.routing.yml
+++ b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.routing.yml
@@ -13,3 +13,11 @@ tabledrag_test.nested_tabledrag_test_form:
_title: 'Nested draggable table test'
requirements:
_access: 'TRUE'
+
+tabledrag_test.subgroup_test_form:
+ path: '/tabledrag_subgroup_test'
+ defaults:
+ _form: '\Drupal\tabledrag_test\Form\TableDragSubgroupTestForm'
+ _title: 'Draggable table test'
+ requirements:
+ _access: 'TRUE'
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/TableDrag/TableDragSubgroupTest.php b/core/tests/Drupal/FunctionalJavascriptTests/TableDrag/TableDragSubgroupTest.php
new file mode 100644
index 0000000000..332f2fb551
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/TableDrag/TableDragSubgroupTest.php
@@ -0,0 +1,262 @@
+state = $this->container->get('state');
+ }
+
+ /**
+ * Tests that dragging rows up and down in a sub-grouped table works.
+ */
+ public function testVerticalDragAndDrop() {
+ $this->drupalGet('tabledrag_subgroup_test');
+
+ $this->assertDraggableTable([
+ ['id' => 1, 'group' => 'a', 'changed' => FALSE],
+ ['id' => 2, 'group' => 'a', 'changed' => FALSE],
+ ['id' => 3, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 4, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 5, 'group' => 'b', 'changed' => FALSE],
+ ]);
+
+ // Drag the first row into group B.
+ $row1 = $this->findRowById(1);
+ $row3 = $this->findRowById(3);
+ $handle1 = $this->findDragHandle($row1);
+ $handle3 = $this->findDragHandle($row3);
+
+ $handle1->dragTo($handle3);
+ $this->assertSession()->waitForText('You have unsaved changes');
+
+ $this->assertDraggableTable([
+ ['id' => 2, 'group' => 'a', 'changed' => FALSE],
+ ['id' => 3, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 1, 'group' => 'b', 'changed' => TRUE],
+ ['id' => 4, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 5, 'group' => 'b', 'changed' => FALSE],
+ ]);
+
+ // Drag row 4 from group B to the top of the table in group A.
+ $row2 = $this->findRowById(2);
+ $row4 = $this->findRowById(4);
+ $handle2 = $this->findDragHandle($row2);
+ $handle4 = $this->findDragHandle($row4);
+
+ $handle4->dragTo($handle2);
+ $this->waitForDragging();
+
+ $this->assertDraggableTable([
+ ['id' => 4, 'group' => 'a', 'changed' => TRUE],
+ ['id' => 2, 'group' => 'a', 'changed' => FALSE],
+ ['id' => 3, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 1, 'group' => 'b', 'changed' => TRUE],
+ ['id' => 5, 'group' => 'b', 'changed' => FALSE],
+ ]);
+ }
+
+ /**
+ * Tests that dragging rows into a hierarchy in a sub-grouped table works.
+ */
+ public function testHierarchicalDragAndDrop() {
+ $this->drupalGet('tabledrag_subgroup_test');
+
+ $this->assertDraggableTable([
+ ['id' => 1, 'group' => 'a', 'changed' => FALSE],
+ ['id' => 2, 'group' => 'a', 'changed' => FALSE],
+ ['id' => 3, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 4, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 5, 'group' => 'b', 'changed' => FALSE],
+ ]);
+
+ // Make row 1 a child of row 2.
+ $row1 = $this->findRowById(1);
+ $row2 = $this->findRowById(2);
+
+ $this->dragToChild($row1, $row2, 'down');
+ $this->assertSession()->waitForText('You have unsaved changes');
+
+ $this->assertDraggableTable([
+ ['id' => 2, 'group' => 'a', 'changed' => FALSE],
+ ['id' => 1, 'group' => 'a', 'changed' => TRUE, 'parent' => 2, 'indent' => 1],
+ ['id' => 3, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 4, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 5, 'group' => 'b', 'changed' => FALSE],
+ ]);
+
+ // Make a deeper hierarchy by moving 5 under 1.
+ $row5 = $this->findRowById(5);
+ $this->dragToChild($row5, $row1, 'up');
+ $this->waitForDragging();
+
+ $this->assertDraggableTable([
+ ['id' => 2, 'group' => 'a', 'changed' => FALSE],
+ ['id' => 1, 'group' => 'a', 'changed' => TRUE, 'parent' => 2, 'indent' => 1],
+ ['id' => 5, 'group' => 'a', 'changed' => TRUE, 'parent' => 1, 'indent' => 2],
+ ['id' => 3, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 4, 'group' => 'b', 'changed' => FALSE],
+ ]);
+
+ // Drag row 1 back to the top row.
+ $handle2 = $this->findDragHandle($row2);
+ $handle1 = $this->findDragHandle($row1);
+
+ $handle1->dragTo($handle2);
+ $this->waitForDragging();
+
+ $this->assertDraggableTable([
+ ['id' => 1, 'group' => 'a', 'changed' => TRUE],
+ ['id' => 5, 'group' => 'a', 'changed' => TRUE, 'parent' => 1, 'indent' => 1],
+ ['id' => 2, 'group' => 'a', 'changed' => FALSE],
+ ['id' => 3, 'group' => 'b', 'changed' => FALSE],
+ ['id' => 4, 'group' => 'b', 'changed' => FALSE],
+ ]);
+ }
+
+ /**
+ * Drag a row to the child of another row.
+ *
+ * @param \Behat\Mink\Element\NodeElement $child
+ * The row to drag into the child position;
+ * @param \Behat\Mink\Element\NodeElement $parent
+ * The row that will be parent;
+ * @param string $direction
+ * Pass 'up' if the child row is being dragged up; 'down' otherwise.
+ */
+ protected function dragToChild($child, $parent, $direction) {
+ $child_handle = $this->findDragHandle($child);
+ $parent_target = $parent->find('css', "[data-drop-target-child-$direction]");
+ $child_handle->dragTo($parent_target);
+ }
+
+ /**
+ * Waits for tabledrag dragging to finish.
+ */
+ protected function waitForDragging() {
+ $this->assertSession()->assertNoElementAfterWait('css', '#tabledrag-test-table tr.drag');
+ }
+
+ /**
+ * Finds a row in the test table by the row ID.
+ *
+ * @param string $id
+ * The ID of the row.
+ *
+ * @return \Behat\Mink\Element\NodeElement
+ * The row element.
+ */
+ protected function findRowById($id) {
+ $xpath = "//table[@id='tabledrag-test-table']/tbody/tr[.//input[@name='table[$id][id]']]";
+ $row = $this->getSession()->getPage()->find('xpath', $xpath);
+ $this->assertNotEmpty($row);
+ return $row;
+ }
+
+ /**
+ * Finds a row drag handle from a containing element, eg. row.
+ *
+ * @param \Behat\Mink\Element\NodeElement $element
+ * The containing element, eg. row.
+ *
+ * @return \Behat\Mink\Element\NodeElement
+ * The drag handle element.
+ */
+ protected function findDragHandle($element) {
+ $handle = $element->find('css', 'a.tabledrag-handle');
+ $this->assertNotEmpty($handle);
+ return $handle;
+ }
+
+ /**
+ * Asserts the structure of the draggable test table.
+ *
+ * Although passing the exact weight in the structure is optional, the order
+ * and number of rows is asserted regardless.
+ *
+ * @param array $structure
+ * The table structure. Each entry represents a row and consists of:
+ * - id: the expected value for the ID hidden field.
+ * - parent: the expected parent ID for the row.
+ * - indent: how many indents the row should have.
+ * - changed: whether or not the row should have been marked as changed.
+ * - weight: (optional) the expected row weight.
+ */
+ protected function assertDraggableTable(array $structure) {
+ $rows = $this->getSession()->getPage()->findAll('xpath', '//table[@id="tabledrag-test-table"]/tbody/tr[contains(@class, "draggable")]');
+ $this->assertSame(count($structure), count($rows), "An unexpected number of draggable table rows was found.");
+
+ foreach ($structure as $delta => $expected) {
+ $parent = $expected['parent'] ?? '';
+ $indent = $expected['indent'] ?? 0;
+ $changed = $expected['changed'] ?? FALSE;
+ $weight = $expected['weight'] ?? NULL;
+ $this->assertTableRow($rows[$delta], $expected['id'], $expected['group'], $parent, $indent, $changed, $weight);
+ }
+ }
+
+ /**
+ * Asserts the values of a draggable row.
+ *
+ * @param \Behat\Mink\Element\NodeElement $row
+ * The row element to assert.
+ * @param string $id
+ * The expected value for the ID hidden input of the row.
+ * @param string $group
+ * The expected group ID.
+ * @param string $parent
+ * The expected parent ID.
+ * @param int $indent
+ * The expected indent of the row.
+ * @param bool $changed
+ * Whether or not the row should have been marked as changed.
+ * @param int|null $weight
+ * (optional) The expected weight of the row or NULL to not test the weight;
+ * defaults to NULL.
+ */
+ protected function assertTableRow(NodeElement $row, $id, $group, $parent = '', $indent = 0, $changed = FALSE, $weight = NULL) {
+ // Assert that the row position is correct by checking that the id
+ // corresponds.
+ $this->assertSession()->hiddenFieldValueEquals("table[$id][id]", $id, $row);
+ $this->assertSession()->hiddenFieldValueEquals("table[$id][parent]", $parent, $row);
+ $this->assertSession()->hiddenFieldValueEquals("table[$id][group]", $group, $row);
+ // $weight might be NULL
+ if (!is_null($weight)) {
+ $this->assertSession()->fieldValueEquals("table[$id][weight]", $weight, $row);
+ }
+ $this->assertSession()->elementsCount('css', '.js-indentation.indentation', $indent, $row);
+ // A row is marked as changed when the related markup is present.
+ $this->assertSession()->elementsCount('css', 'abbr.tabledrag-changed', (int) $changed, $row);
+ }
+
+}