1<?php 2 3namespace Drupal\Tests\quickedit\FunctionalJavascript; 4 5use Drupal\FunctionalJavascriptTests\WebDriverTestBase; 6use WebDriver\Key; 7 8/** 9 * Base class for testing the QuickEdit. 10 */ 11class QuickEditJavascriptTestBase extends WebDriverTestBase { 12 13 /** 14 * {@inheritdoc} 15 */ 16 protected static $modules = ['contextual', 'quickedit', 'toolbar']; 17 18 /** 19 * A user with permissions to edit Articles and use Quick Edit. 20 * 21 * @var \Drupal\user\UserInterface 22 */ 23 protected $contentAuthorUser; 24 25 protected static $expectedFieldStateAttributes = [ 26 'inactive' => '.quickedit-field:not(.quickedit-editable):not(.quickedit-candidate):not(.quickedit-highlighted):not(.quickedit-editing):not(.quickedit-changed)', 27 // A field in 'candidate' state may still have the .quickedit-changed class 28 // because when its changes were saved to tempstore, it'll still be changed. 29 // It's just not currently being edited, so that's why it is not in the 30 // 'changed' state. 31 'candidate' => '.quickedit-field.quickedit-editable.quickedit-candidate:not(.quickedit-highlighted):not(.quickedit-editing)', 32 'highlighted' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted:not(.quickedit-editing)', 33 'activating' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing:not(.quickedit-changed)', 34 'active' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing:not(.quickedit-changed)', 35 'changed' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing.quickedit-changed', 36 'saving' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing.quickedit-changed', 37 ]; 38 39 /** 40 * Starts in-place editing of the given entity instance. 41 * 42 * @param string $entity_type_id 43 * The entity type ID. 44 * @param int $entity_id 45 * The entity ID. 46 * @param int $entity_instance_id 47 * The entity instance ID. (Instance on the page.) 48 */ 49 protected function startQuickEditViaToolbar($entity_type_id, $entity_id, $entity_instance_id) { 50 $page = $this->getSession()->getPage(); 51 52 $toolbar_edit_button_selector = '#toolbar-bar .contextual-toolbar-tab button'; 53 $entity_instance_selector = '[data-quickedit-entity-id="' . $entity_type_id . '/' . $entity_id . '"][data-quickedit-entity-instance-id="' . $entity_instance_id . '"]'; 54 $contextual_links_trigger_selector = '[data-contextual-id] > .trigger'; 55 56 // Assert the original page state does not have the toolbar's "Edit" button 57 // pressed/activated, and hence none of the contextual link triggers should 58 // be visible. 59 $toolbar_edit_button = $page->find('css', $toolbar_edit_button_selector); 60 $this->assertSame('false', $toolbar_edit_button->getAttribute('aria-pressed'), 'The "Edit" button in the toolbar is not yet pressed.'); 61 $this->assertFalse($toolbar_edit_button->hasClass('is-active'), 'The "Edit" button in the toolbar is not yet marked as active.'); 62 foreach ($page->findAll('css', $contextual_links_trigger_selector) as $dom_node) { 63 /** @var \Behat\Mink\Element\NodeElement $dom_node */ 64 $this->assertTrue($dom_node->hasClass('visually-hidden'), 'The contextual links trigger "' . $dom_node->getParent()->getAttribute('data-contextual-id') . '" is hidden.'); 65 } 66 $this->assertTrue(TRUE, 'All contextual links triggers are hidden.'); 67 68 // Click the "Edit" button in the toolbar. 69 $this->click($toolbar_edit_button_selector); 70 71 // Assert the toolbar's "Edit" button is now pressed/activated, and hence 72 // all of the contextual link triggers should be visible. 73 $this->assertSame('true', $toolbar_edit_button->getAttribute('aria-pressed'), 'The "Edit" button in the toolbar is pressed.'); 74 $this->assertTrue($toolbar_edit_button->hasClass('is-active'), 'The "Edit" button in the toolbar is marked as active.'); 75 foreach ($page->findAll('css', $contextual_links_trigger_selector) as $dom_node) { 76 /** @var \Behat\Mink\Element\NodeElement $dom_node */ 77 $this->assertFalse($dom_node->hasClass('visually-hidden'), 'The contextual links trigger "' . $dom_node->getParent()->getAttribute('data-contextual-id') . '" is visible.'); 78 } 79 $this->assertTrue(TRUE, 'All contextual links triggers are visible.'); 80 81 // @todo Press tab key to verify that tabbing is now contrained to only 82 // contextual links triggers: https://www.drupal.org/node/2834776 83 84 // Assert that the contextual links associated with the entity's contextual 85 // links trigger are not visible. 86 /** @var \Behat\Mink\Element\NodeElement $entity_contextual_links_container */ 87 $entity_contextual_links_container = $page->find('css', $entity_instance_selector) 88 ->find('css', $contextual_links_trigger_selector) 89 ->getParent(); 90 $this->assertFalse($entity_contextual_links_container->hasClass('open')); 91 $this->assertTrue($entity_contextual_links_container->find('css', 'ul.contextual-links')->hasAttribute('hidden')); 92 93 // Click the contextual link trigger for the entity we want to Quick Edit. 94 $this->click($entity_instance_selector . ' ' . $contextual_links_trigger_selector); 95 96 $this->assertTrue($entity_contextual_links_container->hasClass('open')); 97 $this->assertFalse($entity_contextual_links_container->find('css', 'ul.contextual-links')->hasAttribute('hidden')); 98 99 // Click the "Quick edit" contextual link. 100 $this->click($entity_instance_selector . ' [data-contextual-id] ul.contextual-links li.quickedit a'); 101 102 // Assert the Quick Edit internal state is correct. 103 $js_condition = <<<JS 104Drupal.quickedit.collections.entities.where({isActive: true}).length === 1 && Drupal.quickedit.collections.entities.where({isActive: true})[0].get('entityID') === '$entity_type_id/$entity_id' 105JS; 106 $this->assertJsCondition($js_condition); 107 } 108 109 /** 110 * Clicks the 'Save' button in the Quick Edit entity toolbar. 111 */ 112 protected function saveQuickEdit() { 113 $quickedit_entity_toolbar = $this->getSession()->getPage()->findById('quickedit-entity-toolbar'); 114 $save_button = $quickedit_entity_toolbar->find('css', 'button.action-save'); 115 $save_button->press(); 116 $this->assertSame('Saving', $save_button->getText()); 117 } 118 119 /** 120 * Awaits Quick Edit to be initiated for all instances of the given entity. 121 * 122 * @param string $entity_type_id 123 * The entity type ID. 124 * @param int $entity_id 125 * The entity ID. 126 */ 127 protected function awaitQuickEditForEntity($entity_type_id, $entity_id) { 128 $entity_selector = '[data-quickedit-entity-id="' . $entity_type_id . '/' . $entity_id . '"]'; 129 $condition = "document.querySelectorAll('" . $entity_selector . "').length === document.querySelectorAll('" . $entity_selector . " .quickedit').length"; 130 $this->assertJsCondition($condition, 10000); 131 } 132 133 /** 134 * Awaits a particular field instance to reach a particular state. 135 * 136 * @param string $entity_type_id 137 * The entity type ID. 138 * @param int $entity_id 139 * The entity ID. 140 * @param int $entity_instance_id 141 * The entity instance ID. (Instance on the page.) 142 * @param string $field_name 143 * The field name. 144 * @param string $langcode 145 * The language code. 146 * @param string $awaited_state 147 * One of the possible field states. 148 */ 149 protected function awaitEntityInstanceFieldState($entity_type_id, $entity_id, $entity_instance_id, $field_name, $langcode, $awaited_state) { 150 $entity_page_id = $entity_type_id . '/' . $entity_id . '[' . $entity_instance_id . ']'; 151 $logical_field_id = $entity_type_id . '/' . $entity_id . '/' . $field_name . '/' . $langcode; 152 $this->assertJsCondition("Drupal.quickedit.collections.entities.get('$entity_page_id').get('fields').findWhere({logicalFieldID: '$logical_field_id'}).get('state') === '$awaited_state'"); 153 } 154 155 /** 156 * Asserts the state of the Quick Edit entity toolbar. 157 * 158 * @param string $expected_entity_label 159 * The expected entity label in the Quick Edit Entity Toolbar. 160 * @param string|null $expected_field_label 161 * The expected field label in the Quick Edit Entity Toolbar, or NULL 162 * if no field label is expected. 163 */ 164 protected function assertQuickEditEntityToolbar($expected_entity_label, $expected_field_label) { 165 $quickedit_entity_toolbar = $this->getSession()->getPage()->findById('quickedit-entity-toolbar'); 166 // We cannot use ->getText() because it also returns the text of all child 167 // nodes. We also cannot use XPath to select text node in Selenium. So we 168 // use JS expression to select only the text node. 169 $this->assertSame($expected_entity_label, $this->getSession()->evaluateScript("return window.jQuery('#quickedit-entity-toolbar .quickedit-toolbar-label').clone().children().remove().end().text();")); 170 if ($expected_field_label !== NULL) { 171 $field_label = $quickedit_entity_toolbar->find('css', '.quickedit-toolbar-label > .field'); 172 // Only try to find the text content of the element if it was actually 173 // found; otherwise use the returned value for assertion. This helps 174 // us find a more useful stack/error message from testbot instead of the 175 // trimmed partial exception stack. 176 if ($field_label) { 177 $field_label = $field_label->getText(); 178 } 179 $this->assertSame($expected_field_label, $field_label); 180 } 181 else { 182 $this->assertEmpty($quickedit_entity_toolbar->find('css', '.quickedit-toolbar-label > .field')); 183 } 184 } 185 186 /** 187 * Asserts all EntityModels (entity instances) on the page. 188 * 189 * @param array $expected_entity_states 190 * Must describe the expected state of all in-place editable entity 191 * instances on the page. 192 * 193 * @see Drupal.quickedit.EntityModel 194 */ 195 protected function assertEntityInstanceStates(array $expected_entity_states) { 196 $js_get_all_field_states_for_entity = <<<JS 197function () { 198 Drupal.quickedit.collections.entities.reduce(function (result, fieldModel) { result[fieldModel.get('id')] = fieldModel.get('state'); return result; }, {}) 199 var entityCollection = Drupal.quickedit.collections.entities; 200 return entityCollection.reduce(function (result, entityModel) { 201 result[entityModel.id] = entityModel.get('state'); 202 return result; 203 }, {}); 204}() 205JS; 206 $this->assertSame($expected_entity_states, $this->getSession()->evaluateScript($js_get_all_field_states_for_entity)); 207 } 208 209 /** 210 * Asserts all FieldModels for the given entity instance. 211 * 212 * @param string $entity_type_id 213 * The entity type ID. 214 * @param int $entity_id 215 * The entity ID. 216 * @param int $entity_instance_id 217 * The entity instance ID. (Instance on the page.) 218 * @param array $expected_field_states 219 * Must describe the expected state of all in-place editable fields of the 220 * given entity instance. 221 */ 222 protected function assertEntityInstanceFieldStates($entity_type_id, $entity_id, $entity_instance_id, array $expected_field_states) { 223 // Get all FieldModel states for the entity instance being asserted. This 224 // ensures that $expected_field_states must describe the state of all fields 225 // of the entity instance. 226 $entity_page_id = $entity_type_id . '/' . $entity_id . '[' . $entity_instance_id . ']'; 227 $js_get_all_field_states_for_entity = <<<JS 228function () { 229 var entityCollection = Drupal.quickedit.collections.entities; 230 var entityModel = entityCollection.get('$entity_page_id'); 231 return entityModel.get('fields').reduce(function (result, fieldModel) { 232 result[fieldModel.get('fieldID')] = fieldModel.get('state'); 233 return result; 234 }, {}); 235}() 236JS; 237 $this->assertEquals($expected_field_states, $this->getSession()->evaluateScript($js_get_all_field_states_for_entity)); 238 239 // Assert that those fields also have the appropriate DOM decorations. 240 $expected_field_attributes = []; 241 foreach ($expected_field_states as $quickedit_field_id => $expected_field_state) { 242 $expected_field_attributes[$quickedit_field_id] = static::$expectedFieldStateAttributes[$expected_field_state]; 243 } 244 $this->assertEntityInstanceFieldMarkup($expected_field_attributes); 245 } 246 247 /** 248 * Asserts all in-place editable fields with markup expectations. 249 * 250 * @param array $expected_field_attributes 251 * Must describe the expected markup attributes for all given in-place 252 * editable fields. 253 * 254 * @todo https://www.drupal.org/project/drupal/issues/3178758 Remove 255 * deprecation layer and add array typehint. 256 */ 257 protected function assertEntityInstanceFieldMarkup($expected_field_attributes) { 258 if (func_num_args() === 4) { 259 $expected_field_attributes = func_get_arg(3); 260 @trigger_error('Calling ' . __METHOD__ . '() with 4 arguments is deprecated in drupal:9.1.0 and will throw an error in drupal:10.0.0. See https://www.drupal.org/project/drupal/issues/3037436', E_USER_DEPRECATED); 261 } 262 if (!is_array($expected_field_attributes)) { 263 throw new \InvalidArgumentException('The $expected_field_attributes argument must be an array.'); 264 } 265 foreach ($expected_field_attributes as $quickedit_field_id => $expectation) { 266 $element = $this->assertSession()->waitForElementVisible('css', '[data-quickedit-field-id="' . $quickedit_field_id . '"]' . $expectation); 267 $this->assertNotEmpty($element, 'Field ' . $quickedit_field_id . ' did not match its expectation selector (' . $expectation . ')'); 268 } 269 } 270 271 /** 272 * Simulates typing in a 'plain_text' in-place editor. 273 * 274 * @param string $css_selector 275 * The CSS selector to find the DOM element (with the 'contenteditable=true' 276 * attribute set), to type in. 277 * @param string $text 278 * The text to type. 279 * 280 * @see \Drupal\quickedit\Plugin\InPlaceEditor\PlainTextEditor 281 */ 282 protected function typeInPlainTextEditor($css_selector, $text) { 283 $field = $this->getSession()->getPage()->find('css', $css_selector); 284 $field->setValue(Key::END . $text); 285 } 286 287 /** 288 * Simulates typing in an input[type=text] inside a 'form' in-place editor. 289 * 290 * @param string $input_name 291 * The "name" attribute of the input[type=text] to type in. 292 * @param string $text 293 * The text to type. 294 * 295 * @see \Drupal\quickedit\Plugin\InPlaceEditor\FormEditor 296 */ 297 protected function typeInFormEditorTextInputField($input_name, $text) { 298 $input = $this->cssSelect('.quickedit-form-container > .quickedit-form[role="dialog"] form.quickedit-field-form input[type=text][name="' . $input_name . '"]')[0]; 299 $input->setValue($text); 300 $js_simulate_user_typing = <<<JS 301function () { 302 var el = document.querySelector('.quickedit-form-container > .quickedit-form[role="dialog"] form.quickedit-field-form input[name="$input_name"]'); 303 window.jQuery(el).trigger('formUpdated'); 304}() 305JS; 306 $this->getSession()->evaluateScript($js_simulate_user_typing); 307 } 308 309} 310