diff --git a/core/modules/outside_in/css/outside_in.module.css b/core/modules/outside_in/css/outside_in.module.css
index 9d90bf227d..a0bb4a32aa 100644
--- a/core/modules/outside_in/css/outside_in.module.css
+++ b/core/modules/outside_in/css/outside_in.module.css
@@ -22,6 +22,11 @@
pointer-events: inherit;
}
+.ui-dialog-off-canvas .messages__wrapper:empty {
+ padding: 0;
+ margin: 0;
+}
+
/*
* Force the tray to be 100% width at the same breakpoint the dialog system uses
* to expand dialog widths.
diff --git a/core/modules/outside_in/outside_in.libraries.yml b/core/modules/outside_in/outside_in.libraries.yml
index 5c388b82f1..574d7ed656 100644
--- a/core/modules/outside_in/outside_in.libraries.yml
+++ b/core/modules/outside_in/outside_in.libraries.yml
@@ -20,6 +20,8 @@ drupal.outside_in:
- core/drupal
- core/jquery.once
- core/drupal.ajax
+ - core/drupalSettings
+ - core/jquery.form
drupal.off_canvas:
version: VERSION
js:
diff --git a/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php b/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php
index 4e0f081fb5..5f85976e25 100644
--- a/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php
+++ b/core/modules/outside_in/src/Block/BlockEntityOffCanvasForm.php
@@ -7,6 +7,7 @@
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginWithFormsInterface;
+use Drupal\outside_in\OffCanvasFormDialogTrait;
/**
* Provides form for block instance forms when used in the off-canvas dialog.
@@ -16,6 +17,8 @@
*/
class BlockEntityOffCanvasForm extends BlockForm {
+ use OffCanvasFormDialogTrait;
+
/**
* Provides a title callback to get the block's admin label.
*
@@ -36,6 +39,7 @@ public function title(BlockInterface $block) {
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
+ $form['#attributes']['data-off-canvas-form'] = TRUE;
// Create link to full block form.
$query = [];
@@ -97,4 +101,14 @@ protected function getPluginForm(BlockPluginInterface $block) {
return $block;
}
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ parent::submitForm($form, $form_state);
+ // \Drupal\block\BlockForm::submitForm() always redirects to block listing.
+ // This would doesn't work with Ajax submit.
+ $form_state->disableRedirect();
+ }
+
}
diff --git a/core/modules/outside_in/src/OffCanvasFormDialogTrait.php b/core/modules/outside_in/src/OffCanvasFormDialogTrait.php
new file mode 100644
index 0000000000..e29e8bb6b2
--- /dev/null
+++ b/core/modules/outside_in/src/OffCanvasFormDialogTrait.php
@@ -0,0 +1,196 @@
+getRequest()
+ ->get(MainContentViewSubscriber::WRAPPER_FORMAT);
+ return (in_array($wrapper_format, [
+ 'drupal_ajax',
+ 'drupal_modal',
+ 'drupal_dialog_off_canvas',
+ ])) ? TRUE : FALSE;
+ }
+
+ /**
+ * Add modal dialog support to a form.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ protected function buildFormDialog(array &$form, FormStateInterface $form_state) {
+ if (!$this->isModalDialog()) {
+ return;
+ }
+
+ $ajax_callback_added = FALSE;
+
+ if (!empty($form['actions']['submit'])) {
+ $form['actions']['submit']['#ajax'] = [
+ 'callback' => '::submitFormDialog',
+ 'event' => 'click',
+ ];
+ $ajax_callback_added = TRUE;
+ }
+
+ if (!empty($form['actions']['cancel'])) {
+ // Replace 'Cancel' link button with a close dialog button.
+ $form['actions']['cancel'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Cancel'),
+ '#submit' => ['::noSubmit'],
+ '#limit_validation_errors' => [],
+ '#weight' => 100,
+ '#ajax' => [
+ 'callback' => '::closeDialog',
+ 'event' => 'click',
+ ],
+ ];
+ $ajax_callback_added = TRUE;
+ }
+
+ if ($ajax_callback_added) {
+ $form['#attached']['library'][] = 'core/drupal.dialog.ajax';
+ $form['#prefix'] = '
';
+ $form['#suffix'] = '
';
+ }
+ }
+
+ /**
+ * Submit form dialog #ajax callback.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ *
+ * @return \Drupal\Core\Ajax\AjaxResponse
+ * An AJAX response that display validation error messages or redirects
+ * to a URL
+ */
+ public function submitFormDialog(array &$form, FormStateInterface $form_state) {
+ $response = new AjaxResponse();
+ if ($form_state->hasAnyErrors()) {
+ unset($form['#prefix'], $form['#suffix']);
+ $form['status_messages'] = [
+ '#type' => 'status_messages',
+ '#weight' => -1000,
+ ];
+ $response->addCommand(new HtmlCommand('#off-canvas-form', $form));
+ }
+ else {
+ if ($redirect_url = $this->getRedirectUrl()) {
+ $response->addCommand(new RedirectCommand($redirect_url->setAbsolute()
+ ->toString()));
+ }
+ else {
+ $response->addCommand(new CloseDialogCommand('#drupal-off-canvas'));
+ }
+ }
+ return $response;
+ }
+
+ /**
+ * Close dialog #ajax callback.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ *
+ * @return bool|\Drupal\Core\Ajax\AjaxResponse
+ * An AJAX response that display validation error messages.
+ */
+ public function closeDialog(array &$form, FormStateInterface $form_state) {
+ $response = new AjaxResponse();
+ $response->addCommand(new CloseDialogCommand('#drupal-off-canvas'));
+ return $response;
+ }
+
+ /**
+ * Empty submit #ajax submit callback.
+ *
+ * This allows modal dialog to using ::submitCallback to validate and submit
+ * the form via one ajax required.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ public function noSubmit(array &$form, FormStateInterface $form_state) {
+ }
+
+ /**
+ * Get the form's redirect URL.
+ *
+ * Isolate a form's redirect URL/destination so that it can be used by
+ * ::submitFormDialog or ::submitForm.
+ *
+ * @return \Drupal\Core\Url|null
+ * The redirect URL or NULL if dialog should just be closed.
+ */
+ protected function getRedirectUrl() {
+ return $this->getDestinationUrl();
+ }
+
+ /**
+ * Get the URL from the destination service.
+ *
+ * @return \Drupal\Core\Url|null
+ * The destination URL or NULL no destination available.
+ */
+ protected function getDestinationUrl() {
+ if ($destination = $this->getRedirectDestinationPath()) {
+ return Url::fromUserInput('/' . $destination);
+ }
+
+ return NULL;
+ }
+
+ /**
+ * Get the redirect destination path if specified in request.
+ *
+ * @return string|null
+ * The redirect path or NULL if it is not specified.
+ */
+ protected function getRedirectDestinationPath() {
+ if ($this->requestStack->getCurrentRequest()->get('destination')) {
+ return $this->getRedirectDestination()->get();
+ }
+ return NULL;
+ }
+
+ /**
+ * Implements \Drupal\Core\Form\FormInterface::buildForm().
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $form = parent::buildForm($form, $form_state);
+ $this->buildFormDialog($form, $form_state);
+ return $form;
+ }
+
+}
diff --git a/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php b/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php
index 9119c9b8cf..b03ab22c0e 100644
--- a/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php
+++ b/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php
@@ -41,6 +41,15 @@ public function __construct(TitleResolverInterface $title_resolver, RendererInte
public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match) {
$response = new AjaxResponse();
+ // Add place holder form messages from Ajax form requests.
+ $main_content = [
+ 'off_canvas_messages' => [
+ '#type' => 'container',
+ '#attributes' => ['class' => ['messages__wrapper']],
+ '#weight' => 100,
+ ],
+ ] + $main_content;
+
// First render the main content, because it might provide a title.
$content = $this->renderer->renderRoot($main_content);
// Attach the library necessary for using the OpenOffCanvasDialogCommand and
diff --git a/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php b/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php
index e2b451e1a7..1e42fe3a3f 100644
--- a/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php
+++ b/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php
@@ -36,7 +36,7 @@ public function testDialog() {
'command' => 'openDialog',
'selector' => '#drupal-off-canvas',
'settings' => NULL,
- 'data' => $dialog_contents,
+ 'data' => $dialog_contents . "\n",
'dialogOptions' =>
[
'title' => 'AJAX Dialog & contents',
diff --git a/core/modules/outside_in/tests/modules/off_canvas_test/off_canvas_test.routing.yml b/core/modules/outside_in/tests/modules/off_canvas_test/off_canvas_test.routing.yml
index 4ae56c4ee8..f0b3a43653 100644
--- a/core/modules/outside_in/tests/modules/off_canvas_test/off_canvas_test.routing.yml
+++ b/core/modules/outside_in/tests/modules/off_canvas_test/off_canvas_test.routing.yml
@@ -27,3 +27,10 @@ off_canvas_test.dialog_links:
_controller: '\Drupal\off_canvas_test\Controller\TestController::otherDialogLinks'
requirements:
_access: 'TRUE'
+
+off_canvas_test.form:
+ path: '/off-canvas-form'
+ defaults:
+ _form: '\Drupal\off_canvas_test\Form\TestForm'
+ requirements:
+ _access: 'TRUE'
diff --git a/core/modules/outside_in/tests/modules/off_canvas_test/src/Controller/TestController.php b/core/modules/outside_in/tests/modules/off_canvas_test/src/Controller/TestController.php
index 6164b06fc2..1208edf2a0 100644
--- a/core/modules/outside_in/tests/modules/off_canvas_test/src/Controller/TestController.php
+++ b/core/modules/outside_in/tests/modules/off_canvas_test/src/Controller/TestController.php
@@ -92,6 +92,42 @@ public function linksDisplay() {
],
],
],
+ 'off_canvas_form' => [
+ '#title' => 'Show form!',
+ '#type' => 'link',
+ '#url' => Url::fromRoute(
+ 'off_canvas_test.form',
+ [],
+ ['query' => ['destination' => 'off-canvas-test-links']]
+ ),
+ '#attributes' => [
+ 'class' => ['use-ajax'],
+ 'data-dialog-type' => 'dialog',
+ 'data-dialog-renderer' => 'off_canvas',
+ ],
+ '#attached' => [
+ 'library' => [
+ 'outside_in/drupal.outside_in',
+ ],
+ ],
+ ],
+ 'off_canvas_form_no_dest' => [
+ '#title' => 'Show form: no destination!',
+ '#type' => 'link',
+ '#url' => Url::fromRoute(
+ 'off_canvas_test.form'
+ ),
+ '#attributes' => [
+ 'class' => ['use-ajax'],
+ 'data-dialog-type' => 'dialog',
+ 'data-dialog-renderer' => 'off_canvas',
+ ],
+ '#attached' => [
+ 'library' => [
+ 'outside_in/drupal.outside_in',
+ ],
+ ],
+ ],
];
}
diff --git a/core/modules/outside_in/tests/modules/off_canvas_test/src/Form/TestForm.php b/core/modules/outside_in/tests/modules/off_canvas_test/src/Form/TestForm.php
new file mode 100644
index 0000000000..0e40595054
--- /dev/null
+++ b/core/modules/outside_in/tests/modules/off_canvas_test/src/Form/TestForm.php
@@ -0,0 +1,69 @@
+ 'checkbox',
+ '#title' => $this->t('Force error?'),
+ ];
+ $form['actions'] = [
+ '#type' => 'actions',
+ 'submit' => [
+ '#type' => 'submit',
+ '#value' => $this->t('Submit'),
+ ],
+ 'cancel' => [
+ '#type' => 'submit',
+ '#value' => $this->t('Cancel'),
+ ],
+ ];
+ $this->buildFormDialog($form, $form_state);
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+ if ($form_state->getValue('force_error')) {
+ $form_state->setErrorByName('force_error', 'Validation error');
+ }
+ }
+
+ /**
+ * Form submission handler.
+ *
+ * @param array $form
+ * An associative array containing the structure of the form.
+ * @param \Drupal\Core\Form\FormStateInterface $form_state
+ * The current state of the form.
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ drupal_set_message('submitted');
+ }
+
+}
diff --git a/core/modules/outside_in/tests/src/FunctionalJavascript/OffCanvasTest.php b/core/modules/outside_in/tests/src/FunctionalJavascript/OffCanvasTest.php
index 94c18f039a..12b979dbc5 100644
--- a/core/modules/outside_in/tests/src/FunctionalJavascript/OffCanvasTest.php
+++ b/core/modules/outside_in/tests/src/FunctionalJavascript/OffCanvasTest.php
@@ -120,4 +120,57 @@ protected function getTestThemes() {
return array_merge(parent::getTestThemes(), ['seven']);
}
+ /**
+ * Test form errors in the Off-Canvas dialog.
+ */
+ public function testFormErrors() {
+ $web_assert = $this->assertSession();
+ $page = $this->getSession()->getPage();
+
+ // Submit form with no error and sending a destination.
+ $this->drupalGet('/off-canvas-test-links');
+ $page->clickLink('Show form!');
+ $this->waitForOffCanvasToOpen();
+ $page->pressButton('Submit');
+ $web_assert->assertWaitOnAjaxRequest();
+ // Make sure the changes are present.
+ $this->assertNotEmpty($web_assert->waitForElement('css', 'div.messages.messages--status:contains(submitted)'));
+ $web_assert->elementNotContains('css', 'body', 'Validation error');
+
+ // Submit form with an error and sending a destination.
+ $this->drupalGet('/off-canvas-test-links');
+ $page->clickLink('Show form!');
+ $this->waitForOffCanvasToOpen();
+ $page->checkField('Force error?');
+ $page->pressButton('Submit');
+ $web_assert->assertWaitOnAjaxRequest();
+ $web_assert->elementNotContains('css', 'body', 'submitted');
+ $web_assert->elementContains('css', '#drupal-off-canvas', 'Validation error');
+
+ // Submit form with no error and NOT sending a destination.
+ $this->drupalGet('/off-canvas-test-links');
+ $page->clickLink('Show form: no destination!');
+ $this->waitForOffCanvasToOpen();
+ $page->pressButton('Submit');
+ $web_assert->assertWaitOnAjaxRequest();
+ // Make sure the changes are present.
+ $this->assertEmpty($web_assert->waitForElement('css', 'div.messages.messages--status:contains(submitted)'));
+ $web_assert->elementNotContains('css', 'body', 'Validation error');
+ // If no validation error and no destination provided page will not be
+ // redirected but the dialog should be closed.
+ $this->waitForNoElement('css', '#drupal-off-canvas');
+
+ $this->drupalGet('/off-canvas-test-links');
+
+ // Submit form with error and NOT sending a destination.
+ $this->drupalGet('/off-canvas-test-links');
+ $page->clickLink('Show form: no destination!');
+ $this->waitForOffCanvasToOpen();
+ $page->checkField('Force error?');
+ $page->pressButton('Submit');
+ $web_assert->assertWaitOnAjaxRequest();
+ $web_assert->elementNotContains('css', 'body', 'submitted');
+ $web_assert->elementContains('css', '#drupal-off-canvas', 'Validation error');
+ }
+
}
diff --git a/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php b/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php
index a5eacc5c53..ff5ba1f29b 100644
--- a/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php
+++ b/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInBlockFormTest.php
@@ -83,7 +83,7 @@ public function testBlocks($block_plugin, $new_page_text, $element_selector, $la
$this->waitForNoElement("#toolbar-administration a.is-active");
}
$page->find('css', $toolbar_item)->click();
- $web_assert->waitForElementVisible('css', "{$toolbar_item}.is-active");
+ $this->assertElementVisibleAfterWait('css', "{$toolbar_item}.is-active");
}
$this->enableEditMode();
if (isset($toolbar_item)) {
@@ -106,10 +106,11 @@ public function testBlocks($block_plugin, $new_page_text, $element_selector, $la
if (isset($new_page_text)) {
$page->pressButton($button_text);
// Make sure the changes are present.
- // @todo Use a wait method that will take into account the form submitting
- // and all JavaScript activity. https://www.drupal.org/node/2837676
- // The use \Behat\Mink\WebAssert::pageTextContains to check text.
- $this->assertJsCondition('jQuery("' . $block_selector . ' ' . $label_selector . '").html() == "' . $new_page_text . '"');
+ $new_page_text_locator = "$block_selector $label_selector:contains($new_page_text)";
+ $this->assertElementVisibleAfterWait('css', $new_page_text_locator);
+ // After the new page text is on the page wait to make sure all Ajax
+ // request are completed on the new page.
+ $web_assert->assertWaitOnAjaxRequest();
}
$this->openBlockForm($block_selector);
@@ -146,7 +147,7 @@ public function providerTestBlocks() {
$blocks = [
'block-powered' => [
'block_plugin' => 'system_powered_by_block',
- 'new_page_text' => 'Can you imagine anyone showing the label on this block?',
+ 'new_page_text' => 'Can you imagine anyone showing the label on this block',
'element_selector' => 'span a',
'label_selector' => 'h2',
'button_text' => 'Save Powered by Drupal',
@@ -154,7 +155,7 @@ public function providerTestBlocks() {
],
'block-branding' => [
'block_plugin' => 'system_branding_block',
- 'new_page_text' => 'The site that will live a very short life.',
+ 'new_page_text' => 'The site that will live a very short life',
'element_selector' => "a[rel='home']:last-child",
'label_selector' => "a[rel='home']:last-child",
'button_text' => 'Save Site branding',
@@ -257,7 +258,7 @@ public function testQuickEditLinks() {
$this->drupalGet('node/' . $node->id());
// Waiting for Toolbar module.
// @todo Remove the hack after https://www.drupal.org/node/2542050.
- $web_assert->waitForElementVisible('css', '.toolbar-fixed');
+ $this->assertElementVisibleAfterWait('css', '.toolbar-fixed');
// Waiting for Toolbar animation.
$web_assert->assertWaitOnAjaxRequest();
// The 2nd page load we should already be in edit mode.
@@ -266,7 +267,7 @@ public function testQuickEditLinks() {
}
// In Edit mode clicking field should open QuickEdit toolbar.
$page->find('css', $body_selector)->click();
- $web_assert->waitForElementVisible('css', $quick_edit_selector);
+ $this->assertElementVisibleAfterWait('css', $quick_edit_selector);
$this->disableEditMode();
// Exiting Edit mode should close QuickEdit toolbar.
@@ -277,7 +278,7 @@ public function testQuickEditLinks() {
$this->enableEditMode();
$this->openBlockForm($block_selector);
$page->find('css', $body_selector)->click();
- $web_assert->waitForElementVisible('css', $quick_edit_selector);
+ $this->assertElementVisibleAfterWait('css', $quick_edit_selector);
// Off-canvas dialog should be closed when opening QuickEdit toolbar.
$this->waitForOffCanvasToClose();
@@ -291,7 +292,7 @@ public function testQuickEditLinks() {
$this->disableEditMode();
// Open QuickEdit toolbar before going into Edit mode.
$this->clickContextualLink($node_selector, "Quick edit");
- $web_assert->waitForElementVisible('css', $quick_edit_selector);
+ $this->assertElementVisibleAfterWait('css', $quick_edit_selector);
// Open off-canvas and enter Edit mode via contextual link.
$this->clickContextualLink($block_selector, "Quick edit");
$this->waitForOffCanvasToOpen();
@@ -300,7 +301,7 @@ public function testQuickEditLinks() {
// Open QuickEdit toolbar via contextual link while in Edit mode.
$this->clickContextualLink($node_selector, "Quick edit", FALSE);
$this->waitForOffCanvasToClose();
- $web_assert->waitForElementVisible('css', $quick_edit_selector);
+ $this->assertElementVisibleAfterWait('css', $quick_edit_selector);
$this->disableEditMode();
}
}
diff --git a/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInJavascriptTestBase.php b/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInJavascriptTestBase.php
index 2dcc40fb44..ea2fe948fb 100644
--- a/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInJavascriptTestBase.php
+++ b/core/modules/outside_in/tests/src/FunctionalJavascript/OutsideInJavascriptTestBase.php
@@ -42,7 +42,7 @@ public function enableTheme($theme) {
protected function waitForOffCanvasToOpen() {
$web_assert = $this->assertSession();
$web_assert->assertWaitOnAjaxRequest();
- $web_assert->waitForElementVisible('css', '#drupal-off-canvas');
+ $this->assertElementVisibleAfterWait('css', '#drupal-off-canvas');
}
/**
@@ -125,7 +125,7 @@ protected function waitForToolbarToLoad() {
$web_assert = $this->assertSession();
// Waiting for Toolbar module.
// @todo Remove the hack after https://www.drupal.org/node/2542050.
- $web_assert->waitForElementVisible('css', '.toolbar-fixed');
+ $this->assertElementVisibleAfterWait('css', '.toolbar-fixed');
// Waiting for Toolbar animation.
$web_assert->assertWaitOnAjaxRequest();
}
@@ -140,4 +140,19 @@ protected function getTestThemes() {
return ['bartik', 'stark', 'classy', 'stable'];
}
+ /**
+ * Assert the specified selector is visible after a wait.
+ *
+ * @param string $selector
+ * The selector engine name. See ElementInterface::findAll() for the
+ * supported selectors.
+ * @param string|array $locator
+ * The selector locator.
+ * @param int $timeout
+ * (Optional) Timeout in milliseconds, defaults to 10000.
+ */
+ protected function assertElementVisibleAfterWait($selector, $locator, $timeout = 10000) {
+ $this->assertNotEmpty($this->assertSession()->waitForElementVisible($selector, $locator, $timeout));
+ }
+
}