From e8be50ec861df444211fb3fedaa31ea08dcf4547 Mon Sep 17 00:00:00 2001 From: Jason Zinn Date: Wed, 8 Apr 2026 13:22:48 -0500 Subject: [PATCH 1/2] [MOOSE-377]: feat: expand related posts block matching Support current post types, taxonomy-based automatic matching, and mixed-type manual selection so the related posts block works across more starter use cases. --- CHANGELOG.md | 4 + .../Blocks/Post_Card_Controller.php | 2 + .../Blocks/Related_Posts_Controller.php | 56 ++++- .../core/src/Components/Traits/Post_Data.php | 33 +++ .../blocks/tribe/related-posts/block.json | 4 + .../core/blocks/tribe/related-posts/edit.js | 228 +++++++++++++----- .../blocks/tribe/related-posts/render.php | 3 +- .../themes/core/components/cards/post.php | 8 +- 8 files changed, 275 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f8b1a98..67e672c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Each changelog entry gets prefixed with the category of the item (Added, Changed, Depreciated, Removed, Fixed, Security). +## [2026.04] + +- Updated: Related Posts now supports current post types, taxonomy-based matching, latest-item fallbacks, and mixed post type manual selection. + ## [2026.03] - Updated: Refactor Horizontal Tabs block into a dynamic block. Add reordering functionality. diff --git a/wp-content/plugins/core/src/Components/Blocks/Post_Card_Controller.php b/wp-content/plugins/core/src/Components/Blocks/Post_Card_Controller.php index 9104369f8..27715e7ba 100644 --- a/wp-content/plugins/core/src/Components/Blocks/Post_Card_Controller.php +++ b/wp-content/plugins/core/src/Components/Blocks/Post_Card_Controller.php @@ -4,6 +4,7 @@ use Tribe\Plugin\Components\Abstracts\Abstract_Block_Controller; use Tribe\Plugin\Components\Traits\Post_Data; +use Tribe\Plugin\Taxonomies\Category\Category; class Post_Card_Controller extends Abstract_Block_Controller { @@ -15,6 +16,7 @@ class Post_Card_Controller extends Abstract_Block_Controller { public function __construct( array $args = [] ) { parent::__construct( $args ); $this->set_post( $args['post_id'] ?? 0 ); + $this->set_display_term_taxonomy( $args['taxonomy_slug'] ?? Category::NAME ); $this->layout = $this->attributes['layout'] ?? 'vertical'; $this->heading_level = $this->attributes['heading_level'] ?? 'h3'; diff --git a/wp-content/plugins/core/src/Components/Blocks/Related_Posts_Controller.php b/wp-content/plugins/core/src/Components/Blocks/Related_Posts_Controller.php index c4e2d2937..7e4b8bbeb 100644 --- a/wp-content/plugins/core/src/Components/Blocks/Related_Posts_Controller.php +++ b/wp-content/plugins/core/src/Components/Blocks/Related_Posts_Controller.php @@ -7,6 +7,7 @@ use Tribe\Plugin\Taxonomies\Category\Category; class Related_Posts_Controller extends Abstract_Block_Controller { + private const string LATEST_ITEMS_VALUE = '__latest__'; /** * @var array @@ -21,6 +22,9 @@ class Related_Posts_Controller extends Abstract_Block_Controller { protected bool $has_automatic_selection; protected int $posts_to_show; protected string $block_layout; + protected string $taxonomy_slug; + protected string $current_post_type; + protected string $card_taxonomy_slug = ''; protected \WP_Query $query; public function __construct( array $args = [] ) { @@ -32,6 +36,8 @@ public function __construct( array $args = [] ) { $this->chosen_posts = $this->attributes['chosenPosts'] ?? []; $this->posts_to_show = absint( $this->attributes['postsToShow'] ?? 3 ); $this->block_layout = $this->attributes['layout'] ?? 'grid'; + $this->current_post_type = get_post_type( $this->post_id ) ?: Post::NAME; + $this->taxonomy_slug = $this->get_effective_taxonomy_slug( $this->attributes['taxonomySlug'] ?? Category::NAME ); $this->block_classes .= " b-related-posts--layout-{$this->block_layout}"; @@ -47,14 +53,19 @@ public function get_query(): \WP_Query { return $this->query; } + public function get_card_taxonomy_slug(): string { + return $this->card_taxonomy_slug; + } + private function set_query_args(): void { $this->query_args = [ - 'post_type' => Post::NAME, + 'post_type' => $this->current_post_type, 'post_status' => 'publish', ]; if ( ! $this->has_automatic_selection ) { $this->query_args = array_merge( $this->query_args, [ + 'post_type' => 'any', 'post__in' => array_map( 'absint', wp_list_pluck( $this->chosen_posts, 'id' ) ), 'orderby' => 'post__in', ] ); @@ -67,7 +78,11 @@ private function set_query_args(): void { 'post__not_in' => [ $this->post_id ], ] ); - $post_terms = get_the_terms( $this->post_id, Category::NAME ); + if ( self::LATEST_ITEMS_VALUE === $this->taxonomy_slug || ! is_object_in_taxonomy( $this->current_post_type, $this->taxonomy_slug ) ) { + return; + } + + $post_terms = get_the_terms( $this->post_id, $this->taxonomy_slug ); if ( empty( $post_terms ) || is_wp_error( $post_terms ) ) { return; @@ -76,7 +91,7 @@ private function set_query_args(): void { $term_ids = wp_list_pluck( $post_terms, 'term_id' ); $this->query_args['tax_query'][] = [ - 'taxonomy' => Category::NAME, + 'taxonomy' => $this->taxonomy_slug, 'field' => 'term_id', 'terms' => $term_ids, ]; @@ -84,6 +99,41 @@ private function set_query_args(): void { private function set_query(): void { $this->query = new \WP_Query( $this->query_args ); + + if ( $this->has_automatic_selection && ! $this->query->have_posts() && ! empty( $this->query_args['tax_query'] ) ) { + unset( $this->query_args['tax_query'] ); + $this->query = new \WP_Query( $this->query_args ); + } + + $this->card_taxonomy_slug = ! empty( $this->query_args['tax_query'] ) ? $this->taxonomy_slug : ''; + } + + /** + * @return array + */ + private function get_available_taxonomies(): array { + return array_filter( + get_object_taxonomies( $this->current_post_type, 'objects' ), + static fn( \WP_Taxonomy $taxonomy ): bool => (bool) $taxonomy->show_in_rest + ); + } + + private function get_effective_taxonomy_slug( string $taxonomy_slug ): string { + if ( self::LATEST_ITEMS_VALUE === $taxonomy_slug ) { + return $taxonomy_slug; + } + + $available_taxonomies = $this->get_available_taxonomies(); + + if ( is_object_in_taxonomy( $this->current_post_type, $taxonomy_slug ) ) { + return $taxonomy_slug; + } + + if ( array_key_exists( Category::NAME, $available_taxonomies ) ) { + return Category::NAME; + } + + return array_key_first( $available_taxonomies ) ?: ''; } } diff --git a/wp-content/plugins/core/src/Components/Traits/Post_Data.php b/wp-content/plugins/core/src/Components/Traits/Post_Data.php index f4dd8b80a..0d25dab2f 100644 --- a/wp-content/plugins/core/src/Components/Traits/Post_Data.php +++ b/wp-content/plugins/core/src/Components/Traits/Post_Data.php @@ -13,6 +13,7 @@ trait Post_Data { protected \WP_Post_Type|null $post_type_object = null; protected int|false $image_id = false; protected \WP_Term|null $primary_category = null; + protected \WP_Term|null $display_term = null; protected string $post_title = ''; protected string $author_id = '0'; protected string $author = ''; @@ -31,6 +32,7 @@ public function set_post( mixed $post_id ): void { $this->post_type_object = get_post_type_object( $this->post_type ); $this->image_id = get_post_thumbnail_id( $this->post_id ); $this->primary_category = $this->get_primary_term( $this->post_id, Category::NAME ); + $this->display_term = $this->primary_category; $this->post_title = get_the_title( $this->post_id ); $this->author_id = get_post_field( 'post_author', $this->post_id ); $this->date = get_the_date( 'M j, Y', $this->post_id ); @@ -64,6 +66,37 @@ public function get_primary_category_name(): string { return $this->has_primary_category() ? $this->primary_category->name : ''; } + public function set_display_term_taxonomy( string $taxonomy ): void { + if ( null === $this->post_id || 0 === $this->post_id || '' === $taxonomy ) { + $this->display_term = null; + + return; + } + + if ( Category::NAME === $taxonomy ) { + $this->display_term = $this->primary_category; + + return; + } + + $terms = get_the_terms( $this->post_id, $taxonomy ); + if ( $terms && ! is_wp_error( $terms ) ) { + $this->display_term = reset( $terms ) ?: null; + + return; + } + + $this->display_term = null; + } + + public function has_display_term(): bool { + return null !== $this->display_term; + } + + public function get_display_term_name(): string { + return $this->has_display_term() ? $this->display_term->name : ''; + } + public function get_post_title(): string { return $this->post_title; } diff --git a/wp-content/themes/core/blocks/tribe/related-posts/block.json b/wp-content/themes/core/blocks/tribe/related-posts/block.json index 23d0f1dd0..af2a7c1a2 100644 --- a/wp-content/themes/core/blocks/tribe/related-posts/block.json +++ b/wp-content/themes/core/blocks/tribe/related-posts/block.json @@ -20,6 +20,10 @@ "type": "number", "default": 3 }, + "taxonomySlug": { + "type": "string", + "default": "category" + }, "layout": { "type": "string", "enum": [ "grid", "list" ], diff --git a/wp-content/themes/core/blocks/tribe/related-posts/edit.js b/wp-content/themes/core/blocks/tribe/related-posts/edit.js index 4f641fcdc..32e56c4d6 100644 --- a/wp-content/themes/core/blocks/tribe/related-posts/edit.js +++ b/wp-content/themes/core/blocks/tribe/related-posts/edit.js @@ -10,40 +10,103 @@ import { import ServerSideRender from '@wordpress/server-side-render'; import { withSelect } from '@wordpress/data'; -function Edit( { props, postList } ) { +const getPickerLabel = ( post ) => { + if ( post.pickerLabel ) { + return post.pickerLabel; + } + + return post.value; +}; + +const LATEST_ITEMS_VALUE = '__latest__'; + +const getEffectiveTaxonomySlug = ( taxonomies = [], taxonomySlug = '' ) => { + if ( taxonomySlug === LATEST_ITEMS_VALUE ) { + return taxonomySlug; + } + + if ( taxonomies.some( ( taxonomy ) => taxonomy.slug === taxonomySlug ) ) { + return taxonomySlug; + } + + if ( taxonomies.some( ( taxonomy ) => taxonomy.slug === 'category' ) ) { + return 'category'; + } + + return taxonomies[ 0 ]?.slug ?? ''; +}; + +function Edit( { props, postList, taxonomies } ) { const blockProps = useBlockProps(); const { attributes, isSelected, setAttributes } = props; - const { hasAutomaticSelection, chosenPosts, postsToShow, layout } = - attributes; - - const setChosenPosts = ( selectedPosts ) => { - const newChosenPosts = selectedPosts.map( ( selectedPost ) => { - /** - * if we've already added a value, it will appear as an object - * in this case, we can just return the existing object - */ - if ( typeof selectedPost !== 'string' ) { - return selectedPost; - } - - /** - * if this is a new value, it will appear as a string so we'll need to grab - * the post object via the post title. The name is provided via the Suggestions - * array in the FormTokenField component. - */ - const foundPost = postList.find( - ( post ) => post.title.rendered === selectedPost - ); - - if ( ! foundPost ) { - return false; - } + const { + hasAutomaticSelection, + chosenPosts, + postsToShow, + taxonomySlug, + layout, + } = attributes; + const effectiveTaxonomySlug = getEffectiveTaxonomySlug( + taxonomies, + taxonomySlug + ); + const taxonomyOptions = [ + { + label: __( 'Latest Items', 'tribe' ), + value: LATEST_ITEMS_VALUE, + }, + ...( taxonomies ?? [] ).map( ( taxonomy ) => ( { + label: taxonomy.name, + value: taxonomy.slug, + } ) ), + ]; + const tokenValues = ( chosenPosts ?? [] ).map( ( chosenPost ) => { + if ( typeof chosenPost === 'string' ) { + return chosenPost; + } + const matchedPost = ( postList ?? [] ).find( + ( post ) => post.id === chosenPost.id + ); + if ( matchedPost ) { return { - value: foundPost.title.rendered, - id: foundPost.id, + ...chosenPost, + value: matchedPost.pickerLabel, }; - } ); + } + + return chosenPost; + } ); + + const setChosenPosts = ( selectedPosts ) => { + const newChosenPosts = selectedPosts + .map( ( selectedPost ) => { + /** + * if we've already added a value, it will appear as an object + * in this case, we can just return the existing object + */ + if ( typeof selectedPost !== 'string' ) { + return selectedPost; + } + + /** + * if this is a new value, it will appear as a string so we'll need to grab + * the post object via the picker label shown in the suggestions list. + */ + const foundPost = ( postList ?? [] ).find( + ( post ) => post.pickerLabel === selectedPost + ); + + if ( ! foundPost ) { + return false; + } + + return { + value: foundPost.pickerLabel, + id: foundPost.id, + }; + } ) + .filter( Boolean ); setAttributes( { chosenPosts: newChosenPosts, @@ -54,7 +117,10 @@ function Edit( { props, postList } ) {
{ isSelected && ( @@ -63,7 +129,7 @@ function Edit( { props, postList } ) { __nextHasNoMarginBottom label={ __( 'Has Automatic Selection?', 'tribe' ) } help={ __( - 'If checked, this setting allows the block to control which posts show. Currently this is done by adding posts that have any categories in common with the current post.', + 'When enabled, the block shows posts related by the selected option. Taxonomy matches fall back to latest items if no matches are found.', 'tribe' ) } onChange={ ( value ) => { @@ -83,10 +149,10 @@ function Edit( { props, postList } ) { 'Manual Post Selection', 'tribe' ) } - suggestions={ postList.map( - ( post ) => post.title.rendered + suggestions={ ( postList ?? [] ).map( + getPickerLabel ) } - value={ chosenPosts } + value={ tokenValues } onChange={ ( tokens ) => { setChosenPosts( tokens ); } } @@ -98,23 +164,41 @@ function Edit( { props, postList } ) {
) } { hasAutomaticSelection && ( - { - setAttributes( { - postsToShow: value, - } ); - } } - /> + <> + { + setAttributes( { + taxonomySlug: value, + } ); + } } + /> + { + setAttributes( { + postsToShow: value, + } ); + } } + /> + ) } { - const { getEntityRecords } = select( 'core' ); - const { getCurrentPostId } = select( 'core/editor' ); + const { getEntityRecords, getPostTypes, getTaxonomies } = select( 'core' ); + const { getCurrentPostId, getCurrentPostType } = select( 'core/editor' ); const currentPostId = getCurrentPostId(); + const currentPostType = getCurrentPostType(); - const postList = getEntityRecords( 'postType', 'post', { - per_page: 100, - exclude: [ currentPostId ], + const EXCLUDED_TYPES = new Set( [ + 'attachment', + 'page', + 'wp_block', + 'wp_template', + 'wp_template_part', + 'wp_navigation', + ] ); + + const allPostTypes = getPostTypes( { per_page: -1 } ) ?? []; + const selectableTypes = allPostTypes.filter( + ( type ) => type.viewable === true && ! EXCLUDED_TYPES.has( type.slug ) + ); + const taxonomies = currentPostType + ? ( + getTaxonomies( { + type: currentPostType, + per_page: -1, + } ) ?? [] + ).filter( + ( taxonomy ) => taxonomy.visibility?.show_in_rest !== false + ) + : []; + + const postList = selectableTypes.flatMap( ( type ) => { + const records = getEntityRecords( 'postType', type.slug, { + per_page: 100, + exclude: [ currentPostId ], + } ); + return ( records ?? [] ).map( ( post ) => ( { + ...post, + pickerLabel: `${ post.title.rendered } (${ type.labels.singular_name })`, + } ) ); } ); return { props: ownProps, postList, + taxonomies, }; } )( Edit ); diff --git a/wp-content/themes/core/blocks/tribe/related-posts/render.php b/wp-content/themes/core/blocks/tribe/related-posts/render.php index 3454dedb5..d86cfff83 100644 --- a/wp-content/themes/core/blocks/tribe/related-posts/render.php +++ b/wp-content/themes/core/blocks/tribe/related-posts/render.php @@ -19,7 +19,8 @@ get_query()->have_posts() ) : ?> get_query()->the_post(); ?> get_the_ID(), + 'post_id' => get_the_ID(), + 'taxonomy_slug' => $c->get_card_taxonomy_slug(), ] ); ?> diff --git a/wp-content/themes/core/components/cards/post.php b/wp-content/themes/core/components/cards/post.php index b6a70e7af..3ebe6db0a 100644 --- a/wp-content/themes/core/components/cards/post.php +++ b/wp-content/themes/core/components/cards/post.php @@ -6,7 +6,8 @@ * @var object $args */ -$post_id = $args['post_id']; +$post_id = $args['post_id']; +$taxonomy_slug = $args['taxonomy_slug'] ?? ''; // return if no post id if ( ! $post_id ) { @@ -18,6 +19,7 @@ $c = Post_Card_Controller::factory( [ 'post_id' => $post_id, + 'taxonomy_slug' => $taxonomy_slug, 'attributes' => $attributes, 'block_classes' => 'c-post-card', ] ); @@ -30,8 +32,8 @@
- has_primary_category() ) : ?> -

get_primary_category_name() ); ?>

+ has_display_term() ) : ?> +

get_display_term_name() ); ?>

Date: Wed, 8 Apr 2026 13:23:36 -0500 Subject: [PATCH 2/2] [MOOSE-377]: task: fix related posts phpcs formatting Address the controller formatting issues caught by the push hooks so the related posts backport can pass repository checks. --- .../core/src/Components/Blocks/Related_Posts_Controller.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/wp-content/plugins/core/src/Components/Blocks/Related_Posts_Controller.php b/wp-content/plugins/core/src/Components/Blocks/Related_Posts_Controller.php index 7e4b8bbeb..08ae5829b 100644 --- a/wp-content/plugins/core/src/Components/Blocks/Related_Posts_Controller.php +++ b/wp-content/plugins/core/src/Components/Blocks/Related_Posts_Controller.php @@ -7,6 +7,7 @@ use Tribe\Plugin\Taxonomies\Category\Category; class Related_Posts_Controller extends Abstract_Block_Controller { + private const string LATEST_ITEMS_VALUE = '__latest__'; /** @@ -66,8 +67,8 @@ private function set_query_args(): void { if ( ! $this->has_automatic_selection ) { $this->query_args = array_merge( $this->query_args, [ 'post_type' => 'any', - 'post__in' => array_map( 'absint', wp_list_pluck( $this->chosen_posts, 'id' ) ), - 'orderby' => 'post__in', + 'post__in' => array_map( 'absint', wp_list_pluck( $this->chosen_posts, 'id' ) ), + 'orderby' => 'post__in', ] ); return;