diff --git a/README.md b/README.md index 0d327ca..506d082 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,31 @@ # LocalGov Drupal Blogs -Provides blog channel and blog post content types with related views for LocalGov Drupal. +This module provides blog channel and blog post content types with related views for the LocalGov Drupal distribution. + +## Content types + +The content types this module provides are: +- Blog channel +- Blog post + +## Other features + +The module includes an optional Previous / Next Navigation block - called Blog Previous Next block - which can be added to any region using block layout +Normally added to the `Content bottom` region and restricted to just `Blog post` content types + +## Installing + +You can install this module with the following composer command. + +``` +composer require localgovdrupal/localgov_blogs:^1.0.0 +``` + +## Issues + +If you run into issues using this module, please report them at https://github.com/localgovdrupal/localgov_blogss/issues + +## Maintainers + +TBC diff --git a/config/install/field.field.node.localgov_blog_channel.localgov_blog_posts.yml b/config/install/field.field.node.localgov_blog_channel.localgov_blog_posts.yml new file mode 100644 index 0000000..83b1bf4 --- /dev/null +++ b/config/install/field.field.node.localgov_blog_channel.localgov_blog_posts.yml @@ -0,0 +1,28 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.localgov_blog_posts + - node.type.localgov_blog_channel + - node.type.localgov_blog_post +id: node.localgov_blog_channel.localgov_blog_posts +field_name: localgov_blog_posts +entity_type: node +bundle: localgov_blog_channel +label: localgov_blog_posts +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:node' + handler_settings: + target_bundles: + localgov_blog_post: localgov_blog_post + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' +field_type: entity_reference diff --git a/config/install/field.storage.node.localgov_blog_posts.yml b/config/install/field.storage.node.localgov_blog_posts.yml new file mode 100644 index 0000000..9cc662b --- /dev/null +++ b/config/install/field.storage.node.localgov_blog_posts.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: + module: + - node +id: node.localgov_blog_posts +field_name: localgov_blog_posts +entity_type: node +type: entity_reference +settings: + target_type: node +module: core +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/optional/block.block.localgov_blogs_prev_next_block_base.yml b/config/optional/block.block.localgov_blogs_prev_next_block_base.yml new file mode 100644 index 0000000..10865f6 --- /dev/null +++ b/config/optional/block.block.localgov_blogs_prev_next_block_base.yml @@ -0,0 +1,19 @@ +langcode: en +status: true +dependencies: + module: + - localgov_blogs + theme: + - localgov_base +id: localgov_blogs_prev_next_block_base +theme: localgov_base +region: content_bottom +weight: -6 +provider: null +plugin: localgov_blogs_prev_next_block +settings: + id: localgov_blogs_prev_next_block + label: 'Blogs prev next block' + provider: localgov_blogs + label_display: '0' +visibility: { } diff --git a/config/schema/localgov_blogs_prev_next_block_base.schema.yml b/config/schema/localgov_blogs_prev_next_block_base.schema.yml new file mode 100644 index 0000000..82bf2a4 --- /dev/null +++ b/config/schema/localgov_blogs_prev_next_block_base.schema.yml @@ -0,0 +1,7 @@ +block.settings.localgov_blogs_prev_next_block: + type: block_settings + label: 'Previous Next button block settings' + mapping: + show_title: + type: boolean + label: 'Show title' diff --git a/localgov_blogs.module b/localgov_blogs.module index 8722c1d..93e8a77 100644 --- a/localgov_blogs.module +++ b/localgov_blogs.module @@ -13,6 +13,23 @@ use Drupal\node\NodeForm; use Drupal\node\NodeInterface; use Drupal\views\Views; +/** + * Implements hook_theme(). + */ +function localgov_blogs_theme($existing, $type, $theme, $path) { + return [ + 'localgov_blogs_prev_next_block' => [ + 'variables' => [ + 'previous_url' => NULL, + 'previous_title' => NULL, + 'next_url' => NULL, + 'next_title' => NULL, + 'show_title' => NULL, + ], + ], + ]; +} + /** * Implements hook_modules_installed(). */ diff --git a/src/Plugin/Block/BlogPrevNextBlock.php b/src/Plugin/Block/BlogPrevNextBlock.php new file mode 100644 index 0000000..6611b35 --- /dev/null +++ b/src/Plugin/Block/BlogPrevNextBlock.php @@ -0,0 +1,243 @@ +get('current_route_match'), + $container->get('entity_type.manager') + ); + } + + /** + * Creates a PrevNextBlock instance. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + */ + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + RouteMatchInterface $route_match, + EntityTypeManagerInterface $entityTypeManager, + ) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->routeMatch = $route_match; + $this->entityTypeManager = $entityTypeManager; + if ($this->routeMatch->getParameter('node')) { + $this->node = $this->routeMatch->getParameter('node'); + if (!$this->node instanceof NodeInterface) { + $node_storage = $this->entityTypeManager->getStorage('node'); + $this->node = $node_storage->load($this->node); + } + } + } + + /** + * {@inheritdoc} + */ + public function build() { + + $node = $this->routeMatch->getParameter('node'); + $previous_url = ''; + $previous_title = ''; + $next_url = ''; + $next_title = ''; + + if ($node instanceof NodeInterface && $node->getType() == 'localgov_blog_post') { + + $prev = $this->generatePrevious($node); + if (!empty($prev)) { + $previous_title = $prev['title']; + $previous_url = $prev['url']; + } + + $next = $this->generateNext($node); + if (!empty($next)) { + $next_title = $next['title']; + $next_url = $next['url']; + } + } + + return [ + '#theme' => 'localgov_blogs_prev_next_block', + '#previous_url' => $previous_url, + '#previous_title' => $previous_title, + '#next_url' => $next_url, + '#next_title' => $next_title, + ]; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + // Get the created time of the current node. + $node = $this->routeMatch->getParameter('node'); + if (!empty($node) && $node instanceof NodeInterface) { + // If there is node add its cachetag. + return Cache::mergeTags(parent::getCacheTags(), ['node:' . $node->id()]); + } + else { + // Return default tags instead. + return parent::getCacheTags(); + } + } + + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + return Cache::mergeContexts(parent::getCacheContexts(), ['route']); + } + + /** + * Lookup the previous node,youngest node which is still older than the node. + * + * @param string $node + * Show current page node id. + * + * @return array + * A render array for a previous node. + */ + private function generatePrevious($node) { + return $this->generateNextPrevious($node, 'prev'); + } + + /** + * Lookup the next node,oldest node which is still younger than the node. + * + * @param string $node + * Show current page node id. + * + * @return array + * A render array for a next node. + */ + private function generateNext($node) { + return $this->generateNextPrevious($node, 'next'); + } + + const DIRECTION__NEXT = 'next'; + + /** + * Lookup the next or previous node. + * + * @param string $node + * Get current page node id. + * @param string $direction + * Default value is "next" and other value come from + * generatePrevious() and generatePrevious(). + * + * @return array + * Find the alias of the next node. + */ + private function generateNextPrevious($node, $direction = self::DIRECTION__NEXT) { + $comparison_operator = '>='; + $sort = 'ASC'; + $current_node_id = $node->id(); + $current_node_date = $node->get('localgov_blog_date')->value; + $current_langcode = $node->get('langcode')->value; + $current_blog_channel = $node->get('localgov_blog_channel')->target_id; + + if ($direction === 'prev') { + $comparison_operator = '<='; + $sort = 'DESC'; + } + + // Lookup 1 node younger (or older) than the current node + // based upon the `localgov_blog_date` field. + $storage = $this->entityTypeManager->getStorage('node'); + $query_result = $storage->getQuery(); + $results = $query_result->condition('localgov_blog_date', $current_node_date, $comparison_operator) + ->condition('type', 'localgov_blog_post') + ->condition('localgov_blog_channel', $current_blog_channel) + ->condition('status', 1) + ->condition('langcode', $current_langcode) + ->sort('localgov_blog_date', $sort) + ->sort('created', $sort) + ->sort('nid', $sort) + ->accessCheck(TRUE) + ->execute(); + + // Since sometimes the next / prev blog post could be on the same day, and + // the query will not have sufficent granularity yet, we need to retrive + // an array of all following / previous including the current post. + // We can then search in the array and find the next one in the list. + // @todo possibly remove if localgov_blog_date changed to a datetime field. + $results = array_values($results); + $index = array_search($current_node_id, $results, TRUE); + $result = $results[$index + 1] ?? NULL; + + // If this is not the youngest (or oldest) node. + if (!empty($result)) { + $node = $storage->load($result); + + return [ + 'title' => $node->get('title')->value, + 'url' => $node->toUrl()->toString(), + ]; + } + return ''; + } + +} diff --git a/templates/localgov-blogs-prev-next-block.html.twig b/templates/localgov-blogs-prev-next-block.html.twig new file mode 100644 index 0000000..7e3548d --- /dev/null +++ b/templates/localgov-blogs-prev-next-block.html.twig @@ -0,0 +1,25 @@ +{# +/** +* @file +* Default theme implementation for the localgov_blogs_prev_next_block block. +*/ +#} + diff --git a/tests/src/Functional/PrevNextBlockTest.php b/tests/src/Functional/PrevNextBlockTest.php new file mode 100644 index 0000000..cdb3171 --- /dev/null +++ b/tests/src/Functional/PrevNextBlockTest.php @@ -0,0 +1,166 @@ +adminUser = $this->drupalCreateUser(['administer blocks']); + $this->drupalLogin($this->adminUser); + $this->drupalPlaceBlock('localgov_blogs_prev_next_block'); + $this->drupalLogout(); + } + + /** + * Test the Previous / Next Navigation block. + */ + public function testPrevNextBlock() { + + // Create channel_one. + $channel_one = $this->createNode([ + 'title' => 'Blog channel_one', + 'type' => 'localgov_blog_channel_one', + 'status' => NodeInterface::PUBLISHED, + ]); + + // Create channel_two. + $channel_two = $this->createNode([ + 'title' => 'Blog channel_two', + 'type' => 'localgov_blog_channel_two', + 'status' => NodeInterface::PUBLISHED, + ]); + + // Create 6 posts. + // Alternate them between channel one and two. + $posts = []; + for ($i = 1; $i <= 6; $i++) { + $posts[$i] = $this->createNode([ + 'title' => 'Blog post ' . $i, + 'type' => 'localgov_blog_post', + 'localgov_blog_date' => '2024-05-1' . $i, + 'status' => NodeInterface::PUBLISHED, + 'localgov_blog_channel' => ['target_id' => ($i % 2 !== 0 ? $channel_one->id() : $channel_two->id())], + ]); + } + + // Test Navigation. + for ($i = 1; $i <= 6; $i++) { + $this->drupalGet($posts[$i]->toUrl()->toString()); + + // Post one and two will not have a prev link. + if ($i <= 2) { + $this->assertSession()->pageTextNotContains('Prev'); + } + else { + $this->assertSession()->pageTextContains('Prev'); + } + + // Post five and six will not have a next link. + if ($i >= 5) { + $this->assertSession()->pageTextNotContains('Next'); + } + else { + $this->assertSession()->pageTextContains('Next'); + } + + // Test the prev link is to the post two before, since posts will + // alternate channels this tests the prev / next links target the correct + // blog channel. + $prev = $i - 2; + if ($prev >= 1) { + $this->assertSession()->responseContains($posts[$prev]->toUrl()->toString()); + } + $next = $i + 2; + if ($next <= 6) { + $this->assertSession()->responseContains($posts[$next]->toUrl()->toString()); + } + + } + } + + /** + * Test the correct next / prev posts appear when all the same date. + */ + public function testPrevNextWithSameDate() { + + // Create a channel. + $channel = $this->createNode([ + 'title' => 'Blog channel_one', + 'type' => 'localgov_blog_channel_one', + 'status' => NodeInterface::PUBLISHED, + ]); + + // Create 6 posts, all on same date. + $posts = []; + for ($i = 1; $i <= 6; $i++) { + $posts[$i] = $this->createNode([ + 'title' => 'Blog post ' . $i, + 'type' => 'localgov_blog_post', + 'localgov_blog_date' => date('Y-m-d'), + 'status' => NodeInterface::PUBLISHED, + 'localgov_blog_channel' => ['target_id' => $channel->id()], + 'created' => strtotime('Midnight +' . $i . ' hour'), + 'changed' => strtotime('Midnight +' . $i . ' hour'), + ]); + } + + // Loop through each post to make sure next / prev has them in order. + // This is done via xpath to test the correct link. + for ($i = 1; $i <= 6; $i++) { + $this->drupalGet($posts[$i]->toUrl()->toString()); + $prev = $i - 1; + if ($prev >= 1) { + $prev_query = $this->xpath('.//*[contains(concat(" ",normalize-space(@class)," ")," localgov-blog-navigation__previous ")]'); + $prev_link = $prev_query[0]->getAttribute('href'); + $this->assertEquals($posts[$prev]->toUrl()->toString(), $prev_link); + } + $next = $i + 1; + if ($next <= 6) { + $next_query = $this->xpath('.//*[contains(concat(" ",normalize-space(@class)," ")," localgov-blog-navigation__next ")]'); + $next_link = $next_query[0]->getAttribute('href'); + $this->assertEquals($posts[$next]->toUrl()->toString(), $next_link); + } + } + } + +}