diff --git a/modules/webform_ajax/config/schema/webform_ajax.schema.yml b/modules/webform_ajax/config/schema/webform_ajax.schema.yml new file mode 100644 index 0000000..6fc72f5 --- /dev/null +++ b/modules/webform_ajax/config/schema/webform_ajax.schema.yml @@ -0,0 +1,7 @@ +webform.settings.third_party.webform_ajax: + type: mapping + label: 'Webform Ajax' + mapping: + enabled: + type: boolean + label: 'AJAX submission' diff --git a/modules/webform_ajax/js/webform_ajax.js b/modules/webform_ajax/js/webform_ajax.js new file mode 100644 index 0000000..4a11133 --- /dev/null +++ b/modules/webform_ajax/js/webform_ajax.js @@ -0,0 +1,23 @@ +(function($) { + Drupal.behaviors.webform_ajax = { + attach : function(context, settings) { + var wrapper_id = settings.webform_ajax.wrapper_id; + var ajax_settings = { + url: settings.webform_ajax.url, + event: 'click', + progress: { + type: 'throbber' + } + }; + + // Bind Ajax behaviors to Webform confirmation screen's "Go back to form" + // link. + $(context) + .find('.webform-confirmation__back a') + .once('webform_ajax') + .each(function() { + Drupal.Ajax[wrapper_id] = new Drupal.Ajax(wrapper_id, this, ajax_settings); + }); + } + }; +}(jQuery)); diff --git a/modules/webform_ajax/src/Controller/WebformAjaxController.php b/modules/webform_ajax/src/Controller/WebformAjaxController.php new file mode 100644 index 0000000..04a352e --- /dev/null +++ b/modules/webform_ajax/src/Controller/WebformAjaxController.php @@ -0,0 +1,43 @@ +addCommand(new RemoveCommand("#$wrapper_id-status-messages")); + // 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); + } else { + $output = array ( + '#type' => 'markup', + '#markup' => t('The webform cannot be displayed.') + ); + } + + $response->addCommand(new ReplaceCommand('#' . $wrapper_id, $output)); + + return $response; + } +} \ No newline at end of file diff --git a/modules/webform_ajax/tests/src/FunctionalJavascript/WebformAjaxTest.php b/modules/webform_ajax/tests/src/FunctionalJavascript/WebformAjaxTest.php new file mode 100644 index 0000000..8f16967 --- /dev/null +++ b/modules/webform_ajax/tests/src/FunctionalJavascript/WebformAjaxTest.php @@ -0,0 +1,336 @@ +provider->webformWithTextfield(); + $webform->setSetting('confirmation_type', 'message'); + $webform->save(); + $path = $webform->toUrl()->getInternalPath(); + $assert = $this->assertSession(); + $this->drupalPostForm($path, ['textfield' => 'test value'], t('Submit')); + $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'); + $assert->fieldValueNotEquals('textfield', 'test value'); + } + + /** + * A basic test of an AJAX submission with inline confirmation. + */ + public function testAjaxSubmissionInlineConfirmation() { + // For now use the test_element_text webform as an easy check. + $webform = $this->provider->webformWithTextfield(); + $webform->setSetting('confirmation_type', 'inline'); + $webform->save(); + $path = $webform->toUrl()->getInternalPath(); + // @todo Use an API call to get the form ID. + $form_id = 'webform_submission_' . $webform->id() . '_form'; + // The inline confirmation message is wrapped in the same wrapper the form + // was. + // @todo Use an API to get the prefix. + $locator = "#webform-ajax-$form_id .webform-confirmation"; + $assert = $this->assertSession(); + + $this->drupalPostForm($path, ['textfield' => 'test value'], t('Submit')); + $element = $assert->waitForElement('css', $locator); + + $this->assertNotNull($element, "An AJAX submission shows an inline confirmation message."); + $assert->pageTextContains('New submission added to Test webform'); + $assert->pageTextContains('test value'); + } + + /** + * A basic test of two AJAX submission with a message confirmation. + */ + public function testMultipleAjaxSubmissionMessageConfirmation() { + $webform = $this->provider->webformWithTextfield(); + $webform->setSetting('confirmation_type', 'message'); + $webform->save(); + $path = $webform->toUrl()->getInternalPath(); + $assert = $this->assertSession(); + + $this->drupalPostForm($path, ['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'); + } + + /** + * 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(); + $path = $webform->toUrl()->getInternalPath(); + $assert = $this->assertSession(); + + $this->drupalPostForm($path, ['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(); + $path = $webform->toUrl()->getInternalPath(); + $assert = $this->assertSession(); + + $this->drupalPostForm($path, ['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 that messages from submissions replace previous ones. + */ + public function testMessages() { + $webform = $this->provider->webformWithTextfield(); + $webform->setSetting('confirmation_type', 'message'); + $webform->save(); + $path = $webform->toUrl()->getInternalPath(); + $assert = $this->assertSession(); + + $this->drupalPostForm($path, ['textfield' => 'test value'], t('Submit')); + $assert->waitForElement('css', '.messages--warning'); + $this->drupalPostForm(NULL, ['textfield' => 'test value 2'], t('Submit')); + $assert->assertWaitOnAjaxRequest(); + $element = $assert->waitForElement('css', '.messages--warning'); + + $this->assertNotNull($element, "An AJAX submission shows a confirmation message."); + $assert->elementsCount('css', '.messages--warning', 1); + } + + /** + * Test a simple two page form submits on both pages. + */ + public function testMultiPageForms() { + $webform = $this->provider->multipageWebformWithTextfields(); + $webform->setSetting('confirmation_type', 'message'); + $webform->save(); + $path = $webform->toUrl()->getInternalPath(); + $assert = $this->assertSession(); + + $this->drupalPostForm($path, ['textfield_1' => 'test value page 1'], t('Next Page >')); + $element = $assert->waitForButton('Submit'); + + $this->assertNotNull($element, "An AJAX submission on the first page of a multi-page form causes the next page to show"); + $assert->pageTextContains('test value page 1'); + + $this->drupalPostForm(NULL, ['textfield_2' => 'test value page 2'], t('Submit')); + $element = $assert->waitForElement('css', '.messages--status'); + + $this->assertNotNull($element, "An AJAX submission on the last page of a multi-page form shows the webform confirmation."); + $assert->pageTextContains('test value page 2'); + $assert->pageTextContains('Textfield Page 1'); + $assert->pageTextNotContains('Textfield Page 2'); + } + + /** + * Test the back link. + */ + public function testBackLink() { + // For now use the test_element_text webform as an easy check. + $webform = $this->provider->webformWithTextfield(); + $webform->setSetting('confirmation_type', 'inline'); + $webform->save(); + $path = $webform->toUrl()->getInternalPath(); + $assert = $this->assertSession(); + + $this->drupalPostForm($path, ['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'); + } + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + $this->provider = new WebformAjaxProvider(); + } + + /** + * @var \Drupal\Tests\webform_ajax\FunctionalJavascript\WebformProvider $provider + */ + protected $provider; + +} + +/** + * Helper object that provides webforms for tests. + * + * @todo: Move this to a sensible namespace (but where?) + * @todo: Add some helper methods for adding elements to a webform. I think it + * will be neater than FAPI arrays into ::createWebform(). + */ +class WebformProvider { + /** + * Create a webform with a textfield. + * + * The webform title is 'Test webform' and the field name is 'textfield'. The + * default textfield title is 'Textfield' but that can be overridden and + * further properties provided with $properties. + * + * @param array $properties + * Properties for the textfield; they will override any defaults. + * + * @return \Drupal\webform\WebformInterface + */ + public function webformWithTextfield($properties = []) { + return $this->createWebform([ + 'title' => 'Test webform', + 'elements' => [ + 'textfield' => $this->textfield($properties), + ] + ]); + } + + /** + * Create a two page webform with a textfield on each page. + * + * It uses the following structure. + * page_1 (Page 1): + * - textfield_1 (Textfield 1) + * page_2 (Page 2): + * - textfield_2 (Textfield 2) + * + * @return \Drupal\webform\WebformInterface + */ + public function multipageWebformWithTextfields() { + return $this->createWebform([ + 'title' => 'Test webform', + 'elements' => [ + 'page_1' => [ + '#type' => 'webform_wizard_page', + '#title' => 'Page 1', + 'textfield_1' => $this->textfield(['#title' => 'Textfield Page 1']), + ], + 'page_2' => [ + '#type' => 'webform_wizard_page', + '#title' => 'Page 2', + 'textfield_2' => $this->textfield(['#title' => 'Textfield Page 2']), + ] + ] + ]); + } + + /** + * Create a textfield FAPI array, optionally with custom properties. + * + * By default the title is 'Textfield'. + * + * @param array $properties + * Custom properties that take precedence over the defaults. + * + * @return array + * The FAPI array + */ + public function textfield($properties = []) { + return $properties + [ + '#type' => 'textfield', + '#title' => 'Textfield', + ]; + } + + /** + * Create a new webform entity. + * + * @param array $values + * The initial values to pass to the Webform constructor; if you set the + * settings key directly here then no defaults will be provided (not + * recommended). + * + * @return \Drupal\webform\WebformInterface + */ + public function createWebform($values = []) { + $values += [ + 'handlers' => [ + 'debug' => [ + 'id' => 'debug', + 'label' => 'Debug', + 'handler_id' => 'debug', + 'status' => TRUE, + 'weight' => 1, + 'settings' => [], + ] + ] + ]; + + if (empty($values['id'])) { + $random = new Random(); + $values['id'] = $random->word(8); + } + $storage = \Drupal::entityTypeManager()->getStorage('webform'); + /** @var \Drupal\webform\WebformInterface $webform */ + $webform = $storage->create($values); + return $webform; + } +} + +/** + * Helper object that provides webforms for AJAX tests. + * + * @todo Move this somewhere sensible as well. + */ +class WebformAjaxProvider extends WebformProvider { + /** + * {@inheritdoc} + */ + public function createWebform($values = []) { + $webform = parent::createWebform($values); + $webform->setThirdPartySetting('webform_ajax', 'enabled', TRUE); + return $webform; + } +} diff --git a/modules/webform_ajax/webform_ajax.info.yml b/modules/webform_ajax/webform_ajax.info.yml new file mode 100644 index 0000000..1405acf --- /dev/null +++ b/modules/webform_ajax/webform_ajax.info.yml @@ -0,0 +1,6 @@ +name: Webform AJAX +description: Adds AJAX support to Webforms +type: module +core: 8.x +dependencies: + - webform:webform (>=8.x-5.x) diff --git a/modules/webform_ajax/webform_ajax.libraries.yml b/modules/webform_ajax/webform_ajax.libraries.yml new file mode 100644 index 0000000..665bd2c --- /dev/null +++ b/modules/webform_ajax/webform_ajax.libraries.yml @@ -0,0 +1,7 @@ +webform_ajax: + version: 1.x + js: + js/webform_ajax.js: {} + dependencies: + - core/jquery + - core/drupalSettings \ No newline at end of file diff --git a/modules/webform_ajax/webform_ajax.module b/modules/webform_ajax/webform_ajax.module new file mode 100644 index 0000000..20ca274 --- /dev/null +++ b/modules/webform_ajax/webform_ajax.module @@ -0,0 +1,271 @@ +getFormObject()->getEntity()->getWebform(); + + if (!$webform->getThirdPartySetting('webform_ajax', 'enabled')) { + 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'])) { + 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. + // @todo: Add draft. + foreach (['previous', 'next', 'submit'] as $button) { + if (!empty($form['actions'][$button])) { + $form['actions'][$button]['#ajax'] = array( + 'callback' => 'webform_ajax_callback', + 'wrapper' => $element_id, + 'effect' => 'fade', + ); + } + } +} + +/** + * Ajax callback for webform. + */ +function webform_ajax_callback($form, FormStateInterface &$form_state) { + /** @var \Drupal\Core\Entity\ContentEntityFormInterface $form_object */ + $form_object = $form_state->getFormObject(); + /** @var \Drupal\webform\WebformSubmissionInterface $submission */ + $submission = $form_object->getEntity(); + $webform = $submission->getWebform(); + $element_id = WEBFORM_AJAX_PREFIX . $form_object->getFormId(); + $prefix = '#' . $element_id; + + $response = new AjaxResponse(); + $output = []; + $confirmation_type = $webform->getSetting('confirmation_type'); + if ($submission->getState() == WebformSubmissionInterface::STATE_COMPLETED) { + switch ($confirmation_type) { + case '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); + break; + + case '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); + break; + } + } + else { + // Display the incomplete form. + $output = $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; + // - add the messages before the form and/or confirmation message; + // - replace the form as usual. + $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)) { + $messages = StatusMessages::renderMessages(NULL); + $messages = _webform_ajax_wrap_element($messages, $message_wrapper_id); + $response->addCommand(new BeforeCommand('#' . $element_id, $messages)); + } + + return $response; +} + +/** + * Return a new webform submission form with no user input. + * + * It would be nice to use WebformInterface::getSubmissionForm() but that uses + * the input from the current request to populate the form. + * + * @param \Drupal\webform\WebformInterface $webform + * The webform to make a submission form from. + * + * @return array + * The built form array for the submission form. + */ +function _webform_ajax_get_fresh_submission_form(WebformInterface $webform) { + $submission = \Drupal::entityTypeManager() + ->getStorage('webform_submission') + ->create(['webform_id' => $webform->id()]); + $form_object = \Drupal::entityTypeManager() + ->getFormObject('webform_submission', 'default'); + $form_object->setEntity($submission); + $form_state = (new FormState())->setFormState([]); + // Explicitly set the user input to nothing, so the form is built on + // that, rather than the request's input. + $form_state->setUserInput([]); + /** @var \Drupal\Core\Form\FormBuilderInterface $form_builder */ + $form_builder = \Drupal::service('form_builder'); + return $form_builder->buildForm($form_object, $form_state); +} + +/** + * Returns a render array for a submission's confirmation message. + * + * @param \Drupal\webform\WebformSubmissionInterface $submission + * The webform submission + * + * @return array + * The confirmation message render array + */ +function webform_ajax_confirmation_message(WebformSubmissionInterface $submission, $element_id) { + $webform = $submission->getWebform(); + /** @var \Drupal\webform\WebformMessageManagerInterface $message_manager */ + $source_entity = $submission->getSourceEntity(); + $url = Url::fromRoute('webform_ajax.return_webform', [ + 'webform' => $webform->id(), + 'wrapper_id' => $element_id + ]); + $output = [ + '#theme' => 'webform_confirmation__ajax', + '#webform' => $webform, + '#source_entity' => $source_entity, + '#webform_submission' => $submission, + '#attached' => [ + 'library' => [ + 'webform_ajax/webform_ajax' + ], + 'drupalSettings' => [ + 'webform_ajax' => [ + 'url' => $url->toString(), + 'webform' => $webform->get('id'), + 'wrapper_id' => $element_id, + ] + ] + ] + ]; + + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); + $renderer->addCacheableDependency($output, $webform); + $renderer->addCacheableDependency($output, $source_entity); + + return $output; +} + +/** + * Implements hook_form_FORM_ID_alter() for webform_third_party_settings_form. + * + * Add webform_ajax settings to an individual webform. + */ +function webform_ajax_form_webform_third_party_settings_form_alter(&$form, FormStateInterface $form_state) { + /** @var \Drupal\webform\WebformInterface $webform */ + $webform = $form_state->getFormObject()->getEntity(); + $settings = [ + '#type' => 'details', + '#title' => t('Webform AJAX'), + '#open' => TRUE, + ]; + $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; +} + +/** + * Safely wrap a render array with an ID using #prefix and #suffix. + * + * @param array $element + * The render array to wrap. + * @param $id + * The ID of the wrapping element. + * + * @return array + * The new render array. + */ +function _webform_ajax_wrap_element($element, $id) { + foreach (['#prefix', '#suffix'] as $property) { + if (!isset($element[$property])) { + $element[$property] = ''; + } + } + $element['#prefix'] = '
' . $element['#prefix']; + $element['#suffix'] .= '
'; + return $element; +} + +/** + * Display the submission confirmation message. + * + * This is ugly - I'm duplicating code from WebformMessageManager::display() + * to avoid the AJAX check. + * + * @param \Drupal\webform\WebformSubmissionInterface $submission + * The submission being confirmed. + * + * @see \Drupal\webform\WebformMessageManager::display() + */ +function _webform_display_confirmation_message(WebformSubmissionInterface $submission) { + /** @var \Drupal\webform\WebformMessageManagerInterface $message_manager */ + $message_manager = \Drupal::service('webform.message_manager'); + $message_manager->setWebform($submission->getWebform()); + $message_manager->setSourceEntity($submission->getSourceEntity()); + $message_manager->setWebformSubmission($submission); + if (!$build = $message_manager->build(WebformMessageManagerInterface::SUBMISSION_CONFIRMATION)) { + $build = $message_manager->build(WebformMessageManagerInterface::SUBMISSION_DEFAULT_CONFIRMATION); + } + if ($build) { + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); + drupal_set_message($renderer->renderPlain($build), 'status'); + } +} diff --git a/modules/webform_ajax/webform_ajax.routing.yml b/modules/webform_ajax/webform_ajax.routing.yml new file mode 100644 index 0000000..235cfdd --- /dev/null +++ b/modules/webform_ajax/webform_ajax.routing.yml @@ -0,0 +1,6 @@ +webform_ajax.return_webform: + path: '/webform_ajax/return_webform/{webform}/{wrapper_id}' + defaults: + _controller: 'Drupal\webform_ajax\Controller\WebformAjaxController::return_webform' + requirements: + _entity_access: 'webform.submission_create' \ No newline at end of file diff --git a/webform.info.yml b/webform.info.yml index 705be15..8f2b93c 100644 --- a/webform.info.yml +++ b/webform.info.yml @@ -8,3 +8,5 @@ dependencies: - 'drupal:field' - 'drupal:system (>= 8.2)' - 'drupal:user' +test_dependencies: + - webform_ajax