diff --git a/assets/js/api-search/src/reducer.js b/assets/js/api-search/src/reducer.js index b4250ac86..01f641939 100644 --- a/assets/js/api-search/src/reducer.js +++ b/assets/js/api-search/src/reducer.js @@ -3,6 +3,54 @@ */ import { getArgsWithoutConstraints } from './utilities'; +/** + * Reorder search results to respect Custom Results ordering. + * + * Posts with an ep_custom_result taxonomy term matching the search term + * are placed at their specified term_order positions. Mirrors the PHP + * SearchOrdering::posts_results() logic. + * + * @param {Array} hits Elasticsearch hits array. + * @param {string} searchTerm Current search term. + * @returns {Array} Reordered hits. + */ +const applyCustomResultsOrdering = (hits, searchTerm) => { + if (!searchTerm || !hits.length) { + return hits; + } + + const normalizedTerm = searchTerm.toLowerCase(); + const toInject = {}; + const remaining = []; + + for (const hit of hits) { + const customResults = hit._source?.terms?.ep_custom_result; + const matchingTerm = Array.isArray(customResults) + ? customResults.find((term) => term.name && term.name.toLowerCase() === normalizedTerm) + : null; + + if (matchingTerm && matchingTerm.term_order) { + toInject[matchingTerm.term_order] = hit; + } else { + remaining.push(hit); + } + } + + if (!Object.keys(toInject).length) { + return hits; + } + + const sortedPositions = Object.keys(toInject) + .map(Number) + .sort((a, b) => a - b); + + for (const position of sortedPositions) { + remaining.splice(position - 1, 0, toInject[position]); + } + + return remaining; +}; + /** * Reducer function. * @@ -74,7 +122,7 @@ export default (state, action) => { const totalNumber = typeof total === 'number' ? total : total.value; newState.aggregations = aggregations; - newState.searchResults = hits; + newState.searchResults = applyCustomResultsOrdering(hits, newState.args.search); newState.searchTerm = newState.args.search; newState.totalResults = totalNumber; newState.suggestedTerms = suggest?.ep_suggestion?.[0]?.options || []; diff --git a/tests/e2e/src/specs/instant-results.spec.ts b/tests/e2e/src/specs/instant-results.spec.ts index 6a40945cb..794634027 100644 --- a/tests/e2e/src/specs/instant-results.spec.ts +++ b/tests/e2e/src/specs/instant-results.spec.ts @@ -463,6 +463,89 @@ test.describe('Instant Results Feature', { tag: '@group1' }, () => { ); }); + test('Custom Results pointer order is respected in Instant Results', async ({ + loggedInPage, + }) => { + const searchTerm = 'block'; + + await maybeEnableFeature('instant-results'); + await maybeEnableFeature('searchordering'); + await wpCli('elasticpress put-search-template', true); + + // Remove any existing pointers to avoid pollution from prior tests. + await wpCliEval(` + $ep_pointers = get_posts( + [ + 'post_type' => 'ep-pointer', + 'numberposts' => 999, + ] + ); + foreach ( $ep_pointers as $pointer ) { + wp_delete_post( $pointer->ID, true ); + } + `); + + /** + * Create a pointer that pins results for the search term in a + * non-default order. + */ + await goToAdminPage(loggedInPage, 'post-new.php?post_type=ep-pointer'); + + const previewResponsePromise = loggedInPage.waitForResponse( + '**/wp-json/elasticpress/v1/pointer_preview*', + ); + await loggedInPage.locator('#titlewrap input').pressSequentially(searchTerm); + await previewResponsePromise; + + const initialOrder = await loggedInPage + .locator('.pointers .pointer .title') + .allTextContents(); + expect(initialOrder.length).toBeGreaterThan(1); + + // Swap the first two results via keyboard drag. + await loggedInPage + .locator('.pointers > div') + .first() + .locator('.dashicons-menu') + .focus(); + await loggedInPage.keyboard.press('Space'); + await loggedInPage.keyboard.press('ArrowDown'); + await loggedInPage.keyboard.press('Space'); + + const expectedOrder = await loggedInPage + .locator('.pointers .pointer .title') + .allTextContents(); + expect(expectedOrder).not.toEqual(initialOrder); + + await loggedInPage.click('#publish'); + await loggedInPage.waitForLoadState('networkidle'); + + // Allow Elasticsearch time to index the pointer assignments. + await loggedInPage.waitForTimeout(2000); + + /** + * Search via Instant Results and verify the modal returns the + * pinned posts in the order configured on the pointer. + */ + const responsePromise = instantResultRequestPromise( + loggedInPage, + `search=${searchTerm}`, + ); + await loggedInPage.goto('/'); + await searchFor(loggedInPage, searchTerm); + await expect(loggedInPage.locator('.ep-search-modal')).toBeVisible(); + await responsePromise; + await expect(loggedInPage.locator('.ep-search-result').first()).toBeVisible(); + + const irTitles = await loggedInPage + .locator('.ep-search-result__title') + .allTextContents(); + + for (let index = 0; index < expectedOrder.length; index++) { + expect(irTitles[index]).toBe(expectedOrder[index]); + } + }); + test('Is possible to set the default post type from a search form', async ({ loggedInPage, }) => {