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() );
+ }
+}