diff --git a/core/modules/layout_builder/src/Controller/LayoutBuilderController.php b/core/modules/layout_builder/src/Controller/LayoutBuilderController.php index c01fea544b2bf3320b5f871d71738cdf7d541b7d..3b56d4acb705d71b092af90ce4eab6ef3100f83d 100644 --- a/core/modules/layout_builder/src/Controller/LayoutBuilderController.php +++ b/core/modules/layout_builder/src/Controller/LayoutBuilderController.php @@ -2,6 +2,7 @@ namespace Drupal\layout_builder\Controller; +use Drupal\Core\Ajax\AjaxHelperTrait; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Plugin\PluginFormInterface; @@ -24,6 +25,7 @@ class LayoutBuilderController implements ContainerInjectionInterface { use LayoutBuilderContextTrait; use StringTranslationTrait; + use AjaxHelperTrait; /** * The layout tempstore repository. @@ -90,6 +92,11 @@ public function layout(SectionStorageInterface $section_storage, $is_rebuilding $this->prepareLayout($section_storage, $is_rebuilding); $output = []; + if ($this->isAjax()) { + $output['status_messages'] = [ + '#type' => 'status_messages', + ]; + } $count = 0; for ($i = 0; $i < $section_storage->count(); $i++) { $output[] = $this->buildAddSectionLink($section_storage, $count); @@ -114,6 +121,11 @@ public function layout(SectionStorageInterface $section_storage, $is_rebuilding * Indicates if the layout is rebuilding. */ protected function prepareLayout(SectionStorageInterface $section_storage, $is_rebuilding) { + // If the layout has pending changes, add a warning. + if ($this->layoutTempstoreRepository->has($section_storage)) { + $this->messenger->addWarning($this->t('You have unsaved changes.')); + } + // Only add sections if the layout is new and empty. if (!$is_rebuilding && $section_storage->count() === 0) { $sections = []; diff --git a/core/modules/layout_builder/src/LayoutTempstoreRepository.php b/core/modules/layout_builder/src/LayoutTempstoreRepository.php index 39725afc7e20068e9d25fdea0af447d0c32c117f..69676861db43583b426fe836d6d36ac3f71cf660 100644 --- a/core/modules/layout_builder/src/LayoutTempstoreRepository.php +++ b/core/modules/layout_builder/src/LayoutTempstoreRepository.php @@ -45,6 +45,15 @@ public function get(SectionStorageInterface $section_storage) { return $section_storage; } + /** + * {@inheritdoc} + */ + public function has(SectionStorageInterface $section_storage) { + $id = $section_storage->getStorageId(); + $tempstore = $this->getTempstore($section_storage)->get($id); + return !empty($tempstore['section_storage']); + } + /** * {@inheritdoc} */ diff --git a/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php b/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php index 4972a47f6f058359b5702b84c12ab2ca164dbf77..67dc59ca99d807d34b60a44778a138f5df88dca4 100644 --- a/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php +++ b/core/modules/layout_builder/src/LayoutTempstoreRepositoryInterface.php @@ -35,6 +35,17 @@ public function get(SectionStorageInterface $section_storage); */ public function set(SectionStorageInterface $section_storage); + /** + * Checks for the existence of a tempstore version of a section storage. + * + * @param \Drupal\layout_builder\SectionStorageInterface $section_storage + * The section storage to check for in tempstore. + * + * @return bool + * TRUE if there is a tempstore version of this section storage. + */ + public function has(SectionStorageInterface $section_storage); + /** * Removes the tempstore version of a section storage. * diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php index be2dd5d25e207cb916276fd7ff4584034455d878..1e4f240869f1b152bb2009e3c9d950915c1d70aa 100644 --- a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php +++ b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php @@ -91,7 +91,7 @@ public function testLayoutBuilderUi() { // The body field is only present once. $assert_session->elementsCount('css', '.field--name-body', 1); // The extra field is only present once. - $this->assertTextAppearsOnce('Placeholder for the "Extra label" field'); + $assert_session->pageTextContainsOnce('Placeholder for the "Extra label" field'); // Save the defaults. $assert_session->linkExists('Save Layout'); $this->clickLink('Save Layout'); @@ -106,7 +106,7 @@ public function testLayoutBuilderUi() { // The body field is only present once. $assert_session->elementsCount('css', '.field--name-body', 1); // The extra field is only present once. - $this->assertTextAppearsOnce('Placeholder for the "Extra label" field'); + $assert_session->pageTextContainsOnce('Placeholder for the "Extra label" field'); // Add a new block. $assert_session->linkExists('Add Block'); @@ -526,14 +526,4 @@ public function testBlockPlaceholder() { $assert_session->pageTextContains($block_content); } - /** - * Asserts that a text string only appears once on the page. - * - * @param string $needle - * The string to look for. - */ - protected function assertTextAppearsOnce($needle) { - $this->assertEquals(1, substr_count($this->getSession()->getPage()->getContent(), $needle), "'$needle' only appears once on the page."); - } - } diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f0c4e42ab1c294736c51d6c612219f1d2a1937a7 --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/LayoutBuilderUiTest.php @@ -0,0 +1,93 @@ +<?php + +namespace Drupal\Tests\layout_builder\FunctionalJavascript; + +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; + +/** + * Tests the Layout Builder UI. + * + * @group layout_builder + */ +class LayoutBuilderUiTest extends WebDriverTestBase { + + /** + * Path prefix for the field UI for the test bundle. + * + * @var string + */ + const FIELD_UI_PREFIX = 'admin/structure/types/manage/bundle_with_section_field'; + + public static $modules = [ + 'layout_builder', + 'block', + 'node', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + // @todo The Layout Builder UI relies on local tasks; fix in + // https://www.drupal.org/project/drupal/issues/2917777. + $this->drupalPlaceBlock('local_tasks_block'); + + $this->createContentType(['type' => 'bundle_with_section_field']); + + $this->drupalLogin($this->drupalCreateUser([ + 'configure any layout', + 'administer node display', + 'administer node fields', + ])); + } + + /** + * Tests the message indicating unsaved changes. + */ + public function testUnsavedChangesMessage() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Enable layout builder. + $this->drupalPostForm( + static::FIELD_UI_PREFIX . '/display/default', + ['layout[enabled]' => TRUE], + 'Save' + ); + $page->clickLink('Manage layout'); + $assert_session->addressEquals(static::FIELD_UI_PREFIX . '/display-layout/default'); + + // Add a new section. + $page->clickLink('Add Section'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextNotContains('You have unsaved changes.'); + $page->clickLink('One column'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextContainsOnce('You have unsaved changes.'); + + // Reload the page. + $this->drupalGet(static::FIELD_UI_PREFIX . '/display-layout/default'); + $assert_session->pageTextContainsOnce('You have unsaved changes.'); + + // Cancel the changes. + $page->clickLink('Cancel Layout'); + $this->drupalGet(static::FIELD_UI_PREFIX . '/display-layout/default'); + $assert_session->pageTextNotContains('You have unsaved changes.'); + + // Add a new section. + $page->clickLink('Add Section'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextNotContains('You have unsaved changes.'); + $page->clickLink('One column'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextContainsOnce('You have unsaved changes.'); + + // Save the changes. + $page->clickLink('Save Layout'); + $this->drupalGet(static::FIELD_UI_PREFIX . '/display-layout/default'); + $assert_session->pageTextNotContains('You have unsaved changes.'); + } + +} diff --git a/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php b/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php index 0c74d01763290cc9d33daeda8ea5b2f8d4fc2894..fb46e608ae33b165db7ae8ddaf56f0e86d55d7ad 100644 --- a/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php +++ b/core/modules/layout_builder/tests/src/Unit/LayoutTempstoreRepositoryTest.php @@ -16,6 +16,7 @@ class LayoutTempstoreRepositoryTest extends UnitTestCase { /** * @covers ::get + * @covers ::has */ public function testGetEmptyTempstore() { $section_storage = $this->prophesize(SectionStorageInterface::class); @@ -30,12 +31,15 @@ public function testGetEmptyTempstore() { $repository = new LayoutTempstoreRepository($tempstore_factory->reveal()); + $this->assertFalse($repository->has($section_storage->reveal())); + $result = $repository->get($section_storage->reveal()); $this->assertSame($section_storage->reveal(), $result); } /** * @covers ::get + * @covers ::has */ public function testGetLoadedTempstore() { $section_storage = $this->prophesize(SectionStorageInterface::class); @@ -50,6 +54,8 @@ public function testGetLoadedTempstore() { $repository = new LayoutTempstoreRepository($tempstore_factory->reveal()); + $this->assertTrue($repository->has($section_storage->reveal())); + $result = $repository->get($section_storage->reveal()); $this->assertSame($tempstore_section_storage->reveal(), $result); $this->assertNotSame($section_storage->reveal(), $result); diff --git a/core/tests/Drupal/Tests/WebAssert.php b/core/tests/Drupal/Tests/WebAssert.php index 93667b1687fed7a761f0756a0fa4f6ff90028dda..4f7a7d292c5e3dcb4c175dbaacf42b366e35bf3f 100644 --- a/core/tests/Drupal/Tests/WebAssert.php +++ b/core/tests/Drupal/Tests/WebAssert.php @@ -3,6 +3,7 @@ namespace Drupal\Tests; use Behat\Mink\Exception\ExpectationException; +use Behat\Mink\Exception\ResponseTextException; use Behat\Mink\WebAssert as MinkWebAssert; use Behat\Mink\Element\TraversableElement; use Behat\Mink\Exception\ElementNotFoundException; @@ -569,4 +570,31 @@ public function hiddenFieldValueNotEquals($field, $value, TraversableElement $co $this->assert(!preg_match($regex, $actual), $message); } + /** + * Checks that current page contains text only once. + * + * @param string $text + * The string to look for. + * + * @see \Behat\Mink\WebAssert::pageTextContains() + */ + public function pageTextContainsOnce($text) { + $actual = $this->session->getPage()->getText(); + $actual = preg_replace('/\s+/u', ' ', $actual); + $regex = '/' . preg_quote($text, '/') . '/ui'; + $count = preg_match_all($regex, $actual); + if ($count === 1) { + return; + } + + if ($count > 1) { + $message = sprintf('The text "%s" appears in the text of this page more than once, but it should not.', $text); + } + else { + $message = sprintf('The text "%s" was not found anywhere in the text of the current page.', $text); + } + + throw new ResponseTextException($message, $this->session->getDriver()); + } + }