From 7956a0b623dc5d1a414fe44a0fa6dac0a3cd83d2 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 12 Aug 2026 09:41:26 +1000 Subject: [PATCH 1/3] Placed the related posts list as a block scoped by path visibility. --- .../block.block.drevops_related_posts.yml | 28 +++++ docs/related-content.md | 22 +++- ...ed.feature => block_related_posts.feature} | 58 ++++++---- web/modules/custom/do_base/do_base.deploy.php | 106 +++++++++++++----- .../drevops/includes/automated_list.inc | 6 +- 5 files changed, 162 insertions(+), 58 deletions(-) create mode 100644 config/default/block.block.drevops_related_posts.yml rename tests/behat/features/{paragraph_civictheme_automated_list_related.feature => block_related_posts.feature} (52%) diff --git a/config/default/block.block.drevops_related_posts.yml b/config/default/block.block.drevops_related_posts.yml new file mode 100644 index 00000000..a45ec2b8 --- /dev/null +++ b/config/default/block.block.drevops_related_posts.yml @@ -0,0 +1,28 @@ +uuid: 0672b3ef-3a1c-433f-b72c-7662a0727809 +langcode: en +status: true +dependencies: + content: + - 'block_content:civictheme_component_block:c1c2b47c-4c96-4f9e-9b6e-2b1f5f3d7e10' + module: + - block_content + - system + theme: + - drevops +id: drevops_related_posts +theme: drevops +region: content_bottom +weight: -1 +provider: null +plugin: 'block_content:c1c2b47c-4c96-4f9e-9b6e-2b1f5f3d7e10' +settings: + id: 'block_content:c1c2b47c-4c96-4f9e-9b6e-2b1f5f3d7e10' + label: 'Related posts' + label_display: '0' + provider: block_content + view_mode: full +visibility: + request_path: + id: request_path + negate: false + pages: "/blog/*\r\n/services/*" diff --git a/docs/related-content.md b/docs/related-content.md index 6eb05d5c..4eacfa58 100644 --- a/docs/related-content.md +++ b/docs/related-content.md @@ -4,7 +4,7 @@ Excluding the site navigation, most pages on this site had exactly one inbound i ## An Automated list can follow the page's own topics -The Automated list paragraph carries a **Use topics of the current page** checkbox (`field_c_p_list_topics_from_page`). With it on, the list ignores the Topics chosen on the paragraph and matches the topics of the node it is placed on instead, which turns the same component into a related-content list. Nothing new was built to do this: it is the Automated list, configured differently. +The Automated list paragraph carries a **Use topics of the current page** checkbox (`field_c_p_list_topics_from_page`), under *Content* → *Filters*. With it on, the list ignores the Topics chosen on the paragraph and matches the topics of the node being viewed instead, which turns the same component into a related-content list. Nothing new was built to do this: it is the Automated list, configured differently. `drevops_civictheme_automated_list_view_alter()` in `web/themes/custom/drevops/includes/automated_list.inc` swaps the Topics contextual argument just before the view runs. CivicTheme fires that alter through the theme manager as well as the module handler, which is why this lives in the theme alongside the rest of the component's theming rather than in a module. @@ -12,10 +12,24 @@ Points worth knowing before changing it: - **The page being viewed is already excluded.** The view's fourth contextual filter is `nid` with `not` set, and it takes its value from the route, so a related list never lists the post it sits on. `_civictheme_automated_list__update_view()` passes only three arguments, which is what leaves that one to its route default. - **The topics argument is position 1.** The arguments are ordered content type, topics, site sections. Reordering them in the view would silently point the swap at the wrong filter. -- **A post with no topics gets `none`, not `all`.** Without that, an empty topic set would fall through to the view's `all` default and advertise the whole site as related. -- **An empty result hides the heading.** `drevops_preprocess_paragraph__civictheme_automated_list()` clears the title when the list found nothing, so a post whose topics nothing else shares does not render a heading introducing empty space. +- **A page with no topics gets `none`, not `all`.** Without that, an empty topic set would fall through to the view's `all` default and advertise the whole site as related. +- **An empty result collapses the component.** `drevops_preprocess_paragraph__civictheme_automated_list()` clears the title, the rows and the vertical spacing when the list found nothing, so a page whose topics nothing else shares renders no heading and takes no height. -`do_base_deploy_add_related_lists()` puts one of these lists on every existing blog post. It is skipped for a post that already has one, so it is safe to re-run. +## One block, placed by path + +**Related posts** is a Component block holding a single Automated list with that option on. `block.block.drevops_related_posts` puts it in the `content_bottom` region of the `drevops` theme, above the Signup block, and one **Pages** visibility condition decides where it appears: + +``` +/blog/* +/services/* +``` + +Points worth knowing before changing it: + +- **It has to be one condition.** Visibility conditions are ANDed, so a *Content type* condition for blog posts alongside a *Pages* condition for the service pages would match nothing. Both audiences are expressed as paths in the single condition. +- **The condition matches the alias.** `RequestPath` resolves the current path to its alias before comparing, so `/node/12` is judged as `/blog/…` and reaches the same verdict as the aliased URL. +- **`/services` is not `/services/*`.** The services landing page carries no topics; leaving it outside the pattern keeps the block off a page that has nothing to put under it. Every service detail page beneath it does carry topics. +- **The block itself is content.** `do_base_deploy_add_related_posts_block()` creates it against the fixed UUID that `block.block.drevops_related_posts` names, and skips when that UUID is already present. Config import runs before deploy hooks, so on a site built from scratch the placement lands one step ahead of the block it points at and starts rendering once the hook has run. ## Topic pages diff --git a/tests/behat/features/paragraph_civictheme_automated_list_related.feature b/tests/behat/features/block_related_posts.feature similarity index 52% rename from tests/behat/features/paragraph_civictheme_automated_list_related.feature rename to tests/behat/features/block_related_posts.feature index 745834c1..bda6149c 100644 --- a/tests/behat/features/paragraph_civictheme_automated_list_related.feature +++ b/tests/behat/features/block_related_posts.feature @@ -1,8 +1,8 @@ -@p0 @civictheme @civictheme_automated_list -Feature: Automated list following the page's own topics +@p0 @drevops @related_posts +Feature: Related posts block As a site visitor - I want a post to link to others on the same topic + I want a page to link to posts on the same topic So that I can keep reading without going back to a paginated listing Background: @@ -18,24 +18,16 @@ Feature: Automated list following the page's own topics | [TEST] Post Other Topic | published | [relative:-3 days] | [TEST] Other Topic | | [TEST] Post Without Topic | published | [relative:-4 days] | | - And the following fields for the paragraph "civictheme_automated_list" exist in the field "field_c_n_components" within the "blog" "node" identified by the field "title" and the value "[TEST] Post Being Read": - | field_c_p_title | [TEST] Related posts | - | field_c_p_list_type | civictheme_automated_list__block1 | - | field_c_p_list_content_type | blog | - | field_c_p_list_limit_type | limited | - | field_c_p_list_limit | 3 | - | field_c_p_list_topics_from_page | 1 | - @api - Scenario: The list shows posts sharing the page's topic + Scenario: The block lists posts sharing the topics of the post it is on Given I am an anonymous user When I visit the "blog" content page with the title "[TEST] Post Being Read" - Then I should see the text "[TEST] Related posts" + Then I should see the text "Related posts" And I should see "[TEST] Post Same Topic" @api - Scenario: The list leaves out the page it is on and posts on other topics + Scenario: The block leaves out the page it is on and posts on other topics Given I am an anonymous user When I visit the "blog" content page with the title "[TEST] Post Being Read" @@ -44,19 +36,37 @@ Feature: Automated list following the page's own topics And I should see 1 ".ct-promo-card" elements @api - Scenario: A post whose topics nothing shares shows no heading - Given the following fields for the paragraph "civictheme_automated_list" exist in the field "field_c_n_components" within the "blog" "node" identified by the field "title" and the value "[TEST] Post Without Topic": - | field_c_p_title | [TEST] Related posts | - | field_c_p_list_type | civictheme_automated_list__block1 | - | field_c_p_list_content_type | blog | - | field_c_p_list_limit_type | limited | - | field_c_p_list_limit | 3 | - | field_c_p_list_topics_from_page | 1 | + Scenario: A page whose topics nothing shares shows no heading + Given I am an anonymous user + + When I visit the "blog" content page with the title "[TEST] Post Without Topic" + Then I should not see the text "Related posts" + @api + Scenario: The block reaches pages under the services path + Given the following "civictheme_page" content: + | title | status | field_c_n_topics | + | [TEST] Service Page | 1 | [TEST] Shared Topic | + + And the "civictheme_page" content "[TEST] Service Page" has the path alias "/services/test-service-page" And I am an anonymous user - When I visit the "blog" content page with the title "[TEST] Post Without Topic" - Then I should not see the text "[TEST] Related posts" + When I go to "/services/test-service-page" + Then I should see the text "Related posts" + And I should see "[TEST] Post Being Read" + And I should see "[TEST] Post Same Topic" + + @api + Scenario: The block stays off pages outside its visibility list + Given the following "civictheme_page" content: + | title | status | field_c_n_topics | + | [TEST] Plain Page | 1 | [TEST] Shared Topic | + + And the "civictheme_page" content "[TEST] Plain Page" has the path alias "/test-plain-page" + And I am an anonymous user + + When I go to "/test-plain-page" + Then I should not see the text "Related posts" @api Scenario: A post links to the topic pages it belongs to diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index dbe265a3..4ffcd9e9 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -1280,30 +1280,17 @@ function _do_base_alias_topic_term(TermInterface $term): void { } /** - * Adds a related-content list to the foot of every blog post. + * Builds the Related posts block that the block layout places in a region. */ -function do_base_deploy_add_related_lists(?array &$sandbox = NULL): ?string { - $query = \Drupal::entityQuery('node') - ->accessCheck(FALSE) - ->condition('type', 'blog'); +function do_base_deploy_add_related_posts_block(): string { + $uuid = 'c1c2b47c-4c96-4f9e-9b6e-2b1f5f3d7e10'; - return Helper::entity($sandbox, 10)->batchQuery($query, static function (NodeInterface $node): void { - _do_base_add_related_list($node); - }, status: Reporter::UPDATED); -} + $existing = \Drupal::service('entity.repository')->loadEntityByUuid('block_content', $uuid); -/** - * Appends an Automated list configured to follow the post's own topics. - */ -function _do_base_add_related_list(NodeInterface $node): void { - if (!$node->hasField('field_c_n_components')) { - return; - } + if ($existing !== NULL) { + Helper::reporter()->skipped('The Related posts block already exists.'); - foreach ($node->get('field_c_n_components')->referencedEntities() as $existing) { - if ($existing instanceof ParagraphInterface && $existing->bundle() === 'civictheme_automated_list' && !$existing->get('field_c_p_list_topics_from_page')->isEmpty() && (bool) $existing->get('field_c_p_list_topics_from_page')->value) { - return; - } + return Helper::report(); } $paragraph = Paragraph::create([ @@ -1316,16 +1303,79 @@ function _do_base_add_related_list(NodeInterface $node): void { 'field_c_p_list_column_count' => 3, 'field_c_p_list_topics_from_page' => 1, ]); - $paragraph->setParentEntity($node, 'field_c_n_components'); - $paragraph->save(); - $components = $node->get('field_c_n_components')->getValue(); - $components[] = [ - 'target_id' => $paragraph->id(), - 'target_revision_id' => $paragraph->getRevisionId(), - ]; + $block_content = \Drupal::entityTypeManager()->getStorage('block_content')->create([ + 'uuid' => $uuid, + 'type' => 'civictheme_component_block', + 'info' => 'Related posts', + 'moderation_state' => 'published', + ]); + $block_content->get('field_c_b_components')->appendItem($paragraph); + $block_content->save(); + + Helper::reporter()->created('Created the Related posts block.'); + + return Helper::report(); +} + +/** + * Strips the related-content list from the foot of every blog post. + * + * @param array|null $sandbox + * Batch sandbox, matching the nullable reference the batch helper takes. + * + * @return string|null + * Summary once every post is stripped, or NULL while batching. + */ +function do_base_deploy_remove_related_lists(?array &$sandbox = NULL): ?string { + // The query doubles as the idempotency guard: a post loses its list on the + // first pass and no longer matches on the next. + $query = \Drupal::entityQuery('node') + ->condition('type', 'blog') + ->condition('field_c_n_components.entity:paragraph.field_c_p_list_topics_from_page', 1); + + return Helper::entity($sandbox, 10)->batchQuery($query, static function (NodeInterface $node): void { + _do_base_remove_related_list($node); + }, status: Reporter::UPDATED); +} + +/** + * Drops the page-topics Automated lists a post carries. + */ +function _do_base_remove_related_list(NodeInterface $node): void { + $dropped = []; + + foreach ($node->get('field_c_n_components')->referencedEntities() as $paragraph) { + if ($paragraph instanceof ParagraphInterface && _do_base_is_related_list($paragraph)) { + $dropped[(int) $paragraph->id()] = $paragraph; + } + } - $node->set('field_c_n_components', $components); + if ($dropped === []) { + return; + } + + $items = $node->get('field_c_n_components')->getValue(); + $kept = array_filter($items, static fn (array $item): bool => !isset($dropped[(int) $item['target_id']])); + + $node->set('field_c_n_components', array_values($kept)); $node->setNewRevision(FALSE); $node->save(); + + // Dropping the reference does not delete the paragraph, and the orphan left + // behind still holds a revision row per node revision it was ever part of. + foreach ($dropped as $paragraph) { + $paragraph->delete(); + } +} + +/** + * Tells whether a paragraph is an Automated list following the page's topics. + */ +function _do_base_is_related_list(ParagraphInterface $paragraph): bool { + if ($paragraph->bundle() !== 'civictheme_automated_list' || !$paragraph->hasField('field_c_p_list_topics_from_page')) { + return FALSE; + } + + return (bool) $paragraph->get('field_c_p_list_topics_from_page')->value; } diff --git a/web/themes/custom/drevops/includes/automated_list.inc b/web/themes/custom/drevops/includes/automated_list.inc index 64ca94c9..61495a29 100644 --- a/web/themes/custom/drevops/includes/automated_list.inc +++ b/web/themes/custom/drevops/includes/automated_list.inc @@ -59,11 +59,13 @@ function drevops_preprocess_paragraph__civictheme_automated_list(array &$variabl return; } - // A post whose topics nothing else shares would otherwise leave a heading - // introducing an empty space. + // The list is placed as a block on every page in its visibility list, so a + // page whose topics nothing else shares has to collapse to nothing: the + // heading and its spacing would otherwise render an empty section. if (_drevops_automated_list_is_empty($variables['rows'] ?? NULL)) { $variables['title'] = NULL; $variables['rows'] = NULL; + $variables['vertical_spacing'] = NULL; } } From a315718f41cf56cf040951bd6e0eee45ae3470bd Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 12 Aug 2026 09:41:33 +1000 Subject: [PATCH 2/3] Moved 'Use topics of the current page' under the Filters field group. --- ...orm_display.paragraph.civictheme_automated_list.default.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml b/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml index a04322df..35d0522c 100644 --- a/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml +++ b/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml @@ -84,6 +84,7 @@ third_party_settings: group_filters: children: - field_c_p_list_filters_exp + - field_c_p_list_topics_from_page label: Filters region: content parent_name: group_content @@ -333,7 +334,7 @@ content: third_party_settings: { } field_c_p_list_topics_from_page: type: boolean_checkbox - weight: 6 + weight: 45 region: content settings: display_label: true From 794e0ebbe74d0db6ee809f341abc916d15c079c8 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 12 Aug 2026 10:42:07 +1000 Subject: [PATCH 3/3] Config. --- ..._display.paragraph.civictheme_automated_list.default.yml | 6 +++--- .../default/core.entity_view_display.node.blog.default.yml | 1 + config/default/views.view.civictheme_automated_list.yml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml b/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml index 35d0522c..7a50ccd8 100644 --- a/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml +++ b/config/default/core.entity_form_display.paragraph.civictheme_automated_list.default.yml @@ -84,7 +84,6 @@ third_party_settings: group_filters: children: - field_c_p_list_filters_exp - - field_c_p_list_topics_from_page label: Filters region: content parent_name: group_content @@ -136,6 +135,7 @@ third_party_settings: group_metadata: children: - field_c_p_list_topics + - field_c_p_list_topics_from_page - field_c_p_list_site_sections label: Metadata region: content @@ -314,7 +314,7 @@ content: third_party_settings: { } field_c_p_list_site_sections: type: entity_reference_autocomplete - weight: 8 + weight: 9 region: content settings: match_operator: CONTAINS @@ -334,7 +334,7 @@ content: third_party_settings: { } field_c_p_list_topics_from_page: type: boolean_checkbox - weight: 45 + weight: 8 region: content settings: display_label: true diff --git a/config/default/core.entity_view_display.node.blog.default.yml b/config/default/core.entity_view_display.node.blog.default.yml index 93c3c9df..b4ea6de6 100644 --- a/config/default/core.entity_view_display.node.blog.default.yml +++ b/config/default/core.entity_view_display.node.blog.default.yml @@ -46,6 +46,7 @@ third_party_settings: label: 'CivicTheme Three Columns' context_mapping: { } is_contained: false + vertical_spacing: auto components: 5cf72ad7-c313-4cf3-9d4e-0573bbfa84a0: uuid: 5cf72ad7-c313-4cf3-9d4e-0573bbfa84a0 diff --git a/config/default/views.view.civictheme_automated_list.yml b/config/default/views.view.civictheme_automated_list.yml index 1b556844..50e25191 100644 --- a/config/default/views.view.civictheme_automated_list.yml +++ b/config/default/views.view.civictheme_automated_list.yml @@ -78,6 +78,7 @@ display: type: full options: offset: 0 + pagination_heading_level: h4 items_per_page: 12 total_pages: null id: 0 @@ -95,7 +96,6 @@ display: offset: false offset_label: Offset quantity: 5 - pagination_heading_level: h4 exposed_form: type: basic options: