Commit 49435a16 authored by jrockowitz's avatar jrockowitz Committed by jrockowitz

Issue #2887192 by jrockowitz: Add Ajax callbacks to manage Webform handlers

parent ba704020
/**
* @file
* Ajax styles
*/
/**
* Floating Ajax message container.
*
* Display status message in a floating container at the bottom of the page.
* NOTE: It is disiplay to display message floating at top because of the floating
* admin toolbar.
*
* @see Drupal.AjaxCommands.prototype.webformInsert
*/
.webform-ajax-messages {
position: fixed;
bottom: 0;
width: 100%;
}
.webform-ajax-messages .messages {
border-width: 10px 0 0 0;
margin: 0;
font-weight: bold;
}
.webform-ajax-messages .messages + .messages {
margin: 0;
}
......@@ -11,7 +11,7 @@
* Provide Webform Ajax link behavior.
*
* Display fullscreen progress indicator instead of throber.
* Copied from: Drupal.behaviors.AJAX
* Copied from: Drupal.behaviors.AJAX
*
* @type {Drupal~behavior}
*
......@@ -39,6 +39,7 @@
});
}
};
/**
* Provide Ajax callback for confirmation back to link.
*
......@@ -72,6 +73,70 @@
}
};
/****************************************************************************/
// Ajax commands.
/****************************************************************************/
/**
* Track the updated table row key.
*/
var updateKey;
/**
* Command to insert new content into the DOM.
*
* @param {Drupal.Ajax} ajax
* {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
* @param {object} response
* The response from the Ajax request.
* @param {string} response.data
* The data to use with the jQuery method.
* @param {string} [response.method]
* The jQuery DOM manipulation method to be used.
* @param {string} [response.selector]
* A optional jQuery selector string.
* @param {object} [response.settings]
* An optional array of settings that will be used.
* @param {number} [status]
* The XMLHttpRequest status.
*/
Drupal.AjaxCommands.prototype.webformInsert = function (ajax, response, status) {
// Insert the HTML.
this.insert(ajax, response, status);
// Scroll to and highlight the updated table row.
if (updateKey) {
var $element = $('tr[data-webform-key="' + updateKey + '"]');
// Highlight the updated element's row.
$element.addClass('color-success');
setTimeout(function() {$element.removeClass('color-success')}, 3000);
// Scroll to elements that are not visible.
if (!isScrolledIntoView($element)) {
$('html, body').animate({scrollTop: $element.offset().top - 140}, 500);
}
}
updateKey = null; // Reset element update.
// Display main page's status message in a floating container.
var $wrapper = $(response.selector);
if ($wrapper.parents('.ui-dialog').length === 0) {
var $messages = $wrapper.find('.messages');
if ($messages.length) {
var $floatingMessage = $('#webform-ajax-messages');
if ($floatingMessage.length === 0) {
$floatingMessage = $('<div id="webform-ajax-messages" class="webform-ajax-messages"></div>')
$('body').append($floatingMessage);
}
if ($floatingMessage.is(":animated")) {
$floatingMessage.stop(true, true);
}
$floatingMessage.html($messages).show().delay(3000).fadeOut(1000);
}
}
};
/**
* Scroll to top ajax command.
*
......@@ -103,4 +168,84 @@
}
};
/**
* Command to refresh the current webform page.
*
* @param {Drupal.Ajax} [ajax]
* {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
* @param {object} response
* The response from the Ajax request.
* @param {string} response.url
* The URL to redirect to.
* @param {number} [status]
* The XMLHttpRequest status.
*/
Drupal.AjaxCommands.prototype.webformRefresh = function (ajax, response, status) {
if (response.url.indexOf(window.location.pathname) !== -1 && $('.webform-ajax-refresh').length) {
updateKey = (response.url.match(/\?update=(.*)$/)) ? RegExp.$1 : null;
$('.webform-ajax-refresh').click();
}
else {
this.redirect(ajax, response, status);
}
};
/**
* Command to close a dialog.
*
* If no selector is given, it defaults to trying to close the modal.
*
* @param {Drupal.Ajax} [ajax]
* @param {object} response
* @param {string} response.selector
* @param {bool} response.persist
* @param {number} [status]
*/
Drupal.AjaxCommands.prototype.webformCloseDialog = function (ajax, response, status) {
if ($('#drupal-off-canvas').length) {
// Close off-canvas system tray which is not triggered by close dialog
// command.
// @see Drupal.behaviors.offCanvasEvents
$('#drupal-off-canvas').remove();
$('body').removeClass('js-tray-open');
// Remove all *.off-canvas events
$(document).off('.off-canvas');
$(window).off('.off-canvas');
var edge = document.documentElement.dir === 'rtl' ? 'left' : 'right';
var $mainCanvasWrapper = $('[data-off-canvas-main-canvas]');
$mainCanvasWrapper.css('padding-' + edge, 0);
}
else {
// https://stackoverflow.com/questions/15763909/jquery-ui-dialog-check-if-exists-by-instance-method
if ($(response.selector).hasClass('ui-dialog-content')) {
this.closeDialog(ajax, response, status);
}
}
};
/****************************************************************************/
// Helper functions.
/****************************************************************************/
/**
* Determine if element is visible in the viewport.
*
* @param element
* An element.
*
* @returns {boolean}
* TRUE if element is visible in the viewport.
*
* @see https://stackoverflow.com/questions/487073/check-if-element-is-visible-after-scrolling
*/
function isScrolledIntoView(element) {
var docViewTop = $(window).scrollTop();
var docViewBottom = docViewTop + $(window).height();
var elemTop = $(element).offset().top;
var elemBottom = elemTop + $(element).height();
return ((elemBottom <= docViewBottom) && (elemTop >= docViewTop));
}
})(jQuery, Drupal);
......@@ -87,10 +87,22 @@
options = $.extend(options, Drupal.webform.htmlEditor.options);
CKEDITOR.replace(this.id, options).on('change', function (evt) {
// Save data onchange since Ajax dialogs don't execute form.onsubmit.
$textarea.val(evt.editor.getData().trim());
});
// Catch and suppress
// "Uncaught TypeError: Cannot read property 'getEditor' of undefined".
//
// Steps to reproduce this error.
// - Goto any form elements.
// - Edit an element.
// - Save the element.
try {
CKEDITOR.replace(this.id, options).on('change', function (evt) {
// Save data onchange since Ajax dialogs don't execute form.onsubmit.
$textarea.val(evt.editor.getData().trim());
});
}
catch (e) {
// Do nothing.
}
});
}
};
......
......@@ -7,42 +7,6 @@
'use strict';
/**
* Highlights the element that was just updated.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the behavior for the element update.
*
* @see Drupal.behaviors.blockHighlightPlacement
*/
Drupal.behaviors.webformUiElementsUpdate = {
attach: function (context, settings) {
if (settings.webformUiElementUpdate) {
$(context).find('[data-drupal-selector="edit-webform-ui-elements"]').once('webform-ui-elements-update').each(function () {
var $container = $(this);
// If the element is visible, don't scroll to it.
// @see http://stackoverflow.com/questions/487073/check-if-element-is-visible-after-scrolling;
var $element = $('.js-webform-ui-element-update');
var elementTop = $element.offset().top;
var elementBottom = elementTop + $element.height();
var isVisible = (elementTop >= 0) && (elementBottom <= window.innerHeight);
if (isVisible) {
return;
}
// Just scrolling the document.body will not work in Firefox. The html
// element is needed as well.
$('html, body').animate({
scrollTop: $('.js-webform-ui-element-update').offset().top - $container.offset().top + $container.scrollTop()
}, 500);
});
}
}
};
/**
* Lock the default actions element by moving it to the table footer (<tfoot>).
*
......
......@@ -349,7 +349,7 @@ abstract class WebformUiElementFormBase extends FormBase implements WebformUiEle
];
drupal_set_message($this->t('%title has been @action.', $t_args));
$form_state->setRedirectUrl($this->webform->toUrl('edit-form', ['query' => ['element-update' => $this->key]]));
$form_state->setRedirectUrl($this->webform->toUrl('edit-form', ['query' => ['update' => $this->key]]));
}
......
......@@ -96,21 +96,15 @@ class WebformUiElementTest extends WebformTestBase {
// Create element.
$this->drupalPostForm('admin/structure/webform/manage/contact/element/add/textfield', ['key' => 'test', 'properties[title]' => 'Test'], t('Save'));
// Check elements URL contains ?element-update query string parameter.
$this->assertUrl('admin/structure/webform/manage/contact', ['query' => ['element-update' => 'test']]);
// Check elements URL contains ?update query string parameter.
$this->assertUrl('admin/structure/webform/manage/contact', ['query' => ['update' => 'test']]);
// Check elements element-update class exists.
$this->assertRaw('color-success js-webform-ui-element-update');
// Check that save elements removes ?element-update query string parameter.
// Check that save elements removes ?update query string parameter.
$this->drupalPostForm(NULL, [], t('Save elements'));
// Check that save elements removes ?element-update query string parameter.
// Check that save elements removes ?update query string parameter.
$this->assertUrl('admin/structure/webform/manage/contact');
// Check that save elements removes element-update class.
$this->assertNoRaw('color-success js-webform-ui-element-update');
// Create validate unique element.
$this->drupalPostForm('admin/structure/webform/manage/contact/element/add/textfield', ['key' => 'test', 'properties[title]' => 'Test'], t('Save'));
$this->assertRaw('The element key is already in use. It must be unique.');
......@@ -123,11 +117,8 @@ class WebformUiElementTest extends WebformTestBase {
// Update element.
$this->drupalPostForm('admin/structure/webform/manage/contact/element/test/edit', ['properties[title]' => 'Test 123', 'properties[default_value]' => 'This is a default value'], t('Save'));
// Check elements URL contains ?element-update query string parameter.
$this->assertUrl('admin/structure/webform/manage/contact', ['query' => ['element-update' => 'test']]);
// Check elements element-update class exists.
$this->assertRaw('color-success js-webform-ui-element-update');
// Check elements URL contains ?update query string parameter.
$this->assertUrl('admin/structure/webform/manage/contact', ['query' => ['update' => 'test']]);
// Check element updated.
$this->drupalGet('webform/contact');
......
......@@ -7,6 +7,7 @@ use Drupal\Component\Utility\Unicode;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Url;
use Drupal\webform\Form\WebformEntityAjaxFormTrait;
use Drupal\webform\Utility\WebformDialogHelper;
use Drupal\webform\WebformEntityForm;
......@@ -15,6 +16,8 @@ use Drupal\webform\WebformEntityForm;
*/
class WebformUiEntityForm extends WebformEntityForm {
use WebformEntityAjaxFormTrait;
/**
* {@inheritdoc}
*/
......@@ -26,13 +29,6 @@ class WebformUiEntityForm extends WebformEntityForm {
return $form;
}
// Track which element has been updated.
$element_update = FALSE;
if ($this->getRequest()->query->has('element-update')) {
$element_update = $this->getRequest()->query->get('element-update');
$form['#attached']['drupalSettings']['webformUiElementUpdate'] = $element_update;
}
$header = $this->getTableHeader();
// Build table rows for elements.
......@@ -40,7 +36,7 @@ class WebformUiEntityForm extends WebformEntityForm {
$elements = $this->getOrderableElements();
$delta = count($elements);
foreach ($elements as $element) {
$rows[$element['#webform_key']] = $this->getElementRow($element, $element_update, $delta);
$rows[$element['#webform_key']] = $this->getElementRow($element, $delta);
}
// Must manually add local actions to the webform because we can't alter local
......@@ -179,10 +175,11 @@ class WebformUiEntityForm extends WebformEntityForm {
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions = parent::actions($form, $form_state);
$actions['submit']['#value'] = ($this->entity->isNew()) ? $this->t('Save') : $this->t('Save elements');
return $actions;
protected function actionsElement(array $form, FormStateInterface $form_state) {
$form = parent::actionsElement($form, $form_state);
$form['submit']['#value'] = ($this->entity->isNew()) ? $this->t('Save') : $this->t('Save elements');
unset($form['delete']);
return $form;
}
/**
......@@ -271,7 +268,7 @@ class WebformUiEntityForm extends WebformEntityForm {
'class' => [RESPONSIVE_PRIORITY_MEDIUM, 'webform-ui-element-operations'],
];
}
if (!$this->isDialog()) {
if (!$this->isQuickEdit()) {
$header['key'] = [
'data' => $this->t('Key'),
'class' => [RESPONSIVE_PRIORITY_LOW],
......@@ -293,7 +290,7 @@ class WebformUiEntityForm extends WebformEntityForm {
}
$header['weight'] = $this->t('Weight');
$header['parent'] = $this->t('Parent');
if (!$this->isDialog()) {
if (!$this->isQuickEdit()) {
$header['operations'] = [
'data' => $this->t('Operations'),
'class' => ['webform-ui-element-operations'],
......@@ -307,19 +304,19 @@ class WebformUiEntityForm extends WebformEntityForm {
*
* @param array $element
* Webform element.
* @param bool|string $element_update
* The name of the element being updated or FALSE if none.
* @param int $delta
* The number of elements. @todo is this correct?
*
* @return array
* The row for the element.
*/
protected function getElementRow(array $element, $element_update, $delta) {
protected function getElementRow(array $element, $delta) {
/** @var \Drupal\webform\WebformInterface $webform */
$webform = $this->getEntity();
$row = [];
$element_dialog_attributes = WebformDialogHelper::getModalDialogAttributes(800);
$key = $element['#webform_key'];
......@@ -355,12 +352,8 @@ class WebformUiEntityForm extends WebformEntityForm {
$row_class[] = 'webform-ui-element-container';
}
// Add classes to updated element.
// @see Drupal.behaviors.webformUiElementsUpdate
if ($element_update && $element_update == $element['#webform_key']) {
$row_class[] = 'color-success';
$row_class[] = 'js-webform-ui-element-update';
}
// Add element key.
$row['#attributes']['data-webform-key'] = $element['#webform_key'];
$row['#attributes']['class'] = $row_class;
......@@ -400,7 +393,7 @@ class WebformUiEntityForm extends WebformEntityForm {
$row['add'] = ['#markup' => ''];
}
}
if (!$this->isDialog()) {
if (!$this->isQuickEdit()) {
$row['name'] = [
'#markup' => $element['#webform_key'],
];
......@@ -458,7 +451,7 @@ class WebformUiEntityForm extends WebformEntityForm {
],
];
if (!$this->isDialog()) {
if (!$this->isQuickEdit()) {
$row['operations'] = [
'#type' => 'operations',
];
......@@ -528,7 +521,7 @@ class WebformUiEntityForm extends WebformEntityForm {
if ($webform->hasContainer()) {
$row['add'] = ['#markup' => ''];
}
if (!$this->isDialog()) {
if (!$this->isQuickEdit()) {
$row['name'] = ['#markup' => 'actions'];
$row['type'] = [
'#markup' => $this->t('Submit button(s)'),
......@@ -540,7 +533,7 @@ class WebformUiEntityForm extends WebformEntityForm {
}
$row['weight'] = ['#markup' => ''];
$row['parent'] = ['#markup' => ''];
if (!$this->isDialog()) {
if (!$this->isQuickEdit()) {
$row['operations'] = [
'#type' => 'operations',
];
......
<?php
namespace Drupal\webform\Ajax;
use Drupal\Core\Ajax\CloseDialogCommand;
/**
* Provides an Ajax command for closing webform dialog and system tray.
*
* This command is implemented in Drupal.AjaxCommands.prototype.webformCloseDialog.
*/
class WebformCloseDialogCommand extends CloseDialogCommand {
/**
* {@inheritdoc}
*/
public function render() {
return [
'command' => 'webformCloseDialog',
'selector' => $this->selector,
'persist' => $this->persist,
];
}
}
<?php
namespace Drupal\webform\Ajax;
use Drupal\Core\Ajax\HtmlCommand;
/**
* Provides an Ajax command for calling the jQuery html() method.
*
* This command is implemented in Drupal.AjaxCommands.prototype.webformHtml.
*/
class WebformHtmlCommand extends HtmlCommand {
/**
* Implements Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'webformInsert',
'method' => 'html',
'selector' => $this->selector,
'data' => $this->getRenderedContent(),
'settings' => $this->settings,
];
}
}
<?php
namespace Drupal\webform\Ajax;
use Drupal\Core\Ajax\RedirectCommand;
/**
* Provides an Ajax command for refreshing webform page/.
*
* This command is implemented in Drupal.AjaxCommands.prototype.webformRefresh.
*/
class WebformRefreshCommand extends RedirectCommand {
/**
* Implements \Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
return [
'command' => 'webformRefresh',
'url' => $this->url,
];
}
}
......@@ -9,7 +9,7 @@ use Drupal\Core\Ajax\CommandInterface;
*
* This command is implemented in Drupal.AjaxCommands.prototype.webformScrollTop.
*/
class ScrollTopCommand implements CommandInterface {
class WebformScrollTopCommand implements CommandInterface {
/**
* A CSS selector string.
......
......@@ -316,7 +316,7 @@ class WebformElementStates extends FormElement {
* An array containing Ajax callback settings.
*
* @return array
* A render array containing state operations..
* A render array containing state operations.
*/
protected static function buildOperations($table_id, $row_index, array $ajax_settings) {
$operations = [];
......
......@@ -4,11 +4,12 @@ namespace Drupal\webform\Form;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\HtmlCommand;
use Drupal\Core\Ajax\RedirectCommand;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Url;
use Drupal\webform\Ajax\ScrollTopCommand;
use Drupal\webform\Ajax\WebformCloseDialogCommand;
use Drupal\webform\Ajax\WebformRefreshCommand;
use Drupal\webform\Ajax\WebformScrollTopCommand;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
......@@ -38,6 +39,22 @@ trait WebformAjaxFormTrait {
*/
abstract public function cancelAjaxForm(array &$form, FormStateInterface $form_state);
/**
* Get default ajax callback settings.
* @return array
*/
protected function getDefaultAjaxSettings() {
return [
'disable-refocus' => TRUE,
'effect' => 'fade',
'speed' => 1000,
'progress' => [
'type' => 'throbber',
'message' => '',
],
];
}
/**
* Get the form's Ajax wrapper id.
*
......@@ -67,15 +84,7 @@ trait WebformAjaxFormTrait {
}
// Apply default settings.
$settings += [
'disable-refocus' => TRUE,
'effect' => 'fade',
'speed' => 1000,
'progress' => [
'type' => 'throbber',
'message' => '',
],
];
$settings += $this->getDefaultAjaxSettings();
// Make sure the form has (submit) actions.
if (!isset($form['actions'])) {
......@@ -119,8 +128,10 @@ trait WebformAjaxFormTrait {
*/
public function submitAjaxForm(array &$form, FormStateInterface $form_state) {
if ($form_state->hasAnyErrors()) {
// Display validation errors.
return $this->replaceForm($form);
// Display validation errors and scroll to the top of the page.
$response = $this->replaceForm($form);
$response->addCommand(new WebformScrollTopCommand('#' . $this->getWrapperId()));
return $response;
}
elseif ($form_state->isRebuilding()) {
// Rebuild form.
......@@ -129,7 +140,8 @@ trait WebformAjaxFormTrait {
elseif ($redirect_url = $this->getFormStateRedirectUrl($form_state)) {
// Redirect to URL.
$response = new AjaxResponse();
$response->addCommand(new RedirectCommand($redirect_url));
$response->addCommand(new WebformCloseDialogCommand());
$response->addCommand(new WebformRefreshCommand($redirect_url));
return $response;
}
else {
......@@ -137,6 +149,16 @@ trait WebformAjaxFormTrait {
}
}
/**
* Empty submit callback used to only have the submit button to use an #ajax submit callback.
*
* This allows modal dialog to using ::submitCallback to validate and submit
* the form via one ajax request.
*/
public function noSubmit(array &$form, FormStateInterface $form_state) {
// Do nothing.
}
/**
* Replace form via an Ajax response.
*
......@@ -159,12 +181,8 @@ trait WebformAjaxFormTrait {
// Remove wrapper.
unset($form['#prefix'], $form['#suffix']);
// Get wrapper id.
$wrapper_id = '#' . $this->getWrapperId();
$response = new AjaxResponse();
$response->addCommand(new HtmlCommand($wrapper_id, $form));
$response->addCommand(new ScrollTopCommand($wrapper_id));
$response->addCommand(new HtmlCommand('#' . $this->getWrapperId(), $form));
return $response;
}
......@@ -179,7 +197,7 @@ trait WebformAjaxFormTrait {
*/
protected function getFormStateRedirectUrl(FormStateInterface $form_state) {
// Always check the ?destination which is used by the off-canvas/system tray.
if ($this->requestStack->getCurrentRequest()->get('destination')) {
if (\Drupal::request()->get('destination')) {
return base_path() . $this->getRedirectDestination()->get();
}
......
......@@ -56,6 +56,16 @@ trait WebformDialogFormTrait {
])) ? TRUE : FALSE;
}
/**
* Is the current request a quick edit page.
*
* @return bool
* TRUE if the current request a quick edit page.
*/
protected function isQuickEdit() {
return (\Drupal::request()->query->get('destination')) ? TRUE : FALSE;
}
/**
* Add modal dialog support to a form.
*
......@@ -96,7 +106,7 @@ trait WebformDialogFormTrait {
'#type' => 'submit',
'#value' => $this->t('Cancel'),
'#submit' => ['::noSubmit'],
'#limit_validation_errors' => [],
'#validate' => ['::noSubmit'],
'#weight' => 100,
'#ajax' => [
'callback' => '::cancelAjaxForm',
......
<?php
namespace Drupal\webform\Form;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Form\FormState;
use Drupal\Core\Form\FormStateInterface;
use Drupal\webform\Ajax\WebformHtmlCommand;
/**
* Trait class for Webform Ajax support.
*/
trait WebformEntityAjaxFormTrait {
use WebformAjaxFormTrait;
/**
* {@inheritdoc}
*/
protected function isAjax() {
return TRUE;
}