TableDragTest.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. <?php
  2. namespace Drupal\FunctionalJavascriptTests\TableDrag;
  3. use Behat\Mink\Element\NodeElement;
  4. use Behat\Mink\Exception\ExpectationException;
  5. use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
  6. /**
  7. * Tests draggable table.
  8. *
  9. * @group javascript
  10. */
  11. class TableDragTest extends WebDriverTestBase {
  12. /**
  13. * Class used to verify that dragging operations are in execution.
  14. */
  15. const DRAGGING_CSS_CLASS = 'tabledrag-test-dragging';
  16. /**
  17. * {@inheritdoc}
  18. */
  19. protected static $modules = ['tabledrag_test'];
  20. /**
  21. * {@inheritdoc}
  22. */
  23. protected $defaultTheme = 'stark';
  24. /**
  25. * The state service.
  26. *
  27. * @var \Drupal\Core\State\StateInterface
  28. */
  29. protected $state;
  30. /**
  31. * {@inheritdoc}
  32. */
  33. protected function setUp() {
  34. parent::setUp();
  35. $this->state = $this->container->get('state');
  36. }
  37. /**
  38. * Tests row weight switch.
  39. */
  40. public function testRowWeightSwitch() {
  41. $this->state->set('tabledrag_test_table', array_flip(range(1, 3)));
  42. $this->drupalGet('tabledrag_test');
  43. $session = $this->getSession();
  44. $page = $session->getPage();
  45. $weight_select1 = $page->findField("table[1][weight]");
  46. $weight_select2 = $page->findField("table[2][weight]");
  47. $weight_select3 = $page->findField("table[3][weight]");
  48. // Check that rows weight selects are hidden.
  49. $this->assertFalse($weight_select1->isVisible());
  50. $this->assertFalse($weight_select2->isVisible());
  51. $this->assertFalse($weight_select3->isVisible());
  52. // Toggle row weight selects as visible.
  53. $this->findWeightsToggle('Show row weights')->click();
  54. // Check that rows weight selects are visible.
  55. $this->assertTrue($weight_select1->isVisible());
  56. $this->assertTrue($weight_select2->isVisible());
  57. $this->assertTrue($weight_select3->isVisible());
  58. // Toggle row weight selects back to hidden.
  59. $this->findWeightsToggle('Hide row weights')->click();
  60. // Check that rows weight selects are hidden again.
  61. $this->assertFalse($weight_select1->isVisible());
  62. $this->assertFalse($weight_select2->isVisible());
  63. $this->assertFalse($weight_select3->isVisible());
  64. }
  65. /**
  66. * Tests draggable table drag'n'drop.
  67. */
  68. public function testDragAndDrop() {
  69. $this->state->set('tabledrag_test_table', array_flip(range(1, 3)));
  70. $this->drupalGet('tabledrag_test');
  71. $session = $this->getSession();
  72. $page = $session->getPage();
  73. $weight_select1 = $page->findField("table[1][weight]");
  74. $weight_select2 = $page->findField("table[2][weight]");
  75. $weight_select3 = $page->findField("table[3][weight]");
  76. // Check that initially the rows are in the correct order.
  77. $this->assertOrder(['Row with id 1', 'Row with id 2', 'Row with id 3']);
  78. // Check that the 'unsaved changes' text is not present in the message area.
  79. $this->assertSession()->pageTextNotContains('You have unsaved changes.');
  80. $row1 = $this->findRowById(1)->find('css', 'a.tabledrag-handle');
  81. $row2 = $this->findRowById(2)->find('css', 'a.tabledrag-handle');
  82. $row3 = $this->findRowById(3)->find('css', 'a.tabledrag-handle');
  83. // Drag row1 over row2.
  84. $row1->dragTo($row2);
  85. // Check that the 'unsaved changes' text was added in the message area.
  86. $this->assertSession()->waitForText('You have unsaved changes.');
  87. // Check that row1 and row2 were swapped.
  88. $this->assertOrder(['Row with id 2', 'Row with id 1', 'Row with id 3']);
  89. // Check that weights were changed.
  90. $this->assertGreaterThan($weight_select2->getValue(), $weight_select1->getValue());
  91. $this->assertGreaterThan($weight_select2->getValue(), $weight_select3->getValue());
  92. $this->assertGreaterThan($weight_select1->getValue(), $weight_select3->getValue());
  93. // Now move the last row (row3) in the second position. row1 should go last.
  94. $row3->dragTo($row1);
  95. // Check that the order is: row2, row3 and row1.
  96. $this->assertOrder(['Row with id 2', 'Row with id 3', 'Row with id 1']);
  97. }
  98. /**
  99. * Tests accessibility through keyboard of the tabledrag functionality.
  100. */
  101. public function testKeyboardAccessibility() {
  102. $this->state->set('tabledrag_test_table', array_flip(range(1, 5)));
  103. $expected_table = [
  104. ['id' => 1, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
  105. ['id' => 2, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
  106. ['id' => 3, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
  107. ['id' => 4, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
  108. ['id' => 5, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
  109. ];
  110. $this->drupalGet('tabledrag_test');
  111. $this->assertDraggableTable($expected_table);
  112. // Nest the row with id 2 as child of row 1.
  113. $this->moveRowWithKeyboard($this->findRowById(2), 'right');
  114. $expected_table[1] = ['id' => 2, 'weight' => -10, 'parent' => 1, 'indentation' => 1, 'changed' => TRUE];
  115. $this->assertDraggableTable($expected_table);
  116. // Nest the row with id 3 as child of row 1.
  117. $this->moveRowWithKeyboard($this->findRowById(3), 'right');
  118. $expected_table[2] = ['id' => 3, 'weight' => -9, 'parent' => 1, 'indentation' => 1, 'changed' => TRUE];
  119. $this->assertDraggableTable($expected_table);
  120. // Nest the row with id 3 as child of row 2.
  121. $this->moveRowWithKeyboard($this->findRowById(3), 'right');
  122. $expected_table[2] = ['id' => 3, 'weight' => -10, 'parent' => 2, 'indentation' => 2, 'changed' => TRUE];
  123. $this->assertDraggableTable($expected_table);
  124. // Nesting should be allowed to maximum level 2.
  125. $this->moveRowWithKeyboard($this->findRowById(4), 'right', 4);
  126. $expected_table[3] = ['id' => 4, 'weight' => -9, 'parent' => 2, 'indentation' => 2, 'changed' => TRUE];
  127. $this->assertDraggableTable($expected_table);
  128. // Re-order children of row 1.
  129. $this->moveRowWithKeyboard($this->findRowById(4), 'up');
  130. $expected_table[2] = ['id' => 4, 'weight' => -10, 'parent' => 2, 'indentation' => 2, 'changed' => TRUE];
  131. $expected_table[3] = ['id' => 3, 'weight' => -9, 'parent' => 2, 'indentation' => 2, 'changed' => TRUE];
  132. $this->assertDraggableTable($expected_table);
  133. // Move back the row 3 to the 1st level.
  134. $this->moveRowWithKeyboard($this->findRowById(3), 'left');
  135. $expected_table[3] = ['id' => 3, 'weight' => -9, 'parent' => 1, 'indentation' => 1, 'changed' => TRUE];
  136. $this->assertDraggableTable($expected_table);
  137. $this->moveRowWithKeyboard($this->findRowById(3), 'left');
  138. $expected_table[0] = ['id' => 1, 'weight' => -10, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
  139. $expected_table[3] = ['id' => 3, 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
  140. $expected_table[4] = ['id' => 5, 'weight' => -8, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
  141. $this->assertDraggableTable($expected_table);
  142. // Move row 3 to the last position.
  143. $this->moveRowWithKeyboard($this->findRowById(3), 'down');
  144. $expected_table[3] = ['id' => 5, 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
  145. $expected_table[4] = ['id' => 3, 'weight' => -8, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
  146. $this->assertDraggableTable($expected_table);
  147. // Nothing happens when trying to move the last row further down.
  148. $this->moveRowWithKeyboard($this->findRowById(3), 'down');
  149. $this->assertDraggableTable($expected_table);
  150. // Nest row 3 under 5. The max depth allowed should be 1.
  151. $this->moveRowWithKeyboard($this->findRowById(3), 'right', 3);
  152. $expected_table[4] = ['id' => 3, 'weight' => -10, 'parent' => 5, 'indentation' => 1, 'changed' => TRUE];
  153. $this->assertDraggableTable($expected_table);
  154. // The first row of the table cannot be nested.
  155. $this->moveRowWithKeyboard($this->findRowById(1), 'right');
  156. $this->assertDraggableTable($expected_table);
  157. // Move a row which has nested children. The children should move with it,
  158. // with nesting preserved. Swap the order of the top-level rows by moving
  159. // row 1 to after row 3.
  160. $this->moveRowWithKeyboard($this->findRowById(1), 'down', 2);
  161. $expected_table[0] = ['id' => 5, 'weight' => -10, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
  162. $expected_table[3] = $expected_table[1];
  163. $expected_table[1] = $expected_table[4];
  164. $expected_table[4] = $expected_table[2];
  165. $expected_table[2] = ['id' => 1, 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
  166. $this->assertDraggableTable($expected_table);
  167. }
  168. /**
  169. * Tests the root and leaf behaviors for rows.
  170. */
  171. public function testRootLeafDraggableRowsWithKeyboard() {
  172. $this->state->set('tabledrag_test_table', [
  173. 1 => [],
  174. 2 => ['parent' => 1, 'depth' => 1, 'classes' => ['tabledrag-leaf']],
  175. 3 => ['parent' => 1, 'depth' => 1],
  176. 4 => [],
  177. 5 => ['classes' => ['tabledrag-root']],
  178. ]);
  179. $this->drupalGet('tabledrag_test');
  180. $expected_table = [
  181. ['id' => 1, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
  182. ['id' => 2, 'weight' => 0, 'parent' => 1, 'indentation' => 1, 'changed' => FALSE],
  183. ['id' => 3, 'weight' => 0, 'parent' => 1, 'indentation' => 1, 'changed' => FALSE],
  184. ['id' => 4, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
  185. ['id' => 5, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
  186. ];
  187. $this->assertDraggableTable($expected_table);
  188. // Rows marked as root cannot be moved as children of another row.
  189. $this->moveRowWithKeyboard($this->findRowById(5), 'right');
  190. $this->assertDraggableTable($expected_table);
  191. // Rows marked as leaf cannot have children. Trying to move the row #3
  192. // as child of #2 should have no results.
  193. $this->moveRowWithKeyboard($this->findRowById(3), 'right');
  194. $this->assertDraggableTable($expected_table);
  195. // Leaf can be still swapped and moved to first level.
  196. $this->moveRowWithKeyboard($this->findRowById(2), 'down');
  197. $this->moveRowWithKeyboard($this->findRowById(2), 'left');
  198. $expected_table[0]['weight'] = -10;
  199. $expected_table[1]['id'] = 3;
  200. $expected_table[1]['weight'] = -10;
  201. $expected_table[2] = ['id' => 2, 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
  202. $expected_table[3]['weight'] = -8;
  203. $expected_table[4]['weight'] = -7;
  204. $this->assertDraggableTable($expected_table);
  205. // Root rows can have children.
  206. $this->moveRowWithKeyboard($this->findRowById(4), 'down');
  207. $this->moveRowWithKeyboard($this->findRowById(4), 'right');
  208. $expected_table[3]['id'] = 5;
  209. $expected_table[4] = ['id' => 4, 'weight' => -10, 'parent' => 5, 'indentation' => 1, 'changed' => TRUE];
  210. $this->assertDraggableTable($expected_table);
  211. }
  212. /**
  213. * Tests the warning that appears upon making changes to a tabledrag table.
  214. */
  215. public function testTableDragChangedWarning() {
  216. $this->drupalGet('tabledrag_test');
  217. // By default no text is visible.
  218. $this->assertSession()->pageTextNotContains('You have unsaved changes.');
  219. // Try to make a non-allowed action, like moving further down the last row.
  220. // No changes happen, so no message should be shown.
  221. $this->moveRowWithKeyboard($this->findRowById(5), 'down');
  222. $this->assertSession()->pageTextNotContains('You have unsaved changes.');
  223. // Make a change. The message will appear.
  224. $this->moveRowWithKeyboard($this->findRowById(5), 'right');
  225. $this->assertSession()->pageTextContainsOnce('You have unsaved changes.');
  226. // Make another change, the text will stay visible and appear only once.
  227. $this->moveRowWithKeyboard($this->findRowById(2), 'up');
  228. $this->assertSession()->pageTextContainsOnce('You have unsaved changes.');
  229. }
  230. /**
  231. * Asserts that several pieces of markup are in a given order in the page.
  232. *
  233. * @param string[] $items
  234. * An ordered list of strings.
  235. *
  236. * @throws \Behat\Mink\Exception\ExpectationException
  237. * When any of the given string is not found.
  238. *
  239. * @todo Remove this and use the WebAssert method when #2817657 is done.
  240. */
  241. protected function assertOrder(array $items) {
  242. $session = $this->getSession();
  243. $text = $session->getPage()->getHtml();
  244. $strings = [];
  245. foreach ($items as $item) {
  246. if (($pos = strpos($text, $item)) === FALSE) {
  247. throw new ExpectationException("Cannot find '$item' in the page", $session->getDriver());
  248. }
  249. $strings[$pos] = $item;
  250. }
  251. ksort($strings);
  252. $this->assertSame($items, array_values($strings), "Strings found on the page but incorrectly ordered.");
  253. }
  254. /**
  255. * Asserts the whole structure of the draggable test table.
  256. *
  257. * @param array $structure
  258. * The table structure. Each entry represents a row and consists of:
  259. * - id: the expected value for the ID hidden field.
  260. * - weight: the expected row weight.
  261. * - parent: the expected parent ID for the row.
  262. * - indentation: how many indents the row should have.
  263. * - changed: whether or not the row should have been marked as changed.
  264. */
  265. protected function assertDraggableTable(array $structure) {
  266. $rows = $this->getSession()->getPage()->findAll('xpath', '//table[@id="tabledrag-test-table"]/tbody/tr');
  267. $this->assertSession()->elementsCount('xpath', '//table[@id="tabledrag-test-table"]/tbody/tr', count($structure));
  268. foreach ($structure as $delta => $expected) {
  269. $this->assertTableRow($rows[$delta], $expected['id'], $expected['weight'], $expected['parent'], $expected['indentation'], $expected['changed']);
  270. }
  271. }
  272. /**
  273. * Asserts the values of a draggable row.
  274. *
  275. * @param \Behat\Mink\Element\NodeElement $row
  276. * The row element to assert.
  277. * @param string $id
  278. * The expected value for the ID hidden input of the row.
  279. * @param int $weight
  280. * The expected weight of the row.
  281. * @param string $parent
  282. * The expected parent ID.
  283. * @param int $indentation
  284. * The expected indentation of the row.
  285. * @param bool $changed
  286. * Whether or not the row should have been marked as changed.
  287. */
  288. protected function assertTableRow(NodeElement $row, $id, $weight, $parent = '', $indentation = 0, $changed = FALSE) {
  289. // Assert that the row position is correct by checking that the id
  290. // corresponds.
  291. $this->assertSession()->hiddenFieldValueEquals("table[$id][id]", $id, $row);
  292. $this->assertSession()->hiddenFieldValueEquals("table[$id][parent]", $parent, $row);
  293. $this->assertSession()->fieldValueEquals("table[$id][weight]", $weight, $row);
  294. $this->assertSession()->elementsCount('css', '.js-indentation.indentation', $indentation, $row);
  295. // A row is marked as changed when the related markup is present.
  296. $this->assertSession()->elementsCount('css', 'abbr.tabledrag-changed', (int) $changed, $row);
  297. }
  298. /**
  299. * Finds a row in the test table by the row ID.
  300. *
  301. * @param string $id
  302. * The ID of the row.
  303. *
  304. * @return \Behat\Mink\Element\NodeElement
  305. * The row element.
  306. */
  307. protected function findRowById($id) {
  308. $xpath = "//table[@id='tabledrag-test-table']/tbody/tr[.//input[@name='table[$id][id]']]";
  309. $row = $this->getSession()->getPage()->find('xpath', $xpath);
  310. $this->assertNotEmpty($row);
  311. return $row;
  312. }
  313. /**
  314. * Finds the show/hide weight toggle element.
  315. *
  316. * @param string $expected_text
  317. * The expected text on the element.
  318. *
  319. * @return \Behat\Mink\Element\NodeElement
  320. * The toggle element.
  321. */
  322. protected function findWeightsToggle($expected_text) {
  323. $toggle = $this->getSession()->getPage()->findButton($expected_text);
  324. $this->assertNotEmpty($toggle);
  325. return $toggle;
  326. }
  327. /**
  328. * Moves a row through the keyboard.
  329. *
  330. * @param \Behat\Mink\Element\NodeElement $row
  331. * The row to move.
  332. * @param string $arrow
  333. * The arrow button to use to move the row. Either one of 'left', 'right',
  334. * 'up' or 'down'.
  335. * @param int $repeat
  336. * (optional) How many times to press the arrow button. Defaults to 1.
  337. */
  338. protected function moveRowWithKeyboard(NodeElement $row, $arrow, $repeat = 1) {
  339. $keys = [
  340. 'left' => 37,
  341. 'right' => 39,
  342. 'up' => 38,
  343. 'down' => 40,
  344. ];
  345. if (!isset($keys[$arrow])) {
  346. throw new \InvalidArgumentException('The arrow parameter must be one of "left", "right", "up" or "down".');
  347. }
  348. $key = $keys[$arrow];
  349. $handle = $row->find('css', 'a.tabledrag-handle');
  350. $handle->focus();
  351. for ($i = 0; $i < $repeat; $i++) {
  352. $this->markRowHandleForDragging($handle);
  353. $handle->keyDown($key);
  354. $handle->keyUp($key);
  355. $this->waitUntilDraggingCompleted($handle);
  356. }
  357. $handle->blur();
  358. }
  359. /**
  360. * Marks a row handle for dragging.
  361. *
  362. * The handle is marked by adding a css class that is removed by an helper
  363. * js library once the dragging is over.
  364. *
  365. * @param \Behat\Mink\Element\NodeElement $handle
  366. * The draggable row handle element.
  367. *
  368. * @throws \Exception
  369. * Thrown when the class is not added successfully to the handle.
  370. */
  371. protected function markRowHandleForDragging(NodeElement $handle) {
  372. $class = self::DRAGGING_CSS_CLASS;
  373. $script = <<<JS
  374. document.evaluate("{$handle->getXpath()}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
  375. .singleNodeValue.classList.add('{$class}');
  376. JS;
  377. $this->getSession()->executeScript($script);
  378. $has_class = $this->getSession()->getPage()->waitFor(1, function () use ($handle, $class) {
  379. return $handle->hasClass($class);
  380. });
  381. if (!$has_class) {
  382. throw new \Exception(sprintf('Dragging css class was not added on handle "%s".', $handle->getXpath()));
  383. }
  384. }
  385. /**
  386. * Waits until the dragging operations are finished on a row handle.
  387. *
  388. * @param \Behat\Mink\Element\NodeElement $handle
  389. * The draggable row handle element.
  390. *
  391. * @throws \Exception
  392. * Thrown when the dragging operations are not completed on time.
  393. */
  394. protected function waitUntilDraggingCompleted(NodeElement $handle) {
  395. $class_removed = $this->getSession()->getPage()->waitFor(1, function () use ($handle) {
  396. return !$handle->hasClass($this::DRAGGING_CSS_CLASS);
  397. });
  398. if (!$class_removed) {
  399. throw new \Exception(sprintf('Dragging operations did not complete on time on handle %s', $handle->getXpath()));
  400. }
  401. }
  402. }