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