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