diff --git a/assets/js/api-search/index.js b/assets/js/api-search/index.js index 87d610e013..77f6d3a801 100644 --- a/assets/js/api-search/index.js +++ b/assets/js/api-search/index.js @@ -12,6 +12,7 @@ import { WPElement, } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies. @@ -316,7 +317,18 @@ export const ApiSearchProvider = ({ return; } - setResults(response); + /** + * Filter the search results response. + * + * @filter ep.InstantResults.response + * @since 5.3.0 + * + * @param {object} response The search results response. + * @returns {object} Filtered search results response. + */ + const filteredResponse = applyFilters('ep.InstantResults.response', response); + + setResults(filteredResponse || response); } catch (e) { const errorMessage = sprintf( /* translators: Error message */ diff --git a/assets/js/instant-results/components/facets/facet.js b/assets/js/instant-results/components/facets/facet.js index 8c3a40f395..43299e1876 100644 --- a/assets/js/instant-results/components/facets/facet.js +++ b/assets/js/instant-results/components/facets/facet.js @@ -2,6 +2,7 @@ * WordPress dependencies. */ import { WPElement } from '@wordpress/element'; +import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies. @@ -24,16 +25,36 @@ import TaxonomyTermsFacet from './taxonomy-terms-facet'; export default ({ index, label, name, postTypes, type }) => { const defaultIsOpen = index < 2; + /** + * Filter the facet label. + * + * @filter ep.InstantResults.filter.label + * @since 5.3.0 + * + * @param {string} label Facet label. + * @param {string} name Facet name. + * @param {string} type Facet type. + * @param {string[]} postTypes Facet post types. + * @returns {string} Filtered facet label. + */ + const filteredLabel = applyFilters( + 'ep.InstantResults.filter.label', + label, + name, + type, + postTypes, + ); + switch (type) { case 'post_type': - return ; + return ; case 'price_range': - return ; + return ; case 'taxonomy': return ( diff --git a/assets/js/instant-results/components/facets/post-type-facet.js b/assets/js/instant-results/components/facets/post-type-facet.js index a04f714120..978659da52 100644 --- a/assets/js/instant-results/components/facets/post-type-facet.js +++ b/assets/js/instant-results/components/facets/post-type-facet.js @@ -3,6 +3,7 @@ */ import { useCallback, useMemo, WPElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies. @@ -61,7 +62,20 @@ export default ({ defaultIsOpen, label }) => { /** * Reduce buckets to options. */ - const options = useMemo(() => buckets.reduce(reduceOptions, []), [buckets, reduceOptions]); + const options = useMemo(() => { + const reducedOptions = buckets.reduce(reduceOptions, []); + + /** + * Filter the post type filter options. + * + * @filter ep.InstantResults.filter.postType.options + * @since 5.3.0 + * + * @param {object[]} options Post type filter options. + * @returns {object[]} Filtered post type filter options. + */ + return applyFilters('ep.InstantResults.filter.postType.options', reducedOptions); + }, [buckets, reduceOptions]); /** * Handle checkbox change event. diff --git a/assets/js/instant-results/components/facets/price-range-facet.js b/assets/js/instant-results/components/facets/price-range-facet.js index aed619c2fd..dd9efb5306 100644 --- a/assets/js/instant-results/components/facets/price-range-facet.js +++ b/assets/js/instant-results/components/facets/price-range-facet.js @@ -3,6 +3,7 @@ */ import { useLayoutEffect, useState, WPElement } from '@wordpress/element'; import { _x, sprintf } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies. @@ -38,8 +39,23 @@ export default ({ defaultIsOpen, label }) => { /** * Minimum and maximum possible values. */ - const max = Math.ceil(maxAgg); - const min = Math.floor(minAgg); + let max = Math.ceil(maxAgg); + let min = Math.floor(minAgg); + + /** + * Filter the price range values. + * + * @filter ep.InstantResults.filter.priceRange.options + * @since 5.3.0 + * + * @param {object} range Price range values. + * @param {number} range.min Minimum price. + * @param {number} range.max Maximum price. + * @returns {object} Filtered price range values. + */ + const range = applyFilters('ep.InstantResults.filter.priceRange.options', { min, max }); + + ({ min, max } = range); /** * Current minimum and maximum values. diff --git a/assets/js/instant-results/components/results/did-you-mean.js b/assets/js/instant-results/components/results/did-you-mean.js index 4197af7fe1..ff15185752 100644 --- a/assets/js/instant-results/components/results/did-you-mean.js +++ b/assets/js/instant-results/components/results/did-you-mean.js @@ -3,6 +3,7 @@ */ import { __, sprintf } from '@wordpress/i18n'; import { WPElement, createInterpolateElement } from '@wordpress/element'; +import { applyFilters } from '@wordpress/hooks'; import { showSuggestions, suggestionsBehavior } from '../../config'; @@ -22,7 +23,7 @@ export default (props) => { // Get other terms by excluding the first term from suggestedTerms const otherTerms = suggestedTerms.slice(1); - return ( + const didYouMean = ( <> {showSuggestions && suggestedTerms && suggestedTerms?.[0]?.text && (
@@ -67,4 +68,16 @@ export default (props) => { )} ); + + /** + * Filter the did you mean component. + * + * @filter ep.InstantResults.component.didYouMean + * @since 5.3.0 + * + * @param {WPElement} didYouMean Did you mean component. + * @param {object} props Props. + * @returns {WPElement} Did you mean component. + */ + return applyFilters('ep.InstantResults.component.didYouMean', didYouMean, props); }; diff --git a/assets/js/instant-results/components/tools/sort.js b/assets/js/instant-results/components/tools/sort.js index 22d1add8ee..a455d9e3a5 100644 --- a/assets/js/instant-results/components/tools/sort.js +++ b/assets/js/instant-results/components/tools/sort.js @@ -3,6 +3,7 @@ */ import { useMemo, WPElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies. @@ -41,7 +42,7 @@ export default () => { search({ orderby, order }); }; - return ( + const sort = ( ); + + /** + * Filter the sort component. + * + * @filter ep.InstantResults.component.sort + * @since 5.3.0 + * + * @param {WPElement} sort Sort component. + * @param {object} context Sort context. + * @param {string} context.orderby Current orderby value. + * @param {string} context.order Current order value. + * @returns {WPElement} Sort component. + */ + return applyFilters('ep.InstantResults.component.sort', sort, { orderby, order }); }; diff --git a/includes/classes/Feature/InstantResults/InstantResults.php b/includes/classes/Feature/InstantResults/InstantResults.php index ebf5985964..177ee18621 100644 --- a/includes/classes/Feature/InstantResults/InstantResults.php +++ b/includes/classes/Feature/InstantResults/InstantResults.php @@ -224,27 +224,39 @@ public function enqueue_frontend_assets() { */ $api_endpoint = apply_filters( 'ep_instant_results_search_endpoint', "api/v1/search/posts/{$index}", $index ); + $ep_instant_results_config = array( + 'apiEndpoint' => $api_endpoint, + 'apiHost' => ( 0 !== strpos( $api_endpoint, 'http' ) ) ? esc_url_raw( $this->host ) : '', + 'argsSchema' => $this->get_args_schema(), + 'currencyCode' => $this->is_woocommerce ? get_woocommerce_currency() : false, + 'facets' => $this->get_facets_for_frontend(), + 'highlightTag' => $this->settings['highlight_tag'], + 'isWooCommerce' => $this->is_woocommerce, + 'locale' => str_replace( '_', '-', get_locale() ), + 'matchType' => $this->settings['match_type'], + 'paramPrefix' => 'ep-', + 'postTypeLabels' => $this->get_post_type_labels(), + 'termCount' => $this->settings['term_count'], + 'numberedPagination' => $this->settings['numbered_pagination'], + 'requestIdBase' => Utils\get_request_id_base(), + 'showSuggestions' => \ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->is_active(), + 'suggestionsBehavior' => $this->settings['search_behavior'], + ); + + /** + * Filter the Instant Results configuration passed to the front end. + * + * @since 5.3.0 + * @hook ep_instant_results_config + * @param {array} $ep_instant_results_config Instant Results configuration. + * @returns {array} Filtered Instant Results configuration. + */ + $ep_instant_results_config = apply_filters( 'ep_instant_results_config', $ep_instant_results_config ); + wp_localize_script( 'elasticpress-instant-results', 'epInstantResults', - array( - 'apiEndpoint' => $api_endpoint, - 'apiHost' => ( 0 !== strpos( $api_endpoint, 'http' ) ) ? esc_url_raw( $this->host ) : '', - 'argsSchema' => $this->get_args_schema(), - 'currencyCode' => $this->is_woocommerce ? get_woocommerce_currency() : false, - 'facets' => $this->get_facets_for_frontend(), - 'highlightTag' => $this->settings['highlight_tag'], - 'isWooCommerce' => $this->is_woocommerce, - 'locale' => str_replace( '_', '-', get_locale() ), - 'matchType' => $this->settings['match_type'], - 'paramPrefix' => 'ep-', - 'postTypeLabels' => $this->get_post_type_labels(), - 'termCount' => $this->settings['term_count'], - 'numberedPagination' => $this->settings['numbered_pagination'], - 'requestIdBase' => Utils\get_request_id_base(), - 'showSuggestions' => \ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->is_active(), - 'suggestionsBehavior' => $this->settings['search_behavior'], - ) + $ep_instant_results_config ); } diff --git a/tests/e2e/src/specs/instant-results.spec.ts b/tests/e2e/src/specs/instant-results.spec.ts index 6a40945cbc..5799dcd05e 100644 --- a/tests/e2e/src/specs/instant-results.spec.ts +++ b/tests/e2e/src/specs/instant-results.spec.ts @@ -120,7 +120,7 @@ test.describe('Instant Results Feature', { tag: '@group1' }, () => { test.beforeEach(async ({ loggedInPage }) => { await deactivatePlugin( loggedInPage, - 'custom-instant-results-template open-instant-results-with-buttons filter-instant-results-per-page filter-instant-results-args-schema', + 'custom-instant-results-template open-instant-results-with-buttons filter-instant-results-per-page filter-instant-results-args-schema filter-instant-results-label filter-instant-results-post-type-options filter-instant-results-price-range filter-instant-results-components filter-instant-results-response filter-instant-results-config filter-instant-results-category-terms', 'wpCli', ); }); @@ -598,6 +598,149 @@ test.describe('Instant Results Feature', { tag: '@group1' }, () => { 'wpCli', ); }); + + test('Can filter facet labels', async ({ loggedInPage }) => { + await maybeEnableFeature('instant-results'); + await activatePlugin(loggedInPage, 'filter-instant-results-label', 'wpCli'); + + await goToAdminPage(loggedInPage, 'admin.php?page=elasticpress'); + const apiResponsePromise = loggedInPage.waitForResponse( + '**/wp-json/elasticpress/v1/features*', + ); + + await loggedInPage.getByRole('button', { name: 'Live Search' }).click(); + await loggedInPage.getByRole('button', { name: 'Instant Results' }).click(); + await addInstantResultFilter(loggedInPage, 'post type'); + await loggedInPage.getByRole('button', { name: 'Save changes' }).click(); + + await apiResponsePromise; + + const responsePromise = instantResultRequestPromise(loggedInPage, 'search=new'); + await loggedInPage.goto('/'); + await searchFor(loggedInPage, 'new'); + await responsePromise; + + const postTypeFacet = loggedInPage.locator('.ep-search-panel').filter({ + has: loggedInPage + .locator('.ep-search-panel__button') + .filter({ hasText: 'Filtered Type Label' }), + }); + await expect(postTypeFacet).toBeVisible(); + }); + + test('Can filter post type options', async ({ loggedInPage }) => { + await maybeEnableFeature('instant-results'); + await activatePlugin( + loggedInPage, + 'filter-instant-results-post-type-options', + 'wpCli', + ); + + await goToAdminPage(loggedInPage, 'admin.php?page=elasticpress'); + const apiResponsePromise = loggedInPage.waitForResponse( + '**/wp-json/elasticpress/v1/features*', + ); + + await loggedInPage.getByRole('button', { name: 'Live Search' }).click(); + await loggedInPage.getByRole('button', { name: 'Instant Results' }).click(); + await addInstantResultFilter(loggedInPage, 'post type'); + await loggedInPage.getByRole('button', { name: 'Save changes' }).click(); + + await apiResponsePromise; + + const responsePromise = instantResultRequestPromise(loggedInPage, 'search=new'); + await loggedInPage.goto('/'); + await searchFor(loggedInPage, 'new'); + await responsePromise; + + const postTypeFacet = loggedInPage.locator('.ep-search-panel').filter({ + has: loggedInPage + .locator('.ep-search-panel__button') + .filter({ hasText: 'Type' }), + }); + await expect(postTypeFacet).toContainText('(filtered)'); + }); + + test('Can filter price range options', async ({ loggedInPage }) => { + await maybeEnableFeature('instant-results'); + await maybeEnableFeature('woocommerce'); + await activatePlugin(loggedInPage, 'filter-instant-results-price-range', 'wpCli'); + await wpCli('wp elasticpress sync'); + + await goToAdminPage(loggedInPage, 'admin.php?page=elasticpress'); + const apiResponsePromise = loggedInPage.waitForResponse( + '**/wp-json/elasticpress/v1/features*', + ); + + await loggedInPage.getByRole('button', { name: 'Live Search' }).click(); + await loggedInPage.getByRole('button', { name: 'Instant Results' }).click(); + await addInstantResultFilter(loggedInPage, 'price'); + await loggedInPage.getByRole('button', { name: 'Save changes' }).click(); + + await apiResponsePromise; + + const responsePromise = instantResultRequestPromise( + loggedInPage, + 'search=ergonomic', + ); + await loggedInPage.goto('/'); + await searchFor(loggedInPage, 'ergonomic'); + await responsePromise; + + const priceValues = await loggedInPage + .locator('.ep-search-price-facet__values') + .textContent(); + expect(priceValues).toContain('$0'); + expect(priceValues).toContain('$999'); + }); + + test('Can filter components (did-you-mean and sort)', async ({ loggedInPage }) => { + await maybeEnableFeature('instant-results'); + await maybeEnableFeature('did-you-mean'); + await activatePlugin(loggedInPage, 'filter-instant-results-components', 'wpCli'); + + await wpCli('wp elasticpress sync --setup --yes'); + + const responsePromise = instantResultRequestPromise( + loggedInPage, + 'search=wordpless', + ); + await loggedInPage.goto('/'); + await searchFor(loggedInPage, 'wordpless'); + await expect(loggedInPage.locator('.ep-search-modal')).toBeVisible(); + await responsePromise; + + await expect(loggedInPage.locator('.my-custom-did-you-mean')).toBeVisible(); + await expect(loggedInPage.locator('.my-custom-sort')).toBeVisible(); + }); + + test('Can filter the search response', async ({ loggedInPage }) => { + await maybeEnableFeature('instant-results'); + await activatePlugin(loggedInPage, 'filter-instant-results-response', 'wpCli'); + + const responsePromise = instantResultRequestPromise(loggedInPage, 'search=new'); + await loggedInPage.goto('/'); + await searchFor(loggedInPage, 'new'); + await expect(loggedInPage.locator('.ep-search-modal')).toBeVisible(); + await responsePromise; + + await expect(loggedInPage.locator('.ep-search-result')).not.toBeVisible(); + await expect(loggedInPage.locator('.ep-search-results__title')).toContainText('0'); + }); + + test('Can filter the front-end config', async ({ loggedInPage }) => { + await maybeEnableFeature('instant-results'); + await activatePlugin(loggedInPage, 'filter-instant-results-config', 'wpCli'); + + await loggedInPage.goto('/'); + + const config = await loggedInPage.evaluate(() => { + return (window as any).epInstantResults; + }); + + expect(config.highlightTag).toBe('mark'); + expect(config.epTestFilteredConfig).toBe(true); + }); }); test('Is possible to filter the arguments schema', async ({ loggedInPage }) => { diff --git a/tests/e2e/src/wordpress-files/test-plugins/filter-instant-results-components.php b/tests/e2e/src/wordpress-files/test-plugins/filter-instant-results-components.php new file mode 100644 index 0000000000..7ea9f5ecd5 --- /dev/null +++ b/tests/e2e/src/wordpress-files/test-plugins/filter-instant-results-components.php @@ -0,0 +1,34 @@ + + + + + + + + + + +