diff -u b/modules/webform_ajax/src/Controller/WebformAjaxController.php b/modules/webform_ajax/src/Controller/WebformAjaxController.php --- b/modules/webform_ajax/src/Controller/WebformAjaxController.php +++ b/modules/webform_ajax/src/Controller/WebformAjaxController.php @@ -19,7 +19,11 @@ * the wrapper_id with a new requested webform * * @param WebformInterface $webform + * The webform being submitted. * @param string $wrapper_id + * The ID of the wrapper that will be replaced by this AJAX callback. + * + * @return AjaxResponse */ public function return_webform(WebformInterface $webform, $wrapper_id) { $response = new AjaxResponse(); @@ -28,12 +32,14 @@ // Display the webform only if it is enabled. Otherwise, show messages. if ($webform->isOpen()) { $form = Webform::load($webform->get('id')); - $output = \Drupal::entityManager()->getViewBuilder('webform')->view($form); + $output = $this->entityTypeManager() + ->getViewBuilder('webform') + ->view($form); } else { - $output = array ( + $output = [ '#type' => 'markup', - '#markup' => t('The webform cannot be displayed.') - ); + '#markup' => $this->t('The webform cannot be displayed.'), + ]; } $response->addCommand(new ReplaceCommand('#' . $wrapper_id, $output)); diff -u b/modules/webform_ajax/tests/src/FunctionalJavascript/WebformAjaxTest.php b/modules/webform_ajax/tests/src/FunctionalJavascript/WebformAjaxTest.php --- b/modules/webform_ajax/tests/src/FunctionalJavascript/WebformAjaxTest.php +++ b/modules/webform_ajax/tests/src/FunctionalJavascript/WebformAjaxTest.php @@ -1,8 +1,18 @@ provider->webformWithTextfield(); + protected $provider; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + $this->provider = new WebformAjaxProvider(); + } + + /** + * Test AJAX webform submission storage. + * + * @see \Drupal\webform\Tests\WebformSubmissionStorageTest::testSubmissionStorage() + */ + public function testAjaxSubmissionStorage() { + $webform = $this->provider->webformWithTextfield(); + $webform->setSetting('confirmation_type', 'message'); + $webform->save(); + $assert = $this->assertSession(); + + $this->drupalPostForm($webform->toUrl(), ['textfield' => 'test value'], t('Submit')); + $assert->waitForElement('css', '.messages--status'); + /** @var \Drupal\webform\WebformSubmissionStorageInterface $storage */ + $storage = \Drupal::entityTypeManager()->getStorage('webform_submission'); + + $this->assertEquals($storage->getTotal($webform), 1); + } + + /** + * Test server-side validation of required fields. + * + * @todo Look into why client-side validation doesn't seem to kick in. + */ + public function testRequiredTextField() { + $webform = $this->provider->webformWithTextfield(['#required' => TRUE]); $webform->setSetting('confirmation_type', 'message'); $webform->save(); $assert = $this->assertSession(); + + $this->drupalPostForm($webform->toUrl(), ['textfield' => ''], t('Submit')); + $element = $assert->waitForElement('css', '.messages--error'); + + $this->assertNotNull($element, "An AJAX submission triggers server-side validation errors."); + $assert->pageTextContains('Textfield field is required.'); + } + + /** + * Test an AJAX submission with a page confirmation. + */ + public function testAjaxSubmissionPageConfirmation() { + $webform = $this->provider + ->webformWithTextfield() + ->setSetting('confirmation_type', 'page'); + $webform->save(); + $assert = $this->assertSession(); + $expected_path = Url::fromRoute('entity.webform.confirmation', ['webform' => $webform->id()]); + $this->drupalPostForm($webform->toUrl(), ['textfield' => 'test value'], t('Submit')); - $element = $assert->waitForElement('css', '.messages--status'); + $confirmation = $assert->waitForElement('css', '.webform-confirmation'); + $actual_path = parse_url($this->getUrl(), PHP_URL_PATH); - $this->assertNotNull($element, "An AJAX submission shows a confirmation message."); + $this->assertNotNull($confirmation, "An AJAX submission redirects to the confirmation page."); $assert->pageTextContains('New submission added to Test webform'); $assert->pageTextContains('test value'); - $assert->fieldValueNotEquals('textfield', 'test value'); + $this->assertEquals($actual_path, $expected_path->toString()); + + $this->clickLink('Back to form'); + $textfield = $assert->waitForField('Textfield'); + $this->assertNotNull($textfield, "Clicking the 'Back to form' link on the confirmation page after an AJAX submission loads the form."); } /** - * A basic test of an AJAX submission with inline confirmation. + * Test an AJAX submission with inline confirmation. */ public function testAjaxSubmissionInlineConfirmation() { // For now use the test_element_text webform as an easy check. @@ -61,87 +129,93 @@ $this->assertNotNull($element, "An AJAX submission shows an inline confirmation message."); $assert->pageTextContains('New submission added to Test webform'); $assert->pageTextContains('test value'); + + $this->clickLink('Back to form'); + $textfield = $assert->waitForField('Textfield'); + $this->assertNotNull($textfield, "Clicking the 'Back to form' link on the confirmation page after an AJAX submission loads the form."); } /** - * A basic test of two AJAX submission with a message confirmation. + * Test an AJAX submission with a message confirmation. */ - public function testMultipleAjaxSubmissionMessageConfirmation() { + public function testAjaxSubmissionMessageConfirmation() { $webform = $this->provider->webformWithTextfield(); $webform->setSetting('confirmation_type', 'message'); $webform->save(); $assert = $this->assertSession(); - $this->drupalPostForm($webform->toUrl(), ['textfield' => 'test value'], t('Submit')); - $assert->waitForElement('css', '.messages--status'); - $this->drupalPostForm(NULL, ['textfield' => 'test value 2'], t('Submit')); - // I'm not sure there's an element I can wait on. - $assert->assertWaitOnAjaxRequest(); $element = $assert->waitForElement('css', '.messages--status'); $this->assertNotNull($element, "An AJAX submission shows a confirmation message."); $assert->pageTextContains('New submission added to Test webform'); - $assert->pageTextContains('test value 2'); + $assert->pageTextContains('test value'); + $assert->fieldValueNotEquals('textfield', 'test value'); } /** - * Test AJAX webform submission storage. - * - * @see \Drupal\webform\Tests\WebformSubmissionStorageTest::testSubmissionStorage() + * Test an AJAX submission with a URL confirmation. */ - public function testAjaxSubmissionStorage() { - $webform = $this->provider->webformWithTextfield(); - $webform->setSetting('confirmation_type', 'message'); + public function testAjaxSubmissionUrlConfirmation() { + $webform = $this->provider + ->webformWithTextfield() + ->setSetting('confirmation_type', 'url') + ->setSetting('confirmation_url', ''); $webform->save(); $assert = $this->assertSession(); $this->drupalPostForm($webform->toUrl(), ['textfield' => 'test value'], t('Submit')); - $assert->waitForElement('css', '.messages--status'); - /** @var \Drupal\webform\WebformSubmissionStorageInterface $storage */ - $storage = \Drupal::entityTypeManager()->getStorage('webform_submission'); + $element = $assert->waitForElement('css', '.messages--warning'); + $actual_path = parse_url($this->getUrl(), PHP_URL_PATH); - $this->assertEquals($storage->getTotal($webform), 1); + $this->assertNotNull($element, "An AJAX submission redirects to a custom URL."); + $assert->pageTextContains('test value'); + $this->assertEquals($actual_path, '/'); } /** - * Test server-side validation of required fields. - * - * @todo Look into why client-side validation doesn't seem to kick in. + * Test an AJAX submission with a URL confirmation. */ - public function testRequiredTextField() { - $webform = $this->provider->webformWithTextfield(['#required' => TRUE]); - $webform->setSetting('confirmation_type', 'message'); + public function testAjaxSubmissionUrlMessageConfirmation() { + $webform = $this->provider + ->webformWithTextfield() + ->setSetting('confirmation_type', 'url_message') + ->setSetting('confirmation_url', ''); $webform->save(); $assert = $this->assertSession(); - $this->drupalPostForm($webform->toUrl(), ['textfield' => ''], t('Submit')); - $element = $assert->waitForElement('css', '.messages--error'); + $this->drupalPostForm($webform->toUrl(), ['textfield' => 'test value'], t('Submit')); + $element = $assert->waitForElement('css', '.messages--status'); + $actual_path = parse_url($this->getUrl(), PHP_URL_PATH); - $this->assertNotNull($element, "An AJAX submission triggers server-side validation errors."); - $assert->pageTextContains('Textfield field is required.'); + $this->assertNotNull($element, "An AJAX submission redirects to a custom URL."); + $assert->pageTextContains('New submission added to Test webform'); + $assert->pageTextContains('test value'); + $this->assertEquals($actual_path, '/'); } /** - * Test that messages from submissions replace previous ones. + * Test two AJAX submission with a message confirmation. */ - public function testMessages() { + public function testMultipleAjaxSubmissionMessageConfirmation() { $webform = $this->provider->webformWithTextfield(); $webform->setSetting('confirmation_type', 'message'); $webform->save(); $assert = $this->assertSession(); $this->drupalPostForm($webform->toUrl(), ['textfield' => 'test value'], t('Submit')); - $assert->waitForElement('css', '.messages--warning'); + $assert->waitForElement('css', '.messages--status'); $this->drupalPostForm(NULL, ['textfield' => 'test value 2'], t('Submit')); + // I'm not sure there's an element I can wait on. $assert->assertWaitOnAjaxRequest(); - $element = $assert->waitForElement('css', '.messages--warning'); + $element = $assert->waitForElement('css', '.messages--status'); $this->assertNotNull($element, "An AJAX submission shows a confirmation message."); - $assert->elementsCount('css', '.messages--warning', 1); + $assert->pageTextContains('New submission added to Test webform'); + $assert->pageTextContains('test value 2'); } /** - * Test a simple two page form submits on both pages. + * Test a two page form submits on both pages. */ public function testMultiPageForms() { $webform = $this->provider->multipageWebformWithTextfields(); @@ -163,42 +237,25 @@ $assert->pageTextContains('Textfield Page 1'); $assert->pageTextNotContains('Textfield Page 2'); } - + /** - * Test the back link. - * - * @todo: Incorpate this into another test. + * Test that messages from submissions replace previous ones. */ - public function testBackLink() { - // For now use the test_element_text webform as an easy check. + public function testMessages() { $webform = $this->provider->webformWithTextfield(); - $webform->setSetting('confirmation_type', 'inline'); + $webform->setSetting('confirmation_type', 'message'); $webform->save(); $assert = $this->assertSession(); $this->drupalPostForm($webform->toUrl(), ['textfield' => 'test value'], t('Submit')); - $back_link = $assert->waitForLink('Back to form'); - - $this->assertNotNull($back_link, "An AJAX submission confirmation shows a 'Back to form' link."); - $back_link->click(); - $submit = $assert->waitForButton('Submit'); - $this->assertNotNull($submit, "The 'Back to form' link causes the form to display."); - $assert->pageTextNotContains('New submission added to Test webform'); - } + $assert->waitForElement('css', '.messages--warning'); + $this->drupalPostForm(NULL, ['textfield' => 'test value 2'], t('Submit')); + $assert->assertWaitOnAjaxRequest(); + $element = $assert->waitForElement('css', '.messages--warning'); - /** - * {@inheritdoc} - */ - protected function setUp() { - parent::setUp(); - $this->provider = new WebformAjaxProvider(); + $this->assertNotNull($element, "An AJAX submission shows a confirmation message."); + $assert->elementsCount('css', '.messages--warning', 1); } - - /** - * @var \Drupal\Tests\webform_ajax\FunctionalJavascript\WebformProvider $provider - */ - protected $provider; - } /** diff -u b/modules/webform_ajax/webform_ajax.libraries.yml b/modules/webform_ajax/webform_ajax.libraries.yml --- b/modules/webform_ajax/webform_ajax.libraries.yml +++ b/modules/webform_ajax/webform_ajax.libraries.yml @@ -6,2 +6,3 @@ - core/jquery + - core/jquery.once - core/drupalSettings \ No newline at end of file diff -u b/modules/webform_ajax/webform_ajax.module b/modules/webform_ajax/webform_ajax.module --- b/modules/webform_ajax/webform_ajax.module +++ b/modules/webform_ajax/webform_ajax.module @@ -4,31 +4,33 @@ * @file * Provides AJAX submission for webforms. * - * Questions: - * - Is there any point in allowing AJAX submissions of forms with a redirect - * confirmation? - * - * @todo Add more tests for server-side validation. + * @todo Add draft support + * @todo Require webform-beta13 when it's released. See https://www.drupal.org/node/2757491#comment-12080296 + * @todo Use data attributes to replace messages. * @todo Check the implementation against the reference on #2757491 and * https://api.drupal.org/api/drupal/core%21core.api.php/group/ajax/8.3.x - * @todo Remove inappropriate confirmation options. - * @todo Expand test coverage: - * - multiple step forms with preview and save draft enabled * @todo I think I saw that webform automatically loads from MODULE.webform.inc. - * @todo Scroll up on submission. + * @todo Scroll up on submission (see \Drupal\views\Ajax\ScrollTopCommand). + * @todo Make the insert/replace effect configurable. */ use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Ajax\BeforeCommand; +use Drupal\Core\Ajax\InvokeCommand; +use Drupal\Core\Ajax\RedirectCommand; use Drupal\Core\Ajax\RemoveCommand; use Drupal\Core\Ajax\ReplaceCommand; +use Drupal\Core\EventSubscriber\MainContentViewSubscriber; +use Drupal\Core\Form\FormBuilderInterface; use Drupal\Core\Form\FormState; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\Element\StatusMessages; use Drupal\Core\Url; +use Drupal\webform\Entity\Webform; use Drupal\webform\WebformInterface; use Drupal\webform\WebformMessageManagerInterface; use Drupal\webform\WebformSubmissionInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; /** * The prefix to use for the AJAX wrapper ID. @@ -48,13 +50,6 @@ return; } - // AJAX submissions only make sense with certain kinds of confirmation. - // @todo Disable the AJAX setting in the UI based on the confirmation type. - $confirmation_type = $webform->getSetting('confirmation_type'); - if (!in_array($confirmation_type, ['message', 'inline'], TRUE)) { - return; - } - $form = _webform_ajax_wrap_element($form, WEBFORM_AJAX_PREFIX . $form_id); $element_id = WEBFORM_AJAX_PREFIX . $form_id; // Adjust the form to use ajax submit. @@ -74,7 +69,7 @@ * Ajax callback for webform. */ function webform_ajax_callback($form, FormStateInterface &$form_state) { - /** @var \Drupal\Core\Entity\ContentEntityFormInterface $form_object */ + /** @var \Drupal\webform\WebformSubmissionForm $form_object */ $form_object = $form_state->getFormObject(); /** @var \Drupal\webform\WebformSubmissionInterface $submission */ $submission = $form_object->getEntity(); @@ -82,30 +77,14 @@ $element_id = WEBFORM_AJAX_PREFIX . $form_object->getFormId(); $prefix = '#' . $element_id; - $response = new AjaxResponse(); - $output = []; - if ($submission->getState() == WebformSubmissionInterface::STATE_COMPLETED) { - $confirmation_type = $webform->getSetting('confirmation_type'); - if ($confirmation_type === 'message') { - // Currently the message set by webform itself is being squashed by - // \Drupal\webform\WebformMessageManager::display() with an explicit - // AJAX check.. - // @todo: Neaten this up - _webform_display_confirmation_message($submission); - $output = _webform_ajax_get_fresh_submission_form($webform); - } - elseif ($confirmation_type === 'inline') { - $confirmation = webform_ajax_confirmation_message($submission, $element_id); - // Add the same wrapper as the form (messages go above it, and it gets - // replaced when clicking the back link). - $output = _webform_ajax_wrap_element($confirmation, $element_id); - } + if ($submission->getState() == WebformSubmissionInterface::STATE_COMPLETED) { + list($response, $redirect) = _webform_ajax_form_completed($webform, $submission, $element_id, $form_state); } else { // Display the incomplete form. - $output = $form; + $response = new AjaxResponse(); + $response->addCommand(new ReplaceCommand($prefix, $form)); } - $response->addCommand(new ReplaceCommand($prefix, $output)); // As far as I know there's no JS command for displaying status messages. // - clear our messages every submission; @@ -114,17 +93,117 @@ $message_wrapper_id = $element_id . '-status-messages'; // Clear out any old confirmation messages... $response->addCommand(new RemoveCommand('#' . $message_wrapper_id)); - // ...and add in any new ones. - if (drupal_get_messages(NULL, FALSE)) { + // ...and add in any new ones as long as we're not issuing an immediate + // redirect. + if (empty($redirect) && drupal_get_messages(NULL, FALSE)) { $messages = StatusMessages::renderMessages(NULL); $messages = _webform_ajax_wrap_element($messages, $message_wrapper_id); - $response->addCommand(new BeforeCommand('#' . $element_id, $messages)); + $response->addCommand(new BeforeCommand($prefix, $messages)); } return $response; } /** + * Respond to a webform submission being completed. + * + * @param \Drupal\webform\WebformInterface $webform + * The webform being submitted. + * @param \Drupal\webform\WebformSubmissionInterface $submission + * The submission itself. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * An indexed array with the following elements: + * - The response object for the form completion; + * - A boolean indicating whether the response is a redirect. + * + * @see webform_ajax_callback() + */ +function _webform_ajax_form_completed(WebformInterface $webform, WebformSubmissionInterface $submission, $element_id, FormStateInterface $form_state) { + $element_selector = '#' . $element_id; + $response = new AjaxResponse(); + $type = $webform->getSetting('confirmation_type'); + $type = $type ? $type : Webform::getDefaultSettings()['confirmation_type']; + $redirect = FALSE; + + if (in_array($type, ['url', 'url_message', 'page']) && $url = _webform_ajax_get_form_state_redirect_url($form_state)) { + if ($type == 'url_message') { + // Currently the message set by webform itself is being squashed by + // \Drupal\webform\WebformMessageManager::display() with an explicit + // AJAX check.. + // @todo: Neaten this up + _webform_display_confirmation_message($submission); + } + // Send two AJAX commands: + // The first disables the form's buttons, to avoid extra click while waiting + // for redirect. The second is a redirect command, giving the browser the + // URL to go to next. + $response->addCommand(new InvokeCommand($element_selector . ' input.form-submit', 'attr', array ('disabled', 'disabled'))); + $response->addCommand(new RedirectCommand($url)); + $redirect = TRUE; + } + elseif ($type == 'inline') { + $confirmation = webform_ajax_confirmation_message($submission, $element_id); + // Add the same wrapper as the form (messages go above it, and it gets + // replaced when clicking the back link). + $output = _webform_ajax_wrap_element($confirmation, $element_id); + $response->addCommand(new ReplaceCommand($element_selector, $output)); + + } + else { + if ($type == 'message') { + // Currently the message set by webform itself is being squashed by + // \Drupal\webform\WebformMessageManager::display() with an explicit + // AJAX check.. + // @todo: Neaten this up + _webform_display_confirmation_message($submission); + } + $output = _webform_ajax_get_fresh_submission_form($webform); + $response->addCommand(new ReplaceCommand($element_selector, $output)); + } + + return [$response, $redirect]; +} + +/** + * Return any redirect set on the form state. + * + * It checks both the form state response and redirect. This is a hack that I'm + * using until I know where this code will live permanently (standalone or + * webform). + * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state object. + * + * @return string|false + * The redirect URL or FALSE if there isn't one. + * + * @todo Clean this up (wait until we know if it's going into webform) + */ +function _webform_ajax_get_form_state_redirect_url(FormStateInterface $form_state) { + // Hack: I can't get the redirect URL from the form state during an AJAX + // submission. So re-enable redirect, grab the URL, and then disable again. I + // think the neat way to do this depends on whether it remains a standalone + // module or if it gets folded into the main module. + $was_redirect_disabled = $form_state->isRedirectDisabled(); + $form_state->disableRedirect(FALSE); + $redirect = $form_state->getRedirect(); + $form_state->disableRedirect($was_redirect_disabled); + if (!$redirect) { + $redirect = $form_state->getResponse(); + } + if ($redirect instanceof RedirectResponse) { + return $redirect->getTargetUrl(); + } + elseif ($redirect instanceof Url) { + return $redirect->setAbsolute()->toString(); + } + return FALSE; +} + +/** * Return a new webform submission form with no user input. * * It would be nice to use WebformInterface::getSubmissionForm() but that uses @@ -180,7 +259,7 @@ 'drupalSettings' => [ 'webform_ajax' => [ 'url' => $url->toString(), - 'webform' => $webform->get('id'), + 'webform' => $webform->id(), 'wrapper_id' => $element_id, ] ] @@ -196,11 +275,11 @@ } /** - * Implements hook_form_FORM_ID_alter() for webform_third_party_settings_form. + * Implements hook_webform_third_party_settings_form_alter(). * - * Add webform_ajax settings to an individual webform. + * Add webform AJAX settings to an individual webform. */ -function webform_ajax_form_webform_third_party_settings_form_alter(&$form, FormStateInterface $form_state) { +function webform_ajax_webform_third_party_settings_form_alter(&$form, FormStateInterface $form_state) { /** @var \Drupal\webform\WebformInterface $webform */ $webform = $form_state->getFormObject()->getEntity(); $settings = [ @@ -211,13 +290,52 @@ $settings['enabled'] = [ '#type' => 'checkbox', '#title' => t('Use AJAX'), - '#description' => t('Send this form using ajax.'), '#default_value' => $webform->getThirdPartySetting('webform_ajax', 'enabled', FALSE), ]; $form['third_party_settings']['webform_ajax'] = $settings; } /** + * Implements hook_preprocess_webform_confirmation(). + * + * Removes AJAX query parameters from the 'Back to form' link after an AJAX + * submission has caused a redirect. + * + * @todo Decide where this should live: + * - the webform submission could strip AJAX query parameters in + * \Drupal\webform\WebformSubmissionInterface::getSourceUrl(); + * - It could be folded into template_preprocess_webform_confirmation(); + * - If this remains as a standalone module the back link can remain here + * (ideally webform can be updated not to use string URLs). + */ +function webform_ajax_preprocess_webform_confirmation(&$variables) { + /** @var WebformInterface $webform */ + $webform = $variables['webform']; + if (!$webform->getThirdPartySetting('webform_ajax', 'enabled')) { + return; + } + if (!in_array($webform->getSetting('confirmation_type'), ['page', 'url', 'url_message'], TRUE)) { + return; + } + + /** @var WebformSubmissionInterface $webform_submission */ + $webform_submission = $variables['webform_submission']; + // @todo I think webform shouldn't be converting URLs to strings - that makes + // them more difficult to modify (and you can output \Drupal\Core\Url + // objects directly from Twig templates). For the time being get the URL + // from the submission rather than $variables. + $back_url = $webform_submission->getSourceUrl(); + $query = $back_url->getOption('query'); + unset($query[FormBuilderInterface::AJAX_FORM_REQUEST]); + $wrapper_format = MainContentViewSubscriber::WRAPPER_FORMAT; + if (!empty($query[$wrapper_format]) && $query[$wrapper_format] == 'drupal_ajax') { + unset($query[$wrapper_format]); + } + $back_url->setOption('query', $query); + $variables['back_url'] = $back_url->toString(); +} + +/** * Safely wrap a render array with an ID using #prefix and #suffix. * * @param array $element