diff --git a/core/modules/book/book.services.yml b/core/modules/book/book.services.yml index 8743de3b8a99b9e06fa9c50f34d5a243b536af44..ab23275144ee42fca4e5a12e8c4c542e07258bd4 100644 --- a/core/modules/book/book.services.yml +++ b/core/modules/book/book.services.yml @@ -1,18 +1,18 @@ services: book.breadcrumb: class: Drupal\book\BookBreadcrumbBuilder - arguments: ['@entity_type.manager', '@current_user'] + arguments: ['@entity_type.manager', '@current_user', '@entity.repository', '@language_manager'] tags: - { name: breadcrumb_builder, priority: 701 } book.manager: class: Drupal\book\BookManager - arguments: ['@entity_type.manager', '@string_translation', '@config.factory', '@book.outline_storage', '@renderer'] + arguments: ['@entity_type.manager', '@string_translation', '@config.factory', '@book.outline_storage', '@renderer', '@language_manager', '@entity.repository'] book.outline: class: Drupal\book\BookOutline arguments: ['@book.manager'] book.export: class: Drupal\book\BookExport - arguments: ['@entity_type.manager', '@book.manager'] + arguments: ['@entity_type.manager', '@book.manager', '@entity.repository'] book.outline_storage: class: Drupal\book\BookOutlineStorage arguments: ['@database'] diff --git a/core/modules/book/src/BookBreadcrumbBuilder.php b/core/modules/book/src/BookBreadcrumbBuilder.php index db446a0651ae1ff71f4a6fdb170997b973a57828..11fe40953856256e063449b8c594d85ab82826d9 100644 --- a/core/modules/book/src/BookBreadcrumbBuilder.php +++ b/core/modules/book/src/BookBreadcrumbBuilder.php @@ -4,7 +4,10 @@ use Drupal\Core\Breadcrumb\Breadcrumb; use Drupal\Core\Breadcrumb\BreadcrumbBuilderInterface; +use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Link; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Session\AccountInterface; @@ -31,6 +34,20 @@ class BookBreadcrumbBuilder implements BreadcrumbBuilderInterface { */ protected $account; + /** + * The entity repository service. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface + */ + protected $entityRepository; + + /** + * The language manager service. + * + * @var \Drupal\Core\Language\LanguageManagerInterface|null + */ + protected $languageManager; + /** * Constructs the BookBreadcrumbBuilder. * @@ -38,10 +55,24 @@ class BookBreadcrumbBuilder implements BreadcrumbBuilderInterface { * The entity type manager service. * @param \Drupal\Core\Session\AccountInterface $account * The current user account. + * @param \Drupal\Core\Entity\EntityRepositoryInterface|null $entity_repository + * The entity repository service. + * @param \Drupal\Core\Language\LanguageManagerInterface|null $language_manager + * The language manager service. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, AccountInterface $account) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, AccountInterface $account, EntityRepositoryInterface $entity_repository = NULL, LanguageManagerInterface $language_manager = NULL) { $this->nodeStorage = $entity_type_manager->getStorage('node'); $this->account = $account; + if (!$entity_repository) { + @trigger_error('The entity.repository service must be passed to ' . __NAMESPACE__ . '\BookBreadcrumbBuilder::__construct(). It was added in drupal:9.2.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED); + $entity_repository = \Drupal::service('entity.repository'); + } + if (!$language_manager) { + @trigger_error('The language.manager service must be passed to ' . __NAMESPACE__ . '\BookBreadcrumbBuilder::__construct(). It was added in drupal:9.2.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED); + $language_manager = \Drupal::service('language.manager'); + } + $this->entityRepository = $entity_repository; + $this->languageManager = $language_manager; } /** @@ -59,7 +90,11 @@ public function build(RouteMatchInterface $route_match) { $book_nids = []; $breadcrumb = new Breadcrumb(); - $links = [Link::createFromRoute($this->t('Home'), '<front>')]; + $links = [Link::createFromRoute($this->t('Home'), '<front>', [], [ + 'language' => $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT), + ]), + ]; + $breadcrumb->addCacheContexts(['languages:' . LanguageInterface::TYPE_CONTENT]); $book = $route_match->getParameter('node')->book; $depth = 1; // We skip the current node. @@ -67,7 +102,9 @@ public function build(RouteMatchInterface $route_match) { $book_nids[] = $book['p' . $depth]; $depth++; } + /** @var \Drupal\node\NodeInterface[] $parent_books */ $parent_books = $this->nodeStorage->loadMultiple($book_nids); + $parent_books = array_map([$this->entityRepository, 'getTranslationFromContext'], $parent_books); if (count($parent_books) > 0) { $depth = 1; while (!empty($book['p' . ($depth + 1)])) { @@ -76,7 +113,7 @@ public function build(RouteMatchInterface $route_match) { $breadcrumb->addCacheableDependency($access); if ($access->isAllowed()) { $breadcrumb->addCacheableDependency($parent_book); - $links[] = Link::createFromRoute($parent_book->label(), 'entity.node.canonical', ['node' => $parent_book->id()]); + $links[] = $parent_book->toLink(); } } $depth++; diff --git a/core/modules/book/src/BookExport.php b/core/modules/book/src/BookExport.php index d130610b64db9ca22ab052264ffca43562245a8b..db5cf3da0d9c0ea98c2e6c4fa21ea82c5f29011a 100644 --- a/core/modules/book/src/BookExport.php +++ b/core/modules/book/src/BookExport.php @@ -2,6 +2,7 @@ namespace Drupal\book; +use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\node\NodeInterface; @@ -33,6 +34,13 @@ class BookExport { */ protected $bookManager; + /** + * The entity repository service. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface + */ + protected $entityRepository; + /** * Constructs a BookExport object. * @@ -40,11 +48,18 @@ class BookExport { * The entity type manager. * @param \Drupal\book\BookManagerInterface $book_manager * The book manager. + * @param \Drupal\Core\Entity\EntityRepositoryInterface|null $entity_repository + * The entity repository service. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, BookManagerInterface $book_manager) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, BookManagerInterface $book_manager, EntityRepositoryInterface $entity_repository = NULL) { $this->nodeStorage = $entity_type_manager->getStorage('node'); $this->viewBuilder = $entity_type_manager->getViewBuilder('node'); $this->bookManager = $book_manager; + if (!$entity_repository) { + @trigger_error('The entity.repository service must be passed to ' . __NAMESPACE__ . '\BookExport::__construct(). It was added in drupal:9.2.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED); + $entity_repository = \Drupal::service('entity.repository'); + } + $this->entityRepository = $entity_repository; } /** @@ -74,6 +89,7 @@ public function bookExportHtml(NodeInterface $node) { $tree = $this->bookManager->bookSubtreeData($node->book); $contents = $this->exportTraverse($tree, [$this, 'bookNodeExport']); + $node = $this->entityRepository->getTranslationFromContext($node); return [ '#theme' => 'book_export_html', '#title' => $node->label(), @@ -96,8 +112,8 @@ public function bookExportHtml(NodeInterface $node) { * @param callable $callable * A callback to be called upon visiting a node in the tree. * - * @return string - * The output generated in visiting each node. + * @return array + * The render array generated in visiting each node. */ protected function exportTraverse(array $tree, $callable) { // If there is no valid callable, use the default callback. @@ -105,8 +121,9 @@ protected function exportTraverse(array $tree, $callable) { $build = []; foreach ($tree as $data) { - // Note- access checking is already performed when building the tree. + // Access checking is already performed when building the tree. if ($node = $this->nodeStorage->load($data['link']['nid'])) { + $node = $this->entityRepository->getTranslationFromContext($node); $children = $data['below'] ? $this->exportTraverse($data['below'], $callable) : ''; $build[] = call_user_func($callable, $node, $children); } diff --git a/core/modules/book/src/BookManager.php b/core/modules/book/src/BookManager.php index 504f042ea553299e76ebf13d12c96f7b09c16362..b0cb5976300dc436140b4d18203161a3bcaf8000 100644 --- a/core/modules/book/src/BookManager.php +++ b/core/modules/book/src/BookManager.php @@ -4,14 +4,18 @@ use Drupal\Component\Utility\Unicode; use Drupal\Core\Cache\Cache; +use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Render\RendererInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\TranslationInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Template\Attribute; +use Drupal\Core\Url; use Drupal\node\NodeInterface; /** @@ -67,6 +71,20 @@ class BookManager implements BookManagerInterface { */ protected $renderer; + /** + * The entity repository service. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface + */ + protected $entityRepository; + + /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface|mixed|null + */ + protected $languageManager; + /** * Constructs a BookManager object. * @@ -80,13 +98,27 @@ class BookManager implements BookManagerInterface { * The book outline storage. * @param \Drupal\Core\Render\RendererInterface $renderer * The renderer. + * @param \Drupal\Core\Language\LanguageManagerInterface|null $language_manager + * The language manager. + * @param \Drupal\Core\Entity\EntityRepositoryInterface|null $entity_repository + * The entity repository service. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, TranslationInterface $translation, ConfigFactoryInterface $config_factory, BookOutlineStorageInterface $book_outline_storage, RendererInterface $renderer) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, TranslationInterface $translation, ConfigFactoryInterface $config_factory, BookOutlineStorageInterface $book_outline_storage, RendererInterface $renderer, LanguageManagerInterface $language_manager = NULL, EntityRepositoryInterface $entity_repository = NULL) { $this->entityTypeManager = $entity_type_manager; $this->stringTranslation = $translation; $this->configFactory = $config_factory; $this->bookOutlineStorage = $book_outline_storage; $this->renderer = $renderer; + if (!$language_manager) { + @trigger_error('The language_manager service must be passed to ' . __NAMESPACE__ . '\BookManager::__construct(). It was added in drupal:9.2.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED); + $language_manager = \Drupal::service('language_manager'); + } + $this->languageManager = $language_manager; + if (!$entity_repository) { + @trigger_error('The entity.repository service must be passed to ' . __NAMESPACE__ . '\BookManager::__construct(). It was added in drupal:9.2.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED); + $entity_repository = \Drupal::service('entity.repository'); + } + $this->entityRepository = $entity_repository; } /** @@ -108,10 +140,11 @@ protected function loadBooks() { if ($nids) { $book_links = $this->bookOutlineStorage->loadMultiple($nids); + // Load nodes with proper translation. $nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids); - // @todo: Sort by weight and translated title. - - // @todo: use route name for links, not system path. + $nodes = array_map([$this->entityRepository, 'getTranslationFromContext'], $nodes); + // @todo Sort by weight and translated title. + // @todo use route name for links, not system path. foreach ($book_links as $link) { $nid = $link['nid']; if (isset($nodes[$nid]) && $nodes[$nid]->access('view')) { @@ -424,7 +457,9 @@ protected function recurseTableOfContents(array $tree, $indent, array &$toc, arr } } + // Load nodes with proper translation. $nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids); + $nodes = array_map([$this->entityRepository, 'getTranslationFromContext'], $nodes); foreach ($tree as $data) { $nid = $data['link']['nid']; @@ -476,14 +511,14 @@ public function deleteFromBook($nid) { */ public function bookTreeAllData($bid, $link = NULL, $max_depth = NULL) { $tree = &drupal_static(__METHOD__, []); - $language_interface = \Drupal::languageManager()->getCurrentLanguage(); // Use $nid as a flag for whether the data being loaded is for the whole // tree. $nid = isset($link['nid']) ? $link['nid'] : 0; - // Generate a cache ID (cid) specific for this $bid, $link, $language, and + // Generate a cache ID (cid) specific for this $bid, $link, language, and // depth. - $cid = 'book-links:' . $bid . ':all:' . $nid . ':' . $language_interface->getId() . ':' . (int) $max_depth; + $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(); + $cid = implode(':', ['book-links', $bid, 'all', $nid, $langcode, (int) $max_depth]); if (!isset($tree[$cid])) { // If the tree data was not in the static cache, build $tree_parameters. @@ -587,7 +622,9 @@ protected function buildItems(array $tree) { // Allow book-specific theme overrides. $element['attributes'] = new Attribute(); $element['title'] = $data['link']['title']; - $element['url'] = 'entity:node/' . $data['link']['nid']; + $element['url'] = Url::fromUri('entity:node/' . $data['link']['nid'], [ + 'language' => $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT), + ]); $element['localized_options'] = !empty($data['link']['localized_options']) ? $data['link']['localized_options'] : []; $element['localized_options']['set_active_class'] = TRUE; $element['below'] = $data['below'] ? $this->buildItems($data['below']) : []; @@ -666,14 +703,14 @@ protected function bookTreeBuild($bid, array $parameters = []) { protected function doBookTreeBuild($bid, array $parameters = []) { // Static cache of already built menu trees. $trees = &drupal_static(__METHOD__, []); - $language_interface = \Drupal::languageManager()->getCurrentLanguage(); // Build the cache id; sort parents to prevent duplicate storage and remove // default parameter values. if (isset($parameters['expanded'])) { sort($parameters['expanded']); } - $tree_cid = 'book-links:' . $bid . ':tree-data:' . $language_interface->getId() . ':' . hash('sha256', serialize($parameters)); + $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(); + $tree_cid = implode(':', ['book-links', $bid, 'tree-data', $langcode, hash('sha256', serialize($parameters))]); // If we do not have this tree in the static cache, check {cache_data}. if (!isset($trees[$tree_cid])) { @@ -865,7 +902,7 @@ protected function moveChildren(array $link, array $original) { if ($shift > 0) { // The order of expressions must be reversed so the new values don't // overwrite the old ones before they can be used because "Single-table - // UPDATE assignments are generally evaluated from left to right" + // UPDATE assignments are generally evaluated from left to right". // @see http://dev.mysql.com/doc/refman/5.0/en/update.html $expressions = array_reverse($expressions); } @@ -1002,13 +1039,14 @@ protected function doBookTreeCheckAccess(&$tree) { public function bookLinkTranslate(&$link) { // Check access via the api, since the query node_access tag doesn't check // for unpublished nodes. - // @todo - load the nodes en-mass rather than individually. + // @todo load the nodes en-mass rather than individually. // @see https://www.drupal.org/project/drupal/issues/2470896 $node = $this->entityTypeManager->getStorage('node')->load($link['nid']); $link['access'] = $node && $node->access('view'); // For performance, don't localize a link the user can't access. if ($link['access']) { - // The node label will be the value for the current user's language. + // The node label will be the value for the current language. + $node = $this->entityRepository->getTranslationFromContext($node); $link['title'] = $node->label(); $link['options'] = []; } @@ -1128,7 +1166,6 @@ public function bookSubtreeData($link) { // Compute the real cid for book subtree data. $tree_cid = 'book-links:subtree-data:' . hash('sha256', serialize($data)); // Cache the data, if it is not already in the cache. - if (!\Drupal::cache('data')->get($tree_cid)) { \Drupal::cache('data')->set($tree_cid, $data, Cache::PERMANENT, ['bid:' . $link['bid']]); } diff --git a/core/modules/book/src/Form/BookAdminEditForm.php b/core/modules/book/src/Form/BookAdminEditForm.php index d5d29c1cc82f5f10aeab426217cad59f2670562f..db535fdad01cd2a4fa3db76f018b17180c85fd5f 100644 --- a/core/modules/book/src/Form/BookAdminEditForm.php +++ b/core/modules/book/src/Form/BookAdminEditForm.php @@ -5,6 +5,7 @@ use Drupal\book\BookManager; use Drupal\book\BookManagerInterface; use Drupal\Component\Utility\Crypt; +use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; @@ -34,6 +35,13 @@ class BookAdminEditForm extends FormBase { */ protected $bookManager; + /** + * The entity repository service. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface + */ + protected $entityRepository; + /** * Constructs a new BookAdminEditForm. * @@ -41,10 +49,17 @@ class BookAdminEditForm extends FormBase { * The custom block storage. * @param \Drupal\book\BookManagerInterface $book_manager * The book manager. + * @param \Drupal\Core\Entity\EntityRepositoryInterface|null $entity_repository + * The entity repository service. */ - public function __construct(EntityStorageInterface $node_storage, BookManagerInterface $book_manager) { + public function __construct(EntityStorageInterface $node_storage, BookManagerInterface $book_manager, EntityRepositoryInterface $entity_repository = NULL) { $this->nodeStorage = $node_storage; $this->bookManager = $book_manager; + if (!$entity_repository) { + @trigger_error('The entity.repository service must be passed to ' . __NAMESPACE__ . '\BookAdminEditForm::__construct(). It was added in drupal:9.2.0 and will be required before drupal:10.0.0.', E_USER_DEPRECATED); + $entity_repository = \Drupal::service('entity.repository'); + } + $this->entityRepository = $entity_repository; } /** @@ -54,7 +69,8 @@ public static function create(ContainerInterface $container) { $entity_type_manager = $container->get('entity_type.manager'); return new static( $entity_type_manager->getStorage('node'), - $container->get('book.manager') + $container->get('book.manager'), + $container->get('entity.repository') ); } @@ -116,6 +132,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) { // Update the title if changed. if ($row['title']['#default_value'] != $values['title']) { $node = $this->nodeStorage->load($values['nid']); + $node = $this->entityRepository->getTranslationFromContext($node); $node->revision_log = $this->t('Title changed from %original to %current.', ['%original' => $node->label(), '%current' => $values['title']]); $node->title = $values['title']; $node->book['link_title'] = $values['title']; diff --git a/core/modules/book/tests/src/Kernel/BookMultilingualTest.php b/core/modules/book/tests/src/Kernel/BookMultilingualTest.php new file mode 100644 index 0000000000000000000000000000000000000000..38bcdcffc382d1319167a56ce05ba5d396e62c6f --- /dev/null +++ b/core/modules/book/tests/src/Kernel/BookMultilingualTest.php @@ -0,0 +1,334 @@ +<?php + +namespace Drupal\Tests\book\Kernel; + +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Link; +use Drupal\Core\Routing\RouteMatch; +use Drupal\Core\Url; +use Drupal\KernelTests\KernelTestBase; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationUrl; +use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\Tests\user\Traits\UserCreationTrait; +use Drupal\user\Plugin\LanguageNegotiation\LanguageNegotiationUser; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Route; + +/** + * Tests multilingual books. + * + * @group book + */ +class BookMultilingualTest extends KernelTestBase { + + use UserCreationTrait; + + /** + * The translation langcode. + */ + const LANGCODE = 'de'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'user', + 'node', + 'field', + 'text', + 'book', + 'language', + 'content_translation', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + // Create the translation language. + $this->installConfig(['language']); + ConfigurableLanguage::create(['id' => self::LANGCODE])->save(); + // Set up language negotiation. + $config = $this->config('language.types'); + $config->set('configurable', [ + LanguageInterface::TYPE_INTERFACE, + LanguageInterface::TYPE_CONTENT, + ]); + // The language being tested should only be available as the content + // language so subsequent tests catch errors where the interface language + // is used instead of the content language. For this, the interface + // language is set to the user language and ::setCurrentLanguage() will + // set the user language to the language not being tested. + $config->set('negotiation', [ + LanguageInterface::TYPE_INTERFACE => [ + 'enabled' => [LanguageNegotiationUser::METHOD_ID => 0], + ], + LanguageInterface::TYPE_CONTENT => [ + 'enabled' => [LanguageNegotiationUrl::METHOD_ID => 0], + ], + ]); + $config->save(); + $config = $this->config('language.negotiation'); + $config->set('url.source', LanguageNegotiationUrl::CONFIG_DOMAIN); + $config->set('url.domains', [ + 'en' => 'en.book.test.domain', + self::LANGCODE => self::LANGCODE . '.book.test.domain', + ]); + $config->save(); + $this->container->get('kernel')->rebuildContainer(); + + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installSchema('book', ['book']); + $this->installSchema('node', ['node_access']); + $this->installSchema('system', ['sequences']); + $this->installConfig(['node', 'book', 'field']); + $node_type = NodeType::create([ + 'type' => $this->randomMachineName(), + 'name' => $this->randomString(), + ]); + $node_type->save(); + $this->container->get('content_translation.manager')->setEnabled('node', $node_type->id(), TRUE); + $book_config = $this->config('book.settings'); + $allowed_types = $book_config->get('allowed_types'); + $allowed_types[] = $node_type->id(); + $book_config->set('allowed_types', $allowed_types)->save(); + // To test every possible combination of root-child / child-child, two + // trees are needed. The first level below the root needs to have two + // leaves and similarly a second level is needed with two-two leaves each: + // + // 1 + // / \ + // / \ + // 2 3 + // / \ / \ + // / \ / \ + // 4 5 6 7 + // + // These are the actual node IDs, these are enforced as auto increment is + // not reliable. + // + // Similarly, the second tree root is node 8, the first two leaves are + // 9 and 10, the third level is 11, 12, 13, 14. + for ($root = 1; $root <= 8; $root += 7) { + for ($i = 0; $i <= 6; $i++) { + /** @var \Drupal\node\NodeInterface $node */ + $node = Node::create([ + 'title' => $this->randomString(), + 'type' => $node_type->id(), + ]); + $node->addTranslation(self::LANGCODE, [ + 'title' => $this->randomString(), + ]); + switch ($i) { + case 0: + $node->book['bid'] = 'new'; + $node->book['pid'] = 0; + $node->book['depth'] = 1; + break; + + case 1: + case 2: + $node->book['bid'] = $root; + $node->book['pid'] = $root; + $node->book['depth'] = 2; + break; + + case 3: + case 4: + $node->book['bid'] = $root; + $node->book['pid'] = $root + 1; + $node->book['depth'] = 3; + break; + + case 5: + case 6: + $node->book['bid'] = $root; + $node->book['pid'] = $root + 2; + $node->book['depth'] = 3; + break; + } + // This is necessary to make the table of contents consistent across + // test runs. + $node->book['weight'] = $i; + $node->nid->value = $root + $i; + $node->enforceIsNew(); + $node->save(); + } + } + \Drupal::currentUser()->setAccount($this->createUser(['access content'])); + } + + /** + * Tests various book manager methods return correct translations. + * + * @dataProvider langcodesProvider + */ + public function testMultilingualBookManager(string $langcode) { + $this->setCurrentLanguage($langcode); + /** @var \Drupal\book\BookManagerInterface $bm */ + $bm = $this->container->get('book.manager'); + $books = $bm->getAllBooks(); + $this->assertNotEmpty($books); + foreach ($books as $book) { + $bid = $book['bid']; + $build = $bm->bookTreeOutput($bm->bookTreeAllData($bid)); + $items = $build['#items']; + $this->assertBookItemIsCorrectlyTranslated($items[$bid], $langcode); + $this->assertBookItemIsCorrectlyTranslated($items[$bid]['below'][$bid + 1], $langcode); + $this->assertBookItemIsCorrectlyTranslated($items[$bid]['below'][$bid + 1]['below'][$bid + 3], $langcode); + $this->assertBookItemIsCorrectlyTranslated($items[$bid]['below'][$bid + 1]['below'][$bid + 4], $langcode); + $this->assertBookItemIsCorrectlyTranslated($items[$bid]['below'][$bid + 2], $langcode); + $this->assertBookItemIsCorrectlyTranslated($items[$bid]['below'][$bid + 2]['below'][$bid + 5], $langcode); + $this->assertBookItemIsCorrectlyTranslated($items[$bid]['below'][$bid + 2]['below'][$bid + 6], $langcode); + $toc = $bm->getTableOfContents($bid, 4); + // Root entry does not have an indent. + $this->assertToCEntryIsCorrectlyTranslated($toc, $langcode, $bid, ''); + // The direct children of the root have one indent. + $this->assertToCEntryIsCorrectlyTranslated($toc, $langcode, $bid + 1, '--'); + $this->assertToCEntryIsCorrectlyTranslated($toc, $langcode, $bid + 2, '--'); + // Their children have two indents. + $this->assertToCEntryIsCorrectlyTranslated($toc, $langcode, $bid + 3, '----'); + $this->assertToCEntryIsCorrectlyTranslated($toc, $langcode, $bid + 4, '----'); + $this->assertToCEntryIsCorrectlyTranslated($toc, $langcode, $bid + 5, '----'); + $this->assertToCEntryIsCorrectlyTranslated($toc, $langcode, $bid + 6, '----'); + // $bid might be a string. + $this->assertSame([$bid + 0, $bid + 1, $bid + 3, $bid + 4, $bid + 2, $bid + 5, $bid + 6], array_keys($toc)); + } + } + + /** + * Tests various book breadcrumb builder methods return correct translations. + * + * @dataProvider langcodesProvider + */ + public function testMultilingualBookBreadcrumbBuilder(string $langcode) { + $this->setCurrentLanguage($langcode); + // Test a level 3 node. + $nid = 7; + /** @var \Drupal\node\NodeInterface $node */ + $node = Node::load($nid); + $route = new Route('/node/{node}'); + $route_match = new RouteMatch('entity.node.canonical', $route, ['node' => $node], ['node' => $nid]); + /** @var \Drupal\book\BookBreadcrumbBuilder $bbb */ + $bbb = $this->container->get('book.breadcrumb'); + $links = $bbb->build($route_match)->getLinks(); + $link = array_shift($links); + $rendered_link = Link::fromTextAndUrl($link->getText(), $link->getUrl())->toString(); + $this->assertStringContainsString("http://$langcode.book.test.domain/", $rendered_link); + $link = array_shift($links); + $this->assertNodeLinkIsCorrectlyTranslated(1, $link->getText(), $link->getUrl(), $langcode); + $link = array_shift($links); + $this->assertNodeLinkIsCorrectlyTranslated(3, $link->getText(), $link->getUrl(), $langcode); + $this->assertEmpty($links); + } + + /** + * Tests the book export returns correct translations. + * + * @dataProvider langcodesProvider + */ + public function testMultilingualBookExport(string $langcode) { + $this->setCurrentLanguage($langcode); + /** @var \Drupal\book\BookExport $be */ + $be = $this->container->get('book.export'); + /** @var \Drupal\book\BookManagerInterface $bm */ + $bm = $this->container->get('book.manager'); + $books = $bm->getAllBooks(); + $this->assertNotEmpty($books); + foreach ($books as $book) { + $contents = $be->bookExportHtml(Node::load($book['bid']))['#contents'][0]; + $this->assertSame($contents["#node"]->language()->getId(), $langcode); + $this->assertSame($contents["#children"][0]["#node"]->language()->getId(), $langcode); + $this->assertSame($contents["#children"][1]["#node"]->language()->getId(), $langcode); + $this->assertSame($contents["#children"][0]["#children"][0]["#node"]->language()->getId(), $langcode); + $this->assertSame($contents["#children"][0]["#children"][1]["#node"]->language()->getId(), $langcode); + $this->assertSame($contents["#children"][1]["#children"][0]["#node"]->language()->getId(), $langcode); + $this->assertSame($contents["#children"][1]["#children"][1]["#node"]->language()->getId(), $langcode); + } + } + + /** + * Data provider for ::testMultilingualBooks(). + */ + public function langcodesProvider() { + return [ + [self::LANGCODE], + ['en'], + ]; + } + + /** + * Sets the current language. + * + * @param string $langcode + * The langcode. The content language will be set to this using the + * appropriate domain while the user language will be set to something + * else so subsequent tests catch errors where the interface language + * is used instead of the content language. + */ + protected function setCurrentLanguage(string $langcode): void { + \Drupal::requestStack()->push(Request::create("http://$langcode.book.test.domain/")); + $language_manager = $this->container->get('language_manager'); + $language_manager->reset(); + $current_user = \Drupal::currentUser(); + $languages = $language_manager->getLanguages(); + unset($languages[$langcode]); + $current_user->getAccount()->set('preferred_langcode', reset($languages)->getId()); + $this->assertNotSame($current_user->getPreferredLangcode(), $langcode); + } + + /** + * Asserts a book item is correctly translated. + * + * @param array $item + * A book tree item. + * @param string $langcode + * The language code for the requested translation. + */ + protected function assertBookItemIsCorrectlyTranslated(array $item, string $langcode): void { + $this->assertNodeLinkIsCorrectlyTranslated($item['original_link']['nid'], $item['title'], $item['url'], $langcode); + } + + /** + * Asserts a node link is correctly translated. + * + * @param int $nid + * The node id. + * @param string $title + * The expected title. + * @param \Drupal\Core\Url $url + * The URL being tested. + * @param string $langcode + * The language code. + */ + protected function assertNodeLinkIsCorrectlyTranslated(int $nid, string $title, Url $url, string $langcode): void { + $node = Node::load($nid); + $this->assertSame($node->getTranslation($langcode)->label(), $title); + $rendered_link = Link::fromTextAndUrl($title, $url)->toString(); + $this->assertStringContainsString("http://$langcode.book.test.domain/node/$nid", $rendered_link); + } + + /** + * Asserts one entry in the table of contents is correct. + * + * @param array $toc + * The entire table of contents array. + * @param string $langcode + * The language code for the requested translation. + * @param int $nid + * The node ID. + * @param string $indent + * The indentation before the actual table of contents label. + */ + protected function assertToCEntryIsCorrectlyTranslated(array $toc, string $langcode, int $nid, string $indent) { + $node = Node::load($nid); + $node_label = $node->getTranslation($langcode)->label(); + $this->assertSame($indent . ' ' . $node_label, $toc[$nid]); + } + +} diff --git a/core/modules/book/tests/src/Unit/BookManagerTest.php b/core/modules/book/tests/src/Unit/BookManagerTest.php index d37b0fceac60b8d413a976e9b7830035ea343784..d452c6ec20125c83fd042972ba23f3d9b71652e9 100644 --- a/core/modules/book/tests/src/Unit/BookManagerTest.php +++ b/core/modules/book/tests/src/Unit/BookManagerTest.php @@ -19,6 +19,20 @@ class BookManagerTest extends UnitTestCase { */ protected $entityTypeManager; + /** + * The mocked language manager. + * + * @var \Drupal\Core\Language\LanguageManager|\PHPUnit\Framework\MockObject\MockObject + */ + protected $languageManager; + + /** + * The mocked entity repository. + * + * @var \Drupal\Core\Entity\EntityRepositoryInterface|\PHPUnit\Framework\MockObject\MockObject + */ + protected $entityRepository; + /** * The mocked config factory. * @@ -63,7 +77,9 @@ protected function setUp(): void { $this->configFactory = $this->getConfigFactoryStub([]); $this->bookOutlineStorage = $this->createMock('Drupal\book\BookOutlineStorageInterface'); $this->renderer = $this->createMock('\Drupal\Core\Render\RendererInterface'); - $this->bookManager = new BookManager($this->entityTypeManager, $this->translation, $this->configFactory, $this->bookOutlineStorage, $this->renderer); + $this->languageManager = $this->createMock('Drupal\Core\Language\LanguageManagerInterface'); + $this->entityRepository = $this->createMock('Drupal\Core\Entity\EntityRepositoryInterface'); + $this->bookManager = new BookManager($this->entityTypeManager, $this->translation, $this->configFactory, $this->bookOutlineStorage, $this->renderer, $this->languageManager, $this->entityRepository); } /**