diff --git a/public/modules/custom/helfi_group/helfi_group.install b/public/modules/custom/helfi_group/helfi_group.install index ba9ba29eb..dc85fd323 100644 --- a/public/modules/custom/helfi_group/helfi_group.install +++ b/public/modules/custom/helfi_group/helfi_group.install @@ -7,9 +7,11 @@ declare(strict_types=1); +use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Url; use Drupal\menu_link_content\Entity\MenuLinkContent; use Drupal\node\Entity\Node; +use Drupal\pathauto\PathautoState; /** * Update group menu item's content translation status to match node's status. @@ -52,3 +54,53 @@ function helfi_group_update_9001(): void { } } } + +/** + * Update path aliases for group news items. + */ +function helfi_group_update_9002(&$sandbox): void { + $query = \Drupal::entityQuery('node') + ->condition('type', 'news_item') + ->accessCheck(FALSE); + $result = $query->execute(); + + if (!$result) { + return; + } + + // Use the sandbox to store the information needed to track progression. + if (!isset($sandbox['current'])) { + // The count of entities visited so far. + $sandbox['current'] = 0; + // Total entities that must be visited. + $sandbox['max'] = count($result); + } + + // Process 50 entities at a time. + $limit = 50; + $nids = array_slice($result, $sandbox['current'], $limit); + $nodes = Node::loadMultiple($nids); + $languageManager = \Drupal::languageManager(); + foreach ($nodes as $node) { + // Set current languages to match node's language for proper + // token replacement. + $languageManager->setCurrentLanguage($node->language(), LanguageInterface::TYPE_CONTENT); + $languageManager->setCurrentLanguage($node->language(), LanguageInterface::TYPE_URL); + + // Update Entity URL alias. + $path_value = $node->get('path')->getValue(); + $path_value = $path_value ?: [[]]; + $path_value[0]['pathauto'] = PathautoState::CREATE; + $node->set('path', $path_value); + \Drupal::service('pathauto.generator')->updateEntityAlias($node, 'bulkupdate', ['message' => TRUE]); + + // Update our progress information. + $sandbox['current']++; + } + + $sandbox['#finished'] = empty($sandbox['max']) ? 1 : ($sandbox['current'] / $sandbox['max']); + + if ($sandbox['#finished'] >= 1) { + \Drupal::messenger()->addMessage('Url alias updated for ' . $sandbox['current'] . ' news items.'); + } +} diff --git a/public/modules/custom/helfi_group/helfi_group.tokens.inc b/public/modules/custom/helfi_group/helfi_group.tokens.inc index 4afd799ef..1d3885bef 100644 --- a/public/modules/custom/helfi_group/helfi_group.tokens.inc +++ b/public/modules/custom/helfi_group/helfi_group.tokens.inc @@ -78,10 +78,19 @@ function helfi_group_tokens($type, $tokens, array $data, array $options, Bubblea break; } + // Resolve the path to its alias so the token works in both normal + // requests and during update hooks. In update hooks the path_alias + // outbound processor is disabled (see UpdateServiceProvider), so + // toUrl('canonical')->toString() would return the internal path + // (e.g. node/123). Using the alias manager directly returns the + // alias in both contexts. + $internal_path = $newsParent->toUrl('canonical')->getInternalPath(); + $path = \Drupal::service('path_alias.manager')->getAliasByPath("/$internal_path", $langcode); + // Set the token replacement using the news parent path, but filtering // out langcode and site prefix sections. $replacements[$original] = _helfi_group_filter_path( - $newsParent->toUrl('canonical')->toString(), + $path, [ $langcode, _helfi_group_get_site_prefix($langcode), diff --git a/public/modules/custom/helfi_group/src/Hook/BlockAlter.php b/public/modules/custom/helfi_group/src/Hook/BlockAlter.php new file mode 100644 index 000000000..060c07acb --- /dev/null +++ b/public/modules/custom/helfi_group/src/Hook/BlockAlter.php @@ -0,0 +1,28 @@ +routeMatch = $route_match; + $this->menuLinkManager = $menu_link_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('menu.link_tree'), + $container->get('menu.active_trail'), + $container->get('entity_type.manager'), + $container->get('current_route_match'), + $container->get('plugin.manager.menu.link'), + ); + } + + /** + * {@inheritdoc} + */ + public function build() { + $menu_name = $this->getMenuName(); + // If unable to determine the menu, prevent the block from rendering. + if (!$menu_name) { + return []; + } + + // Get block configuration and menu tree parameters. + $level = $this->configuration['level']; + $depth = $this->configuration['depth']; + $relative_visibility = $this->configuration['relative_visibility'] ?? FALSE; + $parameters = $this->menuTree->getCurrentRouteMenuTreeParameters($menu_name); + + // If active trail is empty, we might be on a group news item, that + // is not included in the menu tree. This fallback handling for empty + // active trail is the only abstraction from the parent class build method. + if (empty(array_filter($parameters->activeTrail))) { + $parameters->setActiveTrail($this->getActiveTrailForNewsItem($menu_name)); + } + + // Adjust the menu tree parameters based on the block's configuration. + $parameters->setMinDepth($level); + + // Adjust menu root in cases where the active menu item is below root and + // only a subset of the full menu is to be shown, or possibly not at all. + $relative_level = count($parameters->activeTrail); + if ($level > 1 || ($relative_visibility && $relative_level > $level)) { + $menu_trail_ids = array_reverse(array_values($parameters->activeTrail)); + // For relative visibility we reset the root relative to the active + // menu item. + if ($relative_visibility) { + $menu_root = $menu_trail_ids[$relative_level - 2]; + $parameters->setRoot($menu_root); + } + // For absolute visibility, we reset root relative to the original and + // adjust the minimum depth. + elseif ($relative_level >= $level) { + $menu_root = $menu_trail_ids[$level - 1]; + $parameters->setRoot($menu_root)->setMinDepth(1); + } + // If the active menu item is not at or above the visibility level, and + // relative visibility is not in play, then do not show the menu. + else { + return []; + } + } + + // When the depth is configured to zero, there is no depth limit. When depth + // is non-zero, it indicates the number of levels that must be displayed. + // Hence, this is a relative depth that we must convert to an actual + // (absolute) depth, that may never exceed the maximum depth. + if ($depth > 0) { + $relative_depth = $relative_visibility ? $level + $depth - 1 : $depth; + $parameters->setMaxDepth(\min($relative_depth, $this->menuTree->maxDepth())); + } + + // If expandedParents is empty, the whole menu tree is built. + if ($this->configuration['expand_all_items']) { + $parameters->expandedParents = []; + } + + $tree = $this->menuTree->load($menu_name, $parameters); + $tree = $this->menuTree->transform($tree, $this->getMenuManipulators()); + $build = $this->menuTree->build($tree); + $menu_instance = $this->getMenuInstance(); + $build['#group_content_menu'] = [ + 'menu_name' => $menu_name, + 'theme_hook_suggestion' => $this->configuration['theme_hook_suggestion'], + ]; + if ($menu_instance instanceof GroupContentMenuInterface) { + $build['#group_content_menu']['group_content_menu_type'] = $menu_instance->bundle(); + $build['#contextual_links']['group_menu'] = [ + 'route_parameters' => [ + 'group' => $this->getContext('group')->getContextData()->getValue()->id(), + 'group_content_menu' => $menu_instance->id(), + ], + ]; + + } + $build['#theme'] = 'menu'; + return $build; + } + + /** + * Get the active trail for a news item node. + * + * @param string $menu_name + * The menu name. + * + * @return array + * The active trail. + */ + private function getActiveTrailForNewsItem(string $menu_name): array { + $active_trail = ['' => '']; + + // Fetch current node and only continue if it's a news_item. + $node = $this->routeMatch->getParameter('node'); + if (!$node || !($node instanceof NodeInterface) || $node->bundle() !== 'news_item') { + return $active_trail; + } + + // Fetch current group from plugin context. + $group = $this->getContextValue('group'); + if (!$group || !($group instanceof GroupInterface)) { + return $active_trail; + } + + // Fetch news parent from group. + $newsParent = NULL; + if ($group->hasField('field_group_news_parent')) { + $newsParent = $group->get('field_group_news_parent')->entity; + } + if (!$newsParent || !($newsParent instanceof NodeInterface)) { + return $active_trail; + } + + // Build active trail from news parent. + $found = NULL; + $links = []; + $route_name = $newsParent->toUrl()->getRouteName(); + if ($route_name) { + $route_parameters = $newsParent->toUrl()->getRouteParameters(); + + // Load links matching this route. + $links = $this->menuLinkManager->loadLinksByRoute($route_name, $route_parameters, $menu_name); + } + if ($links) { + $found = reset($links); + } + if ($found && $parents = $this->menuLinkManager->getParentIds($found->getPluginId())) { + $active_trail = $parents + $active_trail; + } + + return $active_trail; + } + +} diff --git a/public/modules/custom/helfi_group/tests/src/Kernel/EventSubscriber/GroupMenuFilterByLanguageTest.php b/public/modules/custom/helfi_group/tests/src/Kernel/EventSubscriber/GroupMenuFilterByLanguageTest.php index b22a2cd76..40b676056 100644 --- a/public/modules/custom/helfi_group/tests/src/Kernel/EventSubscriber/GroupMenuFilterByLanguageTest.php +++ b/public/modules/custom/helfi_group/tests/src/Kernel/EventSubscriber/GroupMenuFilterByLanguageTest.php @@ -43,9 +43,12 @@ class GroupMenuFilterByLanguageTest extends EntityKernelTestBase { 'content_translation', 'link', 'helfi_group', + 'helfi_tpr', 'menu_block_current_language', 'language', 'locale', + 'flexible_permissions', + 'group', ]; /** diff --git a/public/modules/custom/helfi_group/tests/src/Kernel/Plugin/Block/HelfiGroupMenuBlockTest.php b/public/modules/custom/helfi_group/tests/src/Kernel/Plugin/Block/HelfiGroupMenuBlockTest.php new file mode 100644 index 000000000..16201d06c --- /dev/null +++ b/public/modules/custom/helfi_group/tests/src/Kernel/Plugin/Block/HelfiGroupMenuBlockTest.php @@ -0,0 +1,428 @@ + 2, + 'depth' => 0, + 'expand_all_items' => FALSE, + 'relative_visibility' => FALSE, + 'theme_hook_suggestion' => '', + ]; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block', + 'system', + 'field', + 'user', + 'node', + 'menu_link_content', + 'link', + 'flexible_permissions', + 'group', + 'group_content_menu', + 'helfi_group', + 'helfi_tpr', + 'language', + 'locale', + 'menu_block_current_language', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installConfig(['system']); + $this->installEntitySchema('group'); + $this->installEntitySchema('group_relationship'); + $this->installConfig(['group']); + $this->installEntitySchema('group_content_menu'); + $this->installEntitySchema('menu_link_content'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + + // Set current user so Group::postSave() can add the creator as a member. + $this->setCurrentUser($this->createUser()); + + // Create the group content menu type that BlockAlter replaces with + // HelfiGroupMenuBlock. + GroupContentMenuType::create([ + 'id' => 'kasko_group_menu', + 'label' => 'Kasko group menu', + ])->save(); + + // Create node types for getActiveTrailForNewsItem tests. + NodeType::create(['type' => 'news_item', 'name' => 'News item'])->save(); + NodeType::create(['type' => 'page', 'name' => 'Page'])->save(); + + // Clear block plugin cache so the derivative and block_alter are applied. + $this->container->get('plugin.manager.block')->clearCachedDefinitions(); + } + + /** + * Tests that build() returns empty when group context is missing. + */ + public function testBuildWithoutGroupContext(): void { + $block = $this->createBlock(); + + $this->assertSame([], $block->build()); + } + + /** + * Tests that build() returns empty when group has no menu. + */ + public function testBuildWithGroupWithoutMenu(): void { + $group_type = $this->createGroupType(); + $group = $this->createGroup(['type' => $group_type->id()]); + + $block = $this->createBlock($group); + + $this->assertSame([], $block->build()); + } + + /** + * Tests that build() returns menu render array when group has a menu. + * + * With level 2 the block requires a non-empty active trail. We add a menu + * link and simulate being on that page so the trail is set and the menu + * builds. + */ + public function testBuildWithGroupAndMenu(): void { + [$group, $menu_name, $menu] = $this->setUpGroupWithMenu(); + $page = Node::create(['type' => 'page', 'title' => 'Group page']); + $page->save(); + MenuLinkContent::create([ + 'title' => 'Group page', + 'link' => ['uri' => 'entity:node/' . $page->id()], + 'menu_name' => $menu_name, + ])->save(); + $this->pushRequestWithNode($page); + + $block = $this->createBlock($group); + $build = $block->build(); + + $this->assertNotEmpty($build); + $this->assertSame('menu', $build['#theme']); + $this->assertSame($menu_name, $build['#group_content_menu']['menu_name']); + $this->assertSame('kasko_group_menu', $build['#group_content_menu']['group_content_menu_type']); + $this->assertArrayHasKey('group_menu', $build['#contextual_links']); + $this->assertSame($group->id(), $build['#contextual_links']['group_menu']['route_parameters']['group']); + $this->assertSame($menu->id(), $build['#contextual_links']['group_menu']['route_parameters']['group_content_menu']); + } + + /** + * Tests that the block uses HelfiGroupMenuBlock class (via block_alter). + */ + public function testBlockUsesHelfiGroupMenuBlockClass(): void { + $block = $this->createBlock(); + + $this->assertInstanceOf(HelfiGroupMenuBlock::class, $block); + } + + /** + * Tests getActiveTrailForNewsItem: empty trail when route has no node. + * + * With level 2 and no node in route, active trail stays ['' => ''], so + * relative_level < level and build() returns [] (no fallback applies). + */ + public function testBuildWithEmptyTrailAndNoNodeInRoute(): void { + [$group] = $this->setUpGroupWithMenu(); + $this->pushRequestWithRoute('user.login', '/user/login', []); + + $build = $this->createBlock($group)->build(); + + $this->assertSame([], $build); + } + + /** + * Tests getActiveTrailForNewsItem: empty trail when node is not news_item. + * + * Fallback only runs for news_item; for other bundles it returns ['' => '']. + * With level 2, relative_level stays 1 so build() returns []. + */ + public function testBuildWithEmptyTrailAndNonNewsItemNode(): void { + [$group] = $this->setUpGroupWithMenu(); + $page_node = Node::create(['type' => 'page', 'title' => 'A page']); + $page_node->save(); + $this->pushRequestWithNode($page_node); + + $build = $this->createBlock($group)->build(); + + $this->assertSame([], $build); + } + + /** + * Tests getActiveTrailForNewsItem: empty trail when group context is missing. + * + * Without group context the block has no menu name and returns [] early. + */ + public function testBuildWithEmptyTrailAndNewsItemWithoutGroupContext(): void { + $this->setUpGroupWithMenu(); + $news_item = Node::create(['type' => 'news_item', 'title' => 'News']); + $news_item->save(); + $this->pushRequestWithNode($news_item); + + $this->assertSame([], $this->createBlock()->build()); + } + + /** + * Empty trail when group has no news parent field. + * + * Tests getActiveTrailForNewsItem fallback. Fallback returns ['' => ''] + * when group has no field_group_news_parent. With level 2, build() + * returns []. + */ + public function testBuildWithEmptyTrailAndNewsItemGroupWithoutNewsParentField(): void { + [$group] = $this->setUpGroupWithMenu(); + $news_item = Node::create(['type' => 'news_item', 'title' => 'News']); + $news_item->save(); + $this->pushRequestWithNode($news_item); + + $build = $this->createBlock($group)->build(); + + $this->assertSame([], $build); + } + + /** + * Tests getActiveTrailForNewsItem: trail from news parent when in menu. + * + * With level 2, an empty trail would cause build() to return []. The fallback + * resolves the news parent link in the menu and sets the active trail, so + * relative_level >= level and the menu is rendered. + */ + public function testBuildWithEmptyTrailAndNewsItemWithNewsParentInMenu(): void { + [$group, $menu_name] = $this->setUpGroupWithMenuAndNewsParent(); + $news_item = Node::create(['type' => 'news_item', 'title' => 'A news item']); + $news_item->save(); + $this->pushRequestWithNode($news_item); + + $build = $this->createBlock($group)->build(); + + $this->assertNotEmpty($build); + $this->assertSame('menu', $build['#theme']); + $this->assertArrayHasKey('#group_content_menu', $build); + $this->assertSame($menu_name, $build['#group_content_menu']['menu_name']); + } + + /** + * Sets up a group with a kasko group menu; returns group, menu name, menu. + * + * @return array{0: \Drupal\group\Entity\GroupInterface, 1: string, + * 2: \Drupal\group_content_menu\Entity\GroupContentMenu} + * Group, menu name and menu entity. + */ + private function setUpGroupWithMenu(): array { + $group_type = $this->createGroupType(); + $relationship_type_storage = $this->getRelationshipTypeStorage(); + $relationship_type_storage->createFromPlugin($group_type, self::BLOCK_PLUGIN_ID)->save(); + + $group = $this->createGroup(['type' => $group_type->id()]); + $menu = GroupContentMenu::create(['label' => 'Test menu', 'bundle' => 'kasko_group_menu']); + $menu->save(); + + $this->attachMenuToGroup($group_type, $group, $menu); + + $menu_name = GroupContentMenuInterface::MENU_PREFIX . $menu->id(); + Menu::create(['id' => $menu_name, 'label' => 'Test', 'description' => ''])->save(); + + return [$group, $menu_name, $menu]; + } + + /** + * Sets up a group with menu and a news parent node (field + menu link). + * + * @return array{0: \Drupal\group\Entity\GroupInterface, 1: string} + * Group and menu name. + */ + private function setUpGroupWithMenuAndNewsParent(): array { + $group_type = $this->createGroupType(); + + FieldStorageConfig::create([ + 'field_name' => 'field_group_news_parent', + 'entity_type' => 'group', + 'type' => 'entity_reference', + 'cardinality' => 1, + 'settings' => ['target_type' => 'node'], + ])->save(); + FieldConfig::create([ + 'field_name' => 'field_group_news_parent', + 'entity_type' => 'group', + 'bundle' => $group_type->id(), + 'label' => 'News parent', + 'settings' => ['handler_settings' => ['target_bundles' => ['page' => 'page']]], + ])->save(); + + $news_parent = Node::create(['type' => 'page', 'title' => 'News parent page']); + $news_parent->save(); + + $relationship_type_storage = $this->getRelationshipTypeStorage(); + $relationship_type_storage->createFromPlugin($group_type, self::BLOCK_PLUGIN_ID)->save(); + + $group = $this->createGroup(['type' => $group_type->id()]); + $group->set('field_group_news_parent', $news_parent->id()); + $group->save(); + + $menu = GroupContentMenu::create(['label' => 'Test menu', 'bundle' => 'kasko_group_menu']); + $menu->save(); + $this->attachMenuToGroup($group_type, $group, $menu); + + $menu_name = GroupContentMenuInterface::MENU_PREFIX . $menu->id(); + Menu::create(['id' => $menu_name, 'label' => 'Test', 'description' => ''])->save(); + + MenuLinkContent::create([ + 'title' => 'News parent', + 'link' => ['uri' => 'entity:node/' . $news_parent->id()], + 'menu_name' => $menu_name, + ])->save(); + + return [$group, $menu_name]; + } + + /** + * Creates the block plugin. + * + * Optionally with group context and configuration overrides. + * + * @param \Drupal\group\Entity\GroupInterface|null $group + * The group to set as context, or NULL for no context. + * @param array $configuration + * Configuration overrides (merged with block defaults). Use for level/depth + * when testing getActiveTrailForNewsItem behavior. + * + * @return \Drupal\helfi_group\Plugin\Block\HelfiGroupMenuBlock + * The block plugin instance. + */ + private function createBlock( + ?GroupInterface $group = NULL, + array $configuration = [], + ): HelfiGroupMenuBlock { + $block = $this->container->get('plugin.manager.block')->createInstance( + self::BLOCK_PLUGIN_ID, + $configuration + self::DEFAULT_BLOCK_CONFIG + ); + if ($group !== NULL) { + $block->setContextValue('group', $group); + } + return $block; + } + + /** + * Pushes a request with the given route and raw parameters. + */ + private function pushRequestWithRoute(string $route_name, string $path, array $raw_variables): void { + $route = $this->container->get('router.route_provider')->getRouteByName($route_name); + $request = Request::create($path); + $request->attributes->set('_route', $route_name); + $request->attributes->set('_route_object', $route); + $request->attributes->set('_raw_variables', new InputBag($raw_variables)); + $this->copySessionToRequest($request); + $this->container->get('request_stack')->push($request); + } + + /** + * Returns the group relationship type storage. + */ + private function getRelationshipTypeStorage(): GroupRelationshipTypeStorageInterface { + $storage = $this->entityTypeManager->getStorage('group_relationship_type'); + assert($storage instanceof GroupRelationshipTypeStorageInterface); + return $storage; + } + + /** + * Attaches the given group content menu to the group via a relationship. + */ + private function attachMenuToGroup(GroupTypeInterface $group_type, GroupInterface $group, GroupContentMenu $menu): void { + $relationship_type_id = $this->getRelationshipTypeStorage()->getRelationshipTypeId($group_type->id(), self::BLOCK_PLUGIN_ID); + $this->entityTypeManager->getStorage('group_relationship')->create([ + 'type' => $relationship_type_id, + 'gid' => $group->id(), + 'label' => 'Test menu', + 'entity_id' => $menu, + ])->save(); + } + + /** + * Pushes a request onto the stack that has the given node as route parameter. + * + * Uses the real entity.node.canonical route and raw parameters so URL + * generation (e.g. in PathMatcher) works. Copies session so tearDown does + * not fail. + * + * @param \Drupal\node\Entity\Node $node + * The node to set as 'node' parameter. + */ + private function pushRequestWithNode(Node $node): void { + $route = $this->container->get('router.route_provider')->getRouteByName('entity.node.canonical'); + $request = Request::create('/node/' . $node->id()); + $request->attributes->set('_route', 'entity.node.canonical'); + $request->attributes->set('_route_object', $route); + $request->attributes->set('node', $node); + $request->attributes->set('_raw_variables', new InputBag(['node' => $node->id()])); + $this->copySessionToRequest($request); + $this->container->get('request_stack')->push($request); + } + + /** + * Copies the current request's session onto the given request. + * + * Prevents SessionNotFoundException in tearDown when tests push a new + * request. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request to attach the session to. + */ + private function copySessionToRequest(Request $request): void { + $current = $this->container->get('request_stack')->getCurrentRequest(); + if ($current && $current->hasSession()) { + $request->setSession($current->getSession()); + } + } + +}