diff --git a/assets/js/ordering/pointers.js b/assets/js/ordering/pointers.js
index 0dd26583a..2e10602ac 100644
--- a/assets/js/ordering/pointers.js
+++ b/assets/js/ordering/pointers.js
@@ -67,6 +67,7 @@ export class Pointers extends Component {
searchText: '',
searchResults: {},
removedPointers: [],
+ excludedPosts: window.epOrdering.excluded_posts || [],
};
}
@@ -121,18 +122,23 @@ export class Pointers extends Component {
removePointer = (pointer) => {
let { pointers } = this.state;
- const { removedPointers } = this.state;
+ const { removedPointers, excludedPosts } = this.state;
delete pointers[pointers.indexOf(pointer)];
pointers = pointers.filter((item) => item !== null);
- removedPointers.push(pointer.ID);
- this.setState({ pointers });
+ if (pointer.type === 'custom-result') {
+ excludedPosts.push(pointer.ID);
+ } else {
+ removedPointers.push(pointer.ID);
+ }
+
+ this.setState({ pointers, removedPointers, excludedPosts });
};
getMergedPosts = () => {
let { pointers } = this.state;
- const { title, defaultResults } = this.state;
+ const { title, defaultResults, excludedPosts } = this.state;
let merged = defaultResults[title].slice();
pointers = pointers.sort((a, b) => {
@@ -140,8 +146,10 @@ export class Pointers extends Component {
});
const pointersIds = pluck(pointers, 'ID');
- // Remove all custom pointers from the default results
- merged = merged.filter((item) => pointersIds.indexOf(item.ID) === -1);
+ // Remove all custom pointers and excluded posts from the default results
+ merged = merged.filter(
+ (item) => pointersIds.indexOf(item.ID) === -1 && excludedPosts.indexOf(item.ID) === -1,
+ );
// Insert pointers into their proper location
pointers.forEach((pointer) => {
@@ -185,6 +193,7 @@ export class Pointers extends Component {
addPointer = (post) => {
const id = post.ID;
const { posts, pointers } = this.state;
+ let { excludedPosts, removedPointers } = this.state;
if (!posts[id]) {
posts[id] = post;
@@ -202,13 +211,17 @@ export class Pointers extends Component {
return;
}
+ // Remove from excluded/removed lists if being re-added
+ excludedPosts = excludedPosts.filter((item) => item !== id);
+ removedPointers = removedPointers.filter((item) => item !== id);
+
pointers.push({
ID: id,
order: position,
type: 'custom-result',
});
- this.setState({ pointers });
+ this.setState({ pointers, excludedPosts, removedPointers });
};
/**
@@ -312,6 +325,7 @@ export class Pointers extends Component {
title,
pointers,
removedPointers,
+ excludedPosts,
searchText,
searchResults: searchResultsFromState,
} = this.state;
@@ -357,6 +371,7 @@ export class Pointers extends Component {
+
{(provided) => (
@@ -383,13 +398,16 @@ export class Pointers extends Component {
// Determine if this result is part of default search results or not
const itemType = item?.type || 'reordered';
- const tooltipText =
- itemType === 'reordered'
- ? __('Return to original position', 'elasticpress')
- : __(
- 'Remove custom result from results list',
- 'elasticpress',
- );
+ const isCustomResult = itemType === 'custom-result';
+ const tooltipText = isCustomResult
+ ? __(
+ 'Remove custom result from results list',
+ 'elasticpress',
+ )
+ : __('Return to original position', 'elasticpress');
+ const iconClass = isCustomResult
+ ? 'dashicons dashicons-trash delete-pointer'
+ : 'dashicons dashicons-undo delete-pointer';
return (
@@ -445,7 +463,7 @@ export class Pointers extends Component {
role="button"
tabIndex="0"
title={tooltipText}
- className="dashicons dashicons-undo delete-pointer"
+ className={iconClass}
onClick={(event) => {
event.preventDefault();
this.removePointer(item);
@@ -456,7 +474,15 @@ export class Pointers extends Component {
}}
>
- Remove Post
+ {isCustomResult
+ ? __(
+ 'Remove custom result',
+ 'elasticpress',
+ )
+ : __(
+ 'Return to original position',
+ 'elasticpress',
+ )}
)}
diff --git a/includes/classes/Feature/SearchOrdering/SearchOrdering.php b/includes/classes/Feature/SearchOrdering/SearchOrdering.php
index 431b314d7..b2f864508 100644
--- a/includes/classes/Feature/SearchOrdering/SearchOrdering.php
+++ b/includes/classes/Feature/SearchOrdering/SearchOrdering.php
@@ -372,8 +372,9 @@ public function get_pointer_data_for_localize() {
if ( empty( $pointers ) ) {
return [
- 'pointers' => [],
- 'posts' => [],
+ 'pointers' => [],
+ 'posts' => [],
+ 'excluded_posts' => [],
];
}
@@ -397,9 +398,12 @@ public function get_pointer_data_for_localize() {
$filtered_pointers[] = $pointers[ array_search( $post->ID, $post_ids, true ) ];
}
+ $excluded_posts = get_post_meta( $post_id, 'excluded_posts', true );
+
return [
- 'pointers' => $filtered_pointers,
- 'posts' => $final_posts,
+ 'pointers' => $filtered_pointers,
+ 'posts' => $final_posts,
+ 'excluded_posts' => ! empty( $excluded_posts ) ? $excluded_posts : [],
];
}
@@ -480,7 +484,7 @@ public function save_post( $post_id, $post ) {
// Search term changed, so remove it from all of the posts it was assigned to
if ( ! empty( $old_search_term ) && $old_search_term !== $post->post_title ) {
- $old_term = $this->create_or_return_custom_result_term( $old_search_term );
+ $old_term = $this->create_or_return_custom_result_term( $old_search_term, $post_id );
foreach ( array_flip( $previous_post_ids ) as $previous_post_id ) {
wp_remove_object_terms( $previous_post_id, $old_term->term_id, self::TAXONOMY_NAME );
@@ -505,7 +509,7 @@ public function save_post( $post_id, $post ) {
}
}
- $custom_result_term = $this->create_or_return_custom_result_term( $post->post_title );
+ $custom_result_term = $this->create_or_return_custom_result_term( $post->post_title, $post_id );
if ( $custom_result_term ) {
foreach ( $final_order_data as $final_order_datum ) {
@@ -529,18 +533,29 @@ public function save_post( $post_id, $post ) {
}
}
+ $excluded_posts = isset( $_POST['excluded_posts'] ) ? wp_unslash( $_POST['excluded_posts'] ) : '[]'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $excluded_posts = json_decode( $excluded_posts, true );
+ $excluded_posts = ! empty( $excluded_posts ) && is_array( $excluded_posts ) ? array_map( 'intval', $excluded_posts ) : [];
+
+ // If the search term changed, clear the excluded list
+ if ( ! empty( $old_search_term ) && $old_search_term !== $post->post_title ) {
+ $excluded_posts = [];
+ }
+
update_post_meta( $post_id, 'pointers', $final_order_data );
+ update_post_meta( $post_id, 'excluded_posts', $excluded_posts );
update_post_meta( $post_id, 'search_term', $post->post_title );
}
/**
* Creates a term in the taxonomy for tracking ordered results or returns the existing term
*
- * @param string $term_name Term name to fetch or create
+ * @param string $term_name Term name to fetch or create
+ * @param int|null $pointer_id Optional. Pointer post ID to associate with the term.
*
* @return false|\WP_Term
*/
- public function create_or_return_custom_result_term( $term_name ) {
+ public function create_or_return_custom_result_term( $term_name, $pointer_id = null ) {
$term = get_term_by( 'name', $term_name, self::TAXONOMY_NAME );
if ( ! $term ) {
@@ -553,6 +568,10 @@ public function create_or_return_custom_result_term( $term_name ) {
$term = get_term( $term_ids['term_id'], self::TAXONOMY_NAME );
}
+ if ( $term && ! empty( $pointer_id ) ) {
+ update_term_meta( $term->term_id, 'ep_pointer_id', $pointer_id );
+ }
+
return $term;
}
@@ -651,8 +670,8 @@ public function filter_column_names( $columns ) {
public function posts_results( $posts, $query ) {
if ( is_array( $posts ) && $query->is_search() ) {
$search_query = strtolower( $query->get( 's' ) );
-
- $to_inject = array();
+ $to_inject = array();
+ $pointer_id = false;
foreach ( $posts as $key => &$post ) {
if ( isset( $post->terms ) && isset( $post->terms[ self::TAXONOMY_NAME ] ) ) {
@@ -660,6 +679,10 @@ public function posts_results( $posts, $query ) {
if ( strtolower( $current_term['name'] ) === $search_query ) {
$to_inject[ $current_term['term_order'] ] = $post;
+ if ( empty( $pointer_id ) && ! empty( $current_term['term_id'] ) ) {
+ $pointer_id = get_term_meta( (int) $current_term['term_id'], 'ep_pointer_id', true );
+ }
+
unset( $posts[ $key ] );
break;
@@ -682,11 +705,51 @@ public function posts_results( $posts, $query ) {
// reindex just in case we got out of order keys
$posts = array_values( $posts );
+
+ $posts = $this->filter_excluded_posts( $posts, $pointer_id );
}
return $posts;
}
+ /**
+ * Filters out posts that have been explicitly excluded from a custom search result.
+ *
+ * @param array $posts Current array of post results.
+ * @param int $pointer_id ID of the custom search result pointer post.
+ * @return array Final modified posts array.
+ */
+ public function filter_excluded_posts( $posts, $pointer_id ) {
+ static $excluded_cache = array();
+
+ if ( ! isset( $excluded_cache[ $pointer_id ] ) ) {
+ $excluded_cache[ $pointer_id ] = array();
+
+ if ( ! empty( $pointer_id ) ) {
+ $excluded = get_post_meta( $pointer_id, 'excluded_posts', true );
+
+ if ( ! empty( $excluded ) && is_array( $excluded ) ) {
+ $excluded_cache[ $pointer_id ] = array_map( 'intval', $excluded );
+ }
+ }
+ }
+
+ $excluded_posts = $excluded_cache[ $pointer_id ];
+
+ if ( empty( $excluded_posts ) ) {
+ return $posts;
+ }
+
+ $posts = array_filter(
+ $posts,
+ function ( $post ) use ( $excluded_posts ) {
+ return ! in_array( (int) $post->ID, $excluded_posts, true );
+ }
+ );
+
+ return array_values( $posts );
+ }
+
/**
* Registers the API endpoint for searching from the admin interface
*/
diff --git a/tests/php/features/TestSearchOrdering.php b/tests/php/features/TestSearchOrdering.php
index 185fe3c63..699f6c609 100644
--- a/tests/php/features/TestSearchOrdering.php
+++ b/tests/php/features/TestSearchOrdering.php
@@ -212,9 +212,10 @@ public function testGetPointerData() {
$localized_data = $this->get_feature()->get_pointer_data_for_localize();
- $this->assertEquals( 2, count( $localized_data ) );
+ $this->assertEquals( 3, count( $localized_data ) );
$this->assertArrayHasKey( 'pointers', $localized_data );
$this->assertArrayHasKey( 'posts', $localized_data );
+ $this->assertArrayHasKey( 'excluded_posts', $localized_data );
$this->assertEquals( $post_id_1, $localized_data['pointers'][0]['ID'] );
$this->assertEquals( $post_id_2, $localized_data['pointers'][1]['ID'] );
$this->assertInstanceOf( '\WP_Post', $localized_data['posts'][ $post_id_1 ] );
@@ -742,4 +743,143 @@ public function testHandlePostUntrash() {
$this->assertContains( 'findme', wp_list_pluck( get_the_terms( $post_id_1, 'ep_custom_result' ), 'name' ) );
$this->assertContains( 'findme', wp_list_pluck( get_the_terms( $post_id_2, 'ep_custom_result' ), 'name' ) );
}
+
+ /**
+ * Test that excluded posts are persisted during save.
+ */
+ public function testSavePostWithExcludedPosts() {
+ $post_id_1 = $this->ep_factory->post->create( [ 'post_content' => 'findme test 1' ] );
+ $post_id_2 = $this->ep_factory->post->create( [ 'post_content' => 'findme test 2' ] );
+ $pointer_id = wp_insert_post(
+ [
+ 'post_title' => 'findme',
+ 'post_status' => 'publish',
+ 'post_type' => 'ep-pointer',
+ ]
+ );
+
+ $_POST = [
+ 'search-ordering-nonce' => wp_create_nonce( 'save-search-ordering' ),
+ 'ordered_posts' => wp_json_encode(
+ [
+ [
+ 'ID' => $post_id_1,
+ 'order' => 1,
+ ],
+ ]
+ ),
+ 'excluded_posts' => wp_json_encode( [ $post_id_2 ] ),
+ ];
+
+ $this->get_feature()->save_post( $pointer_id, get_post( $pointer_id ) );
+
+ $excluded = get_post_meta( $pointer_id, 'excluded_posts', true );
+ $this->assertEquals( [ $post_id_2 ], $excluded );
+ }
+
+ /**
+ * Test that excluded posts are filtered out of posts_results.
+ */
+ public function testPostsResultsFiltersExcludedPosts() {
+ ElasticPress\Features::factory()->activate_feature( 'search' );
+ ElasticPress\Features::factory()->setup_features();
+ ElasticPress\Features::factory()->get_registered_feature( 'search' )->search_setup();
+
+ $post_id_1 = $this->ep_factory->post->create( [ 'post_content' => 'findme test 1' ] );
+ $post_id_2 = $this->ep_factory->post->create( [ 'post_content' => 'findme test 2' ] );
+ $post_id_3 = $this->ep_factory->post->create( [ 'post_content' => 'findme test 3' ] );
+
+ $pointer_id = wp_insert_post(
+ [
+ 'post_title' => 'findme',
+ 'post_status' => 'publish',
+ 'post_type' => 'ep-pointer',
+ ]
+ );
+
+ $_POST = [
+ 'search-ordering-nonce' => wp_create_nonce( 'save-search-ordering' ),
+ 'ordered_posts' => wp_json_encode(
+ [
+ [
+ 'ID' => $post_id_2,
+ 'order' => 1,
+ ],
+ ]
+ ),
+ 'excluded_posts' => wp_json_encode( [ $post_id_1 ] ),
+ ];
+
+ $this->get_feature()->save_post( $pointer_id, get_post( $pointer_id ) );
+
+ ElasticPress\Indexables::factory()->get( 'post' )->index( $post_id_2, true );
+ ElasticPress\Elasticsearch::factory()->refresh_indices();
+
+ $query = new \WP_Query( [ 's' => 'findme' ] );
+
+ $new_posts = $this->get_feature()->posts_results( $query->posts, $query );
+ $post_ids = wp_list_pluck( $new_posts, 'ID' );
+
+ $this->assertEquals( 2, count( $new_posts ) );
+ $this->assertContains( $post_id_2, $post_ids );
+ $this->assertContains( $post_id_3, $post_ids );
+ $this->assertNotContains( $post_id_1, $post_ids );
+ $this->assertEquals( $post_id_2, $new_posts[0]->ID );
+ }
+
+ /**
+ * Test that excluded posts are cleared when the search term changes.
+ */
+ public function testExcludedPostsClearedOnSearchTermChange() {
+ $post_id_1 = $this->ep_factory->post->create( [ 'post_content' => 'findme test 1' ] );
+ $post_id_2 = $this->ep_factory->post->create( [ 'post_content' => 'findme test 2' ] );
+ $pointer_id = wp_insert_post(
+ [
+ 'post_title' => 'findme',
+ 'post_status' => 'publish',
+ 'post_type' => 'ep-pointer',
+ ]
+ );
+
+ $_POST = [
+ 'search-ordering-nonce' => wp_create_nonce( 'save-search-ordering' ),
+ 'ordered_posts' => wp_json_encode(
+ [
+ [
+ 'ID' => $post_id_1,
+ 'order' => 1,
+ ],
+ ]
+ ),
+ 'excluded_posts' => wp_json_encode( [ $post_id_2 ] ),
+ ];
+
+ $this->get_feature()->save_post( $pointer_id, get_post( $pointer_id ) );
+ $this->assertEquals( [ $post_id_2 ], get_post_meta( $pointer_id, 'excluded_posts', true ) );
+
+ $_POST = [];
+
+ wp_update_post(
+ [
+ 'ID' => $pointer_id,
+ 'post_title' => '10up',
+ ]
+ );
+
+ $_POST = [
+ 'search-ordering-nonce' => wp_create_nonce( 'save-search-ordering' ),
+ 'ordered_posts' => wp_json_encode(
+ [
+ [
+ 'ID' => $post_id_1,
+ 'order' => 1,
+ ],
+ ]
+ ),
+ 'excluded_posts' => wp_json_encode( [ $post_id_2 ] ),
+ ];
+
+ $this->get_feature()->save_post( $pointer_id, get_post( $pointer_id ) );
+ $this->assertEquals( [], get_post_meta( $pointer_id, 'excluded_posts', true ) );
+ }
}