diff --git a/includes/Query_Params_Generator.php b/includes/Query_Params_Generator.php index 36f700a..b4a754b 100644 --- a/includes/Query_Params_Generator.php +++ b/includes/Query_Params_Generator.php @@ -14,6 +14,7 @@ class Query_Params_Generator { use Traits\Multiple_Posts; use Traits\Exclude_Current; + use Traits\Exclude_Posts; use Traits\Include_Posts; use Traits\Meta_Query; use Traits\Date_Query; @@ -35,6 +36,7 @@ class Query_Params_Generator { 'date_query_dynamic_range' => 'date_query', 'date_query_relationship' => 'date_query', 'pagination' => 'disable_pagination', + 'exclude_posts' => 'exclude_posts', ); diff --git a/includes/Traits/Exclude_Posts.php b/includes/Traits/Exclude_Posts.php new file mode 100644 index 0000000..4be60f0 --- /dev/null +++ b/includes/Traits/Exclude_Posts.php @@ -0,0 +1,35 @@ +custom_args['post__not_in'] = $this->get_excluded_post_ids( $this->custom_params['exclude_posts'] ); + } + + /** + * Helper to generate the array + * + * @param mixed $to_exclude The value to be excluded. + * + * @return array The ids to exclude + */ + public function get_excluded_post_ids( $to_exclude ) { + // If there are already posts to be excluded, we need to add to them. + $exclude_ids = $this->custom_args['post__not_in'] ?? array(); + + $exclude_ids = array_unique( array_merge( $exclude_ids, (array) $to_exclude ) ); + + return $exclude_ids; + } +} diff --git a/src/components/post-exclude-controls.js b/src/components/post-exclude-controls.js index f1db202..ddd3220 100644 --- a/src/components/post-exclude-controls.js +++ b/src/components/post-exclude-controls.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { ToggleControl } from '@wordpress/components'; +import { ToggleControl, FormTokenField, BaseControl } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; import { useEntityRecord, store as coreDataStore } from '@wordpress/core-data'; import { __ } from '@wordpress/i18n'; @@ -21,7 +21,49 @@ export const PostExcludeControls = ( { setAttributes, allowedControls, } ) => { - const { query: { exclude_current: excludeCurrent } = {} } = attributes; + const { query: { exclude_current: excludeCurrent, exclude_posts: excludePosts = [], } = {} } = attributes; + + // If the control is not allowed, return null. + if ( ! allowedControls.includes( 'exclude_current_post' ) && ! allowedControls.includes( 'exclude_posts' ) ) { + return null; + } + + return ( + <> +

{ __( 'Exclude Posts', 'advanced-query-loop' ) }

+ + + + ); +}; + +/** + * ExcludeCurrentPostControl is a React functional component used within the context + * of advanced query loop settings. It toggles the exclusion of the current post + * or content associated with the current template from query results. + * + * @param {Object} props The properties passed to the component. + * @param {Object} props.attributes The block attributes. + * @param {Function} props.setAttributes Function to update block attributes. + * @param {Array} props.allowedControls List of control identifiers that are allowed for this block. + * + * @return {Element|null} A `ToggleControl` component if the control is allowed, or `null` if not. + */ +const ExcludeCurrentPostControl = ( { attributes, setAttributes, allowedControls } ) => { + const { query: { exclude_current: excludeCurrent, } = {} } = attributes; + + if ( ! allowedControls.includes( 'exclude_current_post' ) ) { + return null; + } + const { record: siteOptions } = useEntityRecord( 'root', 'site' ); const { currentPost, isAdmin } = useSelect( ( select ) => { return { @@ -33,11 +75,6 @@ export const PostExcludeControls = ( { }; }, [] ); - // If the control is not allowed, return null. - if ( ! allowedControls.includes( 'exclude_current_post' ) ) { - return null; - } - if ( ! currentPost ) { return
{ __( 'Loading…', 'advanced-query-loop' ) }
; } @@ -62,33 +99,121 @@ export const PostExcludeControls = ( { }; return ( - <> -

{ __( 'Exclude Posts', 'advanced-query-loop' ) }

- { + { + setAttributes( { + query: { + ...attributes.query, + exclude_current: value ? currentPost.id : 0, + }, + } ); + } } + help={ + isDisabled() + ? __( + 'This option is disabled for this template as there is no dedicated post to exclude.', + 'advanced-query-loop' + ) + : __( + 'Remove the associated post for this template/content from the query results.', + 'advanced-query-loop' + ) + } + /> + ); +} + +/** + * The ExcludePostsControl component allows users to exclude specific posts + * from queries based on post titles, providing search and selection + * functionality in the form of a token field. + * + * @param {Object} props The component props. + * @param {Object} props.attributes The block attributes. + * @param {Function} props.setAttributes Function to update the block attributes. + * @param {Array} props.allowedControls List of controls allowed for the current context. + * + * @returns {Element|null} Returns the control for selecting excluded posts, + * or null if the 'exclude_posts' control is not allowed. + */ +const ExcludePostsControl = ( { attributes, setAttributes, allowedControls } ) => { + const { + query: { + exclude_posts: excludePosts = [], + multiple_posts: multiplePosts = [], + postType + } = {} + } = attributes; + + if ( ! allowedControls.includes( 'exclude_posts' ) ) { + return null; + } + + // Get the posts for all post types used in the query. + const posts = useSelect( + ( select ) => { + const { getEntityRecords } = select( 'core' ); + + // Fetch posts for each post type and combine them into one array + return [ ...multiplePosts, postType ].reduce( + ( accumulator, postType ) => { + // Depending on the number of posts this could take a while, since we can't paginate here + const records = getEntityRecords( 'postType', postType, { + per_page: -1, + } ); + return [ ...accumulator, ...( records || [] ) ]; + }, + [] + ); + }, + [ postType, multiplePosts ] + ); + + // For use with flatMap(), as this lets us remove elements during a map() + const idToTitle = ( id ) => { + const post = posts.find( ( p ) => p.id === id ); + return post ? [ post.title.rendered ] : []; + }; + + const titleToId = ( title ) => { + const post = posts.find( ( p ) => p.title.rendered === title ); + return post ? [ post.id ] : []; + }; + + if ( ! posts ) { + return
{ __( 'Loading…', 'advanced-query-loop' ) }
; + } + + return ( + + idToTitle( id ) ) } + suggestions={ posts.map( ( post ) => post.title.rendered ) } + onChange={ ( titles ) => { + // Converts the Titles to Post IDs before saving them setAttributes( { query: { ...attributes.query, - exclude_current: value ? currentPost.id : 0, + exclude_posts: + titles.flatMap( ( title ) => + titleToId( title ) + ) || [], }, } ); } } - help={ - isDisabled() - ? __( - 'This option is disabled for this template as there is no dedicated post to exclude.', - 'advanced-query-loop' - ) - : __( - 'Remove the associated post for this template/content from the query results.', - 'advanced-query-loop' - ) - } + __experimentalExpandOnFocus + __experimentalShowHowTo={ false } /> - - ); -}; + + ) +} diff --git a/tests/unit/Exclude_Posts_Tests.php b/tests/unit/Exclude_Posts_Tests.php new file mode 100644 index 0000000..50b371b --- /dev/null +++ b/tests/unit/Exclude_Posts_Tests.php @@ -0,0 +1,96 @@ + array(), + ), + ), + ); + } + + /** + * All of these tests will return empty arrays + * + * @param array $default_data The params coming from the default block. + * @param array $custom_data The params coming from AQL. + * + * @dataProvider data_returns_empty_array + */ + public function test_exclude_posts_returns_empty( $default_data, $custom_data ) { + + $qpg = new Query_Params_Generator( $default_data, $custom_data ); + $qpg->process_all(); + + // Empty arrays return empty. + $this->assertEmpty( $qpg->get_query_args() ); + } + + + /** + * Data provider for the non-empty array tests + * + * @return array + */ + public function data_basic_exclude_posts() { + return array( + array( + // Default values. + array(), + // Custom data. + array( 'exclude_posts' => array( 12, 13 ) ), + ), + array( + // Default values. + array(), + // Custom data. + array( + 'exclude_posts' => array( 12, 13 ), + ), + ), + ); + } + + /** + * Test that basics of setting an ID + * + * @param array $default_data The params coming from the default block. + * @param array $custom_data The params coming from AQL. + * + * @dataProvider data_basic_exclude_posts + */ + public function test_basic_exclude_posts( $default_data, $custom_data ) { + $qpg = new Query_Params_Generator( $default_data, $custom_data ); + $qpg->process_all(); + + $this->assertEquals( array( 'post__not_in' => array( 12, 13 ) ), $qpg->get_query_args() ); + } +}