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); + } + +}