Skip to content

Commit fd65400

Browse files
authored
Merge pull request #132 from darylldoyle/feature/exclude-posts
Feature/exclude posts
2 parents 5a4fbb2 + 430cff5 commit fd65400

File tree

4 files changed

+288
-30
lines changed

4 files changed

+288
-30
lines changed

includes/Query_Params_Generator.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class Query_Params_Generator {
1414

1515
use Traits\Multiple_Posts;
1616
use Traits\Exclude_Current;
17+
use Traits\Exclude_Posts;
1718
use Traits\Include_Posts;
1819
use Traits\Meta_Query;
1920
use Traits\Date_Query;
@@ -35,6 +36,7 @@ class Query_Params_Generator {
3536
'date_query_dynamic_range' => 'date_query',
3637
'date_query_relationship' => 'date_query',
3738
'pagination' => 'disable_pagination',
39+
'exclude_posts' => 'exclude_posts',
3840
);
3941

4042

includes/Traits/Exclude_Posts.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
/**
3+
* Exclude_Posts
4+
*/
5+
6+
namespace AdvancedQueryLoop\Traits;
7+
8+
/**
9+
* Trait
10+
*/
11+
trait Exclude_Posts {
12+
13+
/**
14+
* Main processing function.
15+
*/
16+
public function process_exclude_posts(): void {
17+
$this->custom_args['post__not_in'] = $this->get_excluded_post_ids( $this->custom_params['exclude_posts'] );
18+
}
19+
20+
/**
21+
* Helper to generate the array
22+
*
23+
* @param mixed $to_exclude The value to be excluded.
24+
*
25+
* @return array The ids to exclude
26+
*/
27+
public function get_excluded_post_ids( $to_exclude ) {
28+
// If there are already posts to be excluded, we need to add to them.
29+
$exclude_ids = $this->custom_args['post__not_in'] ?? array();
30+
31+
$exclude_ids = array_unique( array_merge( $exclude_ids, (array) $to_exclude ) );
32+
33+
return $exclude_ids;
34+
}
35+
}

src/components/post-exclude-controls.js

Lines changed: 155 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* WordPress dependencies
33
*/
4-
import { ToggleControl } from '@wordpress/components';
4+
import { ToggleControl, FormTokenField, BaseControl } from '@wordpress/components';
55
import { useSelect } from '@wordpress/data';
66
import { useEntityRecord, store as coreDataStore } from '@wordpress/core-data';
77
import { __ } from '@wordpress/i18n';
@@ -21,7 +21,49 @@ export const PostExcludeControls = ( {
2121
setAttributes,
2222
allowedControls,
2323
} ) => {
24-
const { query: { exclude_current: excludeCurrent } = {} } = attributes;
24+
const { query: { exclude_current: excludeCurrent, exclude_posts: excludePosts = [], } = {} } = attributes;
25+
26+
// If the control is not allowed, return null.
27+
if ( ! allowedControls.includes( 'exclude_current_post' ) && ! allowedControls.includes( 'exclude_posts' ) ) {
28+
return null;
29+
}
30+
31+
return (
32+
<>
33+
<h2> { __( 'Exclude Posts', 'advanced-query-loop' ) }</h2>
34+
<ExcludeCurrentPostControl
35+
attributes={ attributes }
36+
setAttributes={ setAttributes }
37+
allowedControls={ allowedControls }
38+
/>
39+
<ExcludePostsControl
40+
attributes={ attributes }
41+
setAttributes={ setAttributes }
42+
allowedControls={ allowedControls }
43+
/>
44+
</>
45+
);
46+
};
47+
48+
/**
49+
* ExcludeCurrentPostControl is a React functional component used within the context
50+
* of advanced query loop settings. It toggles the exclusion of the current post
51+
* or content associated with the current template from query results.
52+
*
53+
* @param {Object} props The properties passed to the component.
54+
* @param {Object} props.attributes The block attributes.
55+
* @param {Function} props.setAttributes Function to update block attributes.
56+
* @param {Array} props.allowedControls List of control identifiers that are allowed for this block.
57+
*
58+
* @return {Element|null} A `ToggleControl` component if the control is allowed, or `null` if not.
59+
*/
60+
const ExcludeCurrentPostControl = ( { attributes, setAttributes, allowedControls } ) => {
61+
const { query: { exclude_current: excludeCurrent, } = {} } = attributes;
62+
63+
if ( ! allowedControls.includes( 'exclude_current_post' ) ) {
64+
return null;
65+
}
66+
2567
const { record: siteOptions } = useEntityRecord( 'root', 'site' );
2668
const { currentPost, isAdmin } = useSelect( ( select ) => {
2769
return {
@@ -33,11 +75,6 @@ export const PostExcludeControls = ( {
3375
};
3476
}, [] );
3577

36-
// If the control is not allowed, return null.
37-
if ( ! allowedControls.includes( 'exclude_current_post' ) ) {
38-
return null;
39-
}
40-
4178
if ( ! currentPost ) {
4279
return <div>{ __( 'Loading…', 'advanced-query-loop' ) }</div>;
4380
}
@@ -62,33 +99,121 @@ export const PostExcludeControls = ( {
6299
};
63100

64101
return (
65-
<>
66-
<h2> { __( 'Exclude Posts', 'advanced-query-loop' ) }</h2>
67-
<ToggleControl
68-
__nextHasNoMarginBottom
69-
label={ __( 'Exclude Current Post', 'advanced-query-loop' ) }
70-
checked={ !! excludeCurrent }
71-
disabled={ isDisabled() }
72-
onChange={ ( value ) => {
102+
<ToggleControl
103+
__nextHasNoMarginBottom
104+
label={ __( 'Exclude Current Post', 'advanced-query-loop' ) }
105+
checked={ !! excludeCurrent }
106+
disabled={ isDisabled() }
107+
onChange={ ( value ) => {
108+
setAttributes( {
109+
query: {
110+
...attributes.query,
111+
exclude_current: value ? currentPost.id : 0,
112+
},
113+
} );
114+
} }
115+
help={
116+
isDisabled()
117+
? __(
118+
'This option is disabled for this template as there is no dedicated post to exclude.',
119+
'advanced-query-loop'
120+
)
121+
: __(
122+
'Remove the associated post for this template/content from the query results.',
123+
'advanced-query-loop'
124+
)
125+
}
126+
/>
127+
);
128+
}
129+
130+
/**
131+
* The ExcludePostsControl component allows users to exclude specific posts
132+
* from queries based on post titles, providing search and selection
133+
* functionality in the form of a token field.
134+
*
135+
* @param {Object} props The component props.
136+
* @param {Object} props.attributes The block attributes.
137+
* @param {Function} props.setAttributes Function to update the block attributes.
138+
* @param {Array} props.allowedControls List of controls allowed for the current context.
139+
*
140+
* @returns {Element|null} Returns the control for selecting excluded posts,
141+
* or null if the 'exclude_posts' control is not allowed.
142+
*/
143+
const ExcludePostsControl = ( { attributes, setAttributes, allowedControls } ) => {
144+
const {
145+
query: {
146+
exclude_posts: excludePosts = [],
147+
multiple_posts: multiplePosts = [],
148+
postType
149+
} = {}
150+
} = attributes;
151+
152+
if ( ! allowedControls.includes( 'exclude_posts' ) ) {
153+
return null;
154+
}
155+
156+
// Get the posts for all post types used in the query.
157+
const posts = useSelect(
158+
( select ) => {
159+
const { getEntityRecords } = select( 'core' );
160+
161+
// Fetch posts for each post type and combine them into one array
162+
return [ ...multiplePosts, postType ].reduce(
163+
( accumulator, postType ) => {
164+
// Depending on the number of posts this could take a while, since we can't paginate here
165+
const records = getEntityRecords( 'postType', postType, {
166+
per_page: -1,
167+
} );
168+
return [ ...accumulator, ...( records || [] ) ];
169+
},
170+
[]
171+
);
172+
},
173+
[ postType, multiplePosts ]
174+
);
175+
176+
// For use with flatMap(), as this lets us remove elements during a map()
177+
const idToTitle = ( id ) => {
178+
const post = posts.find( ( p ) => p.id === id );
179+
return post ? [ post.title.rendered ] : [];
180+
};
181+
182+
const titleToId = ( title ) => {
183+
const post = posts.find( ( p ) => p.title.rendered === title );
184+
return post ? [ post.id ] : [];
185+
};
186+
187+
if ( ! posts ) {
188+
return <div>{ __( 'Loading…', 'advanced-query-loop' ) }</div>;
189+
}
190+
191+
return (
192+
<BaseControl
193+
help={ __(
194+
'Start typing to search for a post title to exclude, or manually enter one.',
195+
'advanced-query-loop'
196+
) }
197+
>
198+
<FormTokenField
199+
label={ __( 'Posts to Exclude', 'advanced-query-loop' ) }
200+
value={ excludePosts.flatMap( ( id ) => idToTitle( id ) ) }
201+
suggestions={ posts.map( ( post ) => post.title.rendered ) }
202+
onChange={ ( titles ) => {
203+
// Converts the Titles to Post IDs before saving them
73204
setAttributes( {
74205
query: {
75206
...attributes.query,
76-
exclude_current: value ? currentPost.id : 0,
207+
exclude_posts:
208+
titles.flatMap( ( title ) =>
209+
titleToId( title )
210+
) || [],
77211
},
78212
} );
79213
} }
80-
help={
81-
isDisabled()
82-
? __(
83-
'This option is disabled for this template as there is no dedicated post to exclude.',
84-
'advanced-query-loop'
85-
)
86-
: __(
87-
'Remove the associated post for this template/content from the query results.',
88-
'advanced-query-loop'
89-
)
90-
}
214+
__experimentalExpandOnFocus
215+
__experimentalShowHowTo={ false }
91216
/>
92-
</>
93-
);
94-
};
217+
</BaseControl>
218+
)
219+
}

tests/unit/Exclude_Posts_Tests.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
/**
3+
* Tests for the Query_Params_Generator class.
4+
*/
5+
6+
namespace AdvancedQueryLoop\UnitTests;
7+
8+
use AdvancedQueryLoop\Query_Params_Generator;
9+
use PHPUnit\Framework\TestCase;
10+
11+
/**
12+
* Test the generator class
13+
*/
14+
class Exclude_Posts_Tests extends TestCase {
15+
16+
/**
17+
* Data provider for the empty array tests
18+
*
19+
* @return array
20+
*/
21+
public function data_returns_empty_array() {
22+
return array(
23+
array(
24+
// Default values.
25+
array(),
26+
// Custom data.
27+
array(),
28+
),
29+
array(
30+
// Default values.
31+
array(),
32+
// Custom data.
33+
array(
34+
'exclude_posts' => array(),
35+
),
36+
),
37+
);
38+
}
39+
40+
/**
41+
* All of these tests will return empty arrays
42+
*
43+
* @param array $default_data The params coming from the default block.
44+
* @param array $custom_data The params coming from AQL.
45+
*
46+
* @dataProvider data_returns_empty_array
47+
*/
48+
public function test_exclude_posts_returns_empty( $default_data, $custom_data ) {
49+
50+
$qpg = new Query_Params_Generator( $default_data, $custom_data );
51+
$qpg->process_all();
52+
53+
// Empty arrays return empty.
54+
$this->assertEmpty( $qpg->get_query_args() );
55+
}
56+
57+
58+
/**
59+
* Data provider for the non-empty array tests
60+
*
61+
* @return array
62+
*/
63+
public function data_basic_exclude_posts() {
64+
return array(
65+
array(
66+
// Default values.
67+
array(),
68+
// Custom data.
69+
array( 'exclude_posts' => array( 12, 13 ) ),
70+
),
71+
array(
72+
// Default values.
73+
array(),
74+
// Custom data.
75+
array(
76+
'exclude_posts' => array( 12, 13 ),
77+
),
78+
),
79+
);
80+
}
81+
82+
/**
83+
* Test that basics of setting an ID
84+
*
85+
* @param array $default_data The params coming from the default block.
86+
* @param array $custom_data The params coming from AQL.
87+
*
88+
* @dataProvider data_basic_exclude_posts
89+
*/
90+
public function test_basic_exclude_posts( $default_data, $custom_data ) {
91+
$qpg = new Query_Params_Generator( $default_data, $custom_data );
92+
$qpg->process_all();
93+
94+
$this->assertEquals( array( 'post__not_in' => array( 12, 13 ) ), $qpg->get_query_args() );
95+
}
96+
}

0 commit comments

Comments
 (0)