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)); + } + }