diff --git a/.wp-env.json b/.wp-env.json
index 65868db282..a11eaa857b 100644
--- a/.wp-env.json
+++ b/.wp-env.json
@@ -40,7 +40,9 @@
"wp-content/plugins/sync-error.php": "./tests/cypress/wordpress-files/test-plugins/sync-error.php",
"wp-content/plugins/unsupported-server-software.php": "./tests/cypress/wordpress-files/test-plugins/unsupported-server-software.php",
"wp-content/plugins/unsupported-elasticsearch-version.php": "./tests/cypress/wordpress-files/test-plugins/unsupported-elasticsearch-version.php",
- "wp-content/uploads/content-example.xml": "./tests/cypress/wordpress-files/test-docs/content-example.xml"
+ "wp-content/uploads/content-example.xml": "./tests/cypress/wordpress-files/test-docs/content-example.xml",
+ "wp-content/plugins/autosuggestv2-proxy.php": "./tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php",
+ "wp-content/plugins/autosuggestv2-proxy-plugin.php": "./tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy-plugin.php"
}
}
}
diff --git a/assets/css/autosuggest-v2.css b/assets/css/autosuggest-v2.css
new file mode 100644
index 0000000000..52243769f8
--- /dev/null
+++ b/assets/css/autosuggest-v2.css
@@ -0,0 +1,49 @@
+@import "./global/colors.css";
+
+.ep-autosuggest-wrapper {
+ position: relative;
+
+ & .ep-autosuggest {
+ background: var(--ep-c-white);
+ border: 1px solid var(--ep-c-white-gray);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ width: 100%;
+ z-index: 200;
+
+ & ul {
+ list-style: none;
+ margin: 0 !important;
+
+ & li {
+ font-family: sans-serif;
+
+ & a {
+ color: var(--ep-c-black);
+ cursor: pointer;
+ display: block;
+ font-size: 14px;
+ font-weight: 700;
+ padding: 2px 10px;
+ text-decoration: none;
+ transition: background-color 0.15s, color 0.15s;
+
+ &:hover,
+ &:active {
+ background-color: var(--ep-c-medium-white);
+ text-decoration: none;
+ }
+ }
+ }
+ }
+ }
+
+ & .selected {
+ background-color: var(--ep-c-medium-white);
+ text-decoration: none;
+ }
+}
+
+.ep-autosuggest-dropdown-container {
+ position: absolute;
+ width: 100%;
+}
diff --git a/assets/js/api-search/index.js b/assets/js/api-search/index.js
index b112178b01..d3b8039d04 100644
--- a/assets/js/api-search/index.js
+++ b/assets/js/api-search/index.js
@@ -25,6 +25,8 @@ import {
getUrlWithParams,
} from './src/utilities';
+import { applyResultsFilter } from '../autosuggest-v2/hooks';
+
/**
* Instant Results context.
*/
@@ -42,6 +44,7 @@ const Context = createContext();
* @param {WPElement} props.children Component children.
* @param {string} props.paramPrefix Prefix used to set and parse URL parameters.
* @param {Function} props.onAuthError Function to run when request authentication fails.
+ * @param {boolean} props.useUrlParams Whether to use URL parameters for state (default true).
* @returns {WPElement} Component.
*/
export const ApiSearchProvider = ({
@@ -53,17 +56,18 @@ export const ApiSearchProvider = ({
children,
paramPrefix,
onAuthError,
+ useUrlParams = true,
}) => {
/**
* Any default args from the URL.
*/
const defaultArgsFromUrl = useMemo(() => {
- if (!paramPrefix) {
+ if (!paramPrefix || !useUrlParams) {
return {};
}
return getArgsFromUrlParams(argsSchema, paramPrefix);
- }, [argsSchema, paramPrefix]);
+ }, [argsSchema, paramPrefix, useUrlParams]);
/**
* All default args including defaults from the schema.
@@ -81,8 +85,8 @@ export const ApiSearchProvider = ({
* Whether the provider is "on" by default.
*/
const defaultIsOn = useMemo(() => {
- return Object.keys(defaultArgsFromUrl).length > 0;
- }, [defaultArgsFromUrl]);
+ return useUrlParams ? Object.keys(defaultArgsFromUrl).length > 0 : false;
+ }, [defaultArgsFromUrl, useUrlParams]);
/**
* Set up fetch method.
@@ -222,7 +226,7 @@ export const ApiSearchProvider = ({
* @returns {void}
*/
const pushState = useCallback(() => {
- if (typeof paramPrefix === 'undefined') {
+ if (typeof paramPrefix === 'undefined' || !useUrlParams) {
return;
}
@@ -243,7 +247,7 @@ export const ApiSearchProvider = ({
} else {
window.history.replaceState(state, document.title, window.location.href);
}
- }, [argsSchema, paramPrefix]);
+ }, [argsSchema, paramPrefix, useUrlParams]);
/**
* Handle popstate event.
@@ -252,7 +256,7 @@ export const ApiSearchProvider = ({
*/
const onPopState = useCallback(
(event) => {
- if (typeof paramPrefix === 'undefined') {
+ if (typeof paramPrefix === 'undefined' || !useUrlParams) {
return;
}
@@ -262,7 +266,7 @@ export const ApiSearchProvider = ({
popState(event.state);
}
},
- [paramPrefix],
+ [paramPrefix, useUrlParams],
);
/**
@@ -271,12 +275,14 @@ export const ApiSearchProvider = ({
* @returns {Function} A cleanup function.
*/
const handleInit = useCallback(() => {
- window.addEventListener('popstate', onPopState);
-
- return () => {
- window.removeEventListener('popstate', onPopState);
- };
- }, [onPopState]);
+ if (useUrlParams) {
+ window.addEventListener('popstate', onPopState);
+ return () => {
+ window.removeEventListener('popstate', onPopState);
+ };
+ }
+ return () => {};
+ }, [onPopState, useUrlParams]);
/**
* Handle a change to search args.
@@ -285,9 +291,9 @@ export const ApiSearchProvider = ({
*/
const handleSearch = useCallback(() => {
const handle = async () => {
- const { args, isOn, isPoppingState } = stateRef.current;
+ const { args, isOn, isPoppingState, searchTerm } = stateRef.current;
- if (!isPoppingState) {
+ if (!isPoppingState && useUrlParams) {
pushState();
}
@@ -306,6 +312,11 @@ export const ApiSearchProvider = ({
return;
}
+ // Apply filters to search results if hooks are available
+ if (response.hits && response.hits.hits && !useUrlParams) {
+ response.hits.hits = applyResultsFilter(response.hits.hits, searchTerm);
+ }
+
setResults(response);
} catch (e) {
const errorMessage = sprintf(
@@ -321,7 +332,7 @@ export const ApiSearchProvider = ({
};
handle();
- }, [argsSchema, fetchResults, pushState]);
+ }, [argsSchema, fetchResults, pushState, useUrlParams]);
/**
* Effects.
@@ -372,6 +383,7 @@ export const ApiSearchProvider = ({
turnOff,
suggestedTerms,
isFirstSearch,
+ useUrlParams,
};
return
+ {suggestion._source.customFlag} +
+ )} +' . __( 'Input fields of type "search" or with the CSS class "search-field" or "ep-autosuggest" will be enhanced with autosuggest functionality. As text is entered into the search field, suggested content will appear below it, based on top search results for the text. Suggestions link directly to the content.', 'elasticpress' ) . '
' . + '' . __( 'Requires an ElasticPress.io plan or a custom proxy to function.', 'elasticpress' ) . '
'; + } + + /** + * Tell user whether requirements for feature are met or not. + * + * @return array $status Status array + */ + public function requirements_status() { + $status = new FeatureRequirementsStatus( 2 ); + + $status->message = []; + + if ( Utils\is_epio() ) { + $status->code = 1; + + /** + * Whether the feature is available for non ElasticPress.io customers. + * + * Installations using self-hosted Elasticsearch will need to implement an API for + * handling search requests before making the feature available. + * + * @since 5.3.0 + * @hook ep_autosuggest_v2_available + * @param {string} $available Whether the feature is available. + */ + } elseif ( apply_filters( 'ep_autosuggest_v2_available', false ) ) { + $status->code = 1; + $status->message[] = esc_html__( 'You are using a custom proxy. Make sure you implement all security measures needed.', 'elasticpress' ); + } else { + $status->message[] = wp_kses_post( __( "To use this feature you need to be an ElasticPress.io customer or implement a custom proxy.", 'elasticpress' ) ); + } + + /** + * Display a warning if ElasticPress is network activated. + */ + if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) { + $status->message[] = wp_kses_post( + sprintf( + /* translators: Article URL */ + __( + 'ElasticPress is network activated. Additional steps are required to ensure AutosuggestV2 works for all sites on the network. See our article on running ElasticPress in network mode for more details.', + 'elasticpress' + ), + 'https://www.elasticpress.io/documentation/article/running-elasticpress-in-a-wordpress-multisite-network-mode/' + ) + ); + } + + return $status; + } + + /** + * Setup feature functionality. + * + * @return void + */ + public function setup() { + add_filter( 'ep_after_update_feature', [ $this, 'after_update_feature' ], 10, 3 ); + add_filter( 'ep_post_mapping', [ $this, 'add_mapping_properties' ] ); + add_filter( 'ep_post_sync_args', [ $this, 'add_post_sync_args' ], 10, 2 ); + add_filter( 'ep_after_sync_index', [ $this, 'epio_save_search_template' ] ); + add_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_save_search_template' ] ); + add_filter( 'ep_bypass_exclusion_from_search', [ $this, 'maybe_bypass_post_exclusion' ], 10, 2 ); + add_action( 'pre_get_posts', [ $this, 'maybe_apply_product_visibility' ] ); + add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_assets' ] ); + } + + /** + * Enqueue our autosuggest script. + */ + public function enqueue_frontend_assets() { + if ( Utils\is_indexing() ) { + return; + } + + wp_enqueue_style( + 'elasticpress-autosuggest-v2', + EP_URL . 'dist/css/autosuggest-v2-styles.css', + Utils\get_asset_info( 'autosuggest-v2-styles', 'dependencies' ), + Utils\get_asset_info( 'autosuggest-v2-styles', 'version' ) + ); + + wp_enqueue_script( + 'elasticpress-autosuggest-v2', + EP_URL . 'dist/js/autosuggest-v2-script.js', + Utils\get_asset_info( 'autosuggest-v2-script', 'dependencies' ), + Utils\get_asset_info( 'autosuggest-v2-script', 'version' ), + true + ); + + wp_set_script_translations( 'elasticpress-autosuggest-v2', 'elasticpress' ); + + /** + * The search API endpoint. + * + * @since 5.3.0 + * @hook ep_autosuggest_v2_search_endpoint + * @param {string} $endpoint Endpoint path. + * @param {string} $index Elasticsearch index. + */ + $api_endpoint = apply_filters( 'ep_autosuggest_v2_search_endpoint', "api/v1/search/posts/{$this->index}", $this->index ); + + wp_localize_script( + 'elasticpress-autosuggest-v2', + 'epAutosuggestV2', + 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, + 'isWooCommerce' => $this->is_woocommerce, + 'locale' => str_replace( '_', '-', get_locale() ), + 'matchType' => $this->settings['match_type'], + 'paramPrefix' => 'ep-', + 'termCount' => $this->settings['term_count'], + 'requestIdBase' => Utils\get_request_id_base(), + 'showSuggestions' => \ElasticPress\Features::factory()->get_registered_feature( 'did-you-mean' )->is_active(), + 'suggestionsBehavior' => $this->settings['search_behavior'], + ) + ); + } + + /** + * Save or delete the search template on ElasticPress.io based on whether + * the AutosuggestV2 feature is being activated or deactivated. + * + * @param string $feature Feature slug + * @param array $settings Feature settings + * @param array $data Feature activation data + * + * @return void + * + * @since 5.3.0 + */ + public function after_update_feature( $feature, $settings, $data ) { + if ( $feature !== $this->slug ) { + return; + } + + if ( true === $data['active'] ) { + $this->epio_save_search_template(); + } else { + $this->epio_delete_search_template(); + } + } + + /** + * Get the endpoint for the AutosuggestV2 search template. + * + * @return string AutosuggestV2 search template endpoint. + */ + public function get_template_endpoint() { + /** + * Filters the search template API endpoint. + * + * @since 5.3.0 + * @hook ep_autosuggest_v2_template_endpoint + * @param {string} $endpoint Endpoint path. + * @param {string} $index Elasticsearch index. + * @returns {string} Search template API endpoint. + */ + return apply_filters( 'ep_autosuggest_v2_template_endpoint', "api/v1/search/posts/{$this->index}/template/", $this->index ); + } + + /** + * Save the search template to ElasticPress.io. + * + * @return void + */ + public function epio_save_search_template() { + $endpoint = $this->get_template_endpoint(); + $template = $this->get_search_template(); + + Elasticsearch::factory()->remote_request( + $endpoint, + [ + 'blocking' => false, + 'body' => $template, + 'method' => 'PUT', + ] + ); + + /** + * Fires after the request is sent the search template API endpoint. + * + * @since 5.3.0 + * @hook ep_autosuggest_v2_template_saved + * @param {string} $template The search template (JSON). + * @param {string} $index Index name. + */ + do_action( 'ep_autosuggest_v2_template_saved', $template, $this->index ); + } + + /** + * Delete the search template from ElasticPress.io. + * + * @return void + * + * @since 5.3.0 + */ + public function epio_delete_search_template() { + $endpoint = $this->get_template_endpoint(); + + Elasticsearch::factory()->remote_request( + $endpoint, + [ + 'blocking' => false, + 'method' => 'DELETE', + ] + ); + + /** + * Fires after the request is sent the search template API endpoint. + * + * @since 5.3.0 + * @hook ep_autosuggest_v2_template_deleted + * @param {string} $index Index name. + */ + do_action( 'ep_autosuggest_v2_template_deleted', $this->index ); + } + + /** + * Get the saved search template from ElasticPress.io. + * + * @return string|WP_Error Search template if found, WP_Error on error. + * + * @since 5.3.0 + */ + public function epio_get_search_template() { + $endpoint = $this->get_template_endpoint(); + $request = Elasticsearch::factory()->remote_request( $endpoint ); + + if ( is_wp_error( $request ) ) { + return $request; + } + + $response = wp_remote_retrieve_body( $request ); + + return $response; + } + + /** + * Generate a search template. + * + * A search template is the JSON for an Elasticsearch query with a + * placeholder search term. The template is sent to ElasticPress.io where + * it's used to make Elasticsearch queries using search terms sent from + * the front end. + * + * @return string The search template as JSON. + */ + public function get_search_template() { + $post_types = Features::factory()->get_registered_feature( 'search' )->get_searchable_post_types(); + $post_statuses = get_post_stati( + [ + 'public' => true, + 'exclude_from_search' => false, + ] + ); + + /** + * The ID of the current user when generating the AutosuggestV2 + * search template. + * + * By default AutosuggestV2 sets the current user as anomnymous when + * generating the search template, so that any filters applied to + * queries for logged-in or specific users are not applied to the + * template. This filter supports setting a specific user as the + * current user while the template is generated. + * + * @since 5.3.0 + * @hook ep_search_template_user_id + * @param {int} $user_id User ID to use. + * @return {int} New user ID to use. + */ + $template_user_id = apply_filters( 'ep_search_template_user_id', 0 ); + $original_user_id = get_current_user_id(); + + wp_set_current_user( $template_user_id ); + + add_filter( 'ep_intercept_remote_request', '__return_true' ); + add_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10, 4 ); + add_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10, 2 ); + + $query = new \WP_Query( + array( + 'ep_integrate' => true, + 'ep_search_template' => true, + 'post_status' => array_values( $post_statuses ), + 'post_type' => $post_types, + 's' => '{{ep_placeholder}}', + ) + ); + + remove_filter( 'ep_intercept_remote_request', '__return_true' ); + remove_filter( 'ep_do_intercept_request', [ $this, 'intercept_search_request' ], 10 ); + remove_filter( 'ep_is_integrated_request', [ $this, 'is_integrated_request' ], 10 ); + + wp_set_current_user( $original_user_id ); + + return $this->search_template; + } + + /** + * Return true if a given feature is supported by AutosuggestV2. + * + * Applied as a filter on Utils\is_integrated_request() so that features + * are enabled for the query that is used to generate the search template, + * regardless of the request type. This avoids the need to send a request + * to the front end. + * + * @param bool $is_integrated Whether queries for the request will be + * integrated. + * @param string $context Context for the original check. Usually the + * slug of the feature doing the check. + * @return bool True if the check is for a feature supported by instant + * search. + */ + public function is_integrated_request( $is_integrated, $context ) { + $supported_contexts = [ + 'autosuggest', + 'documents', + 'search', + 'weighting', + 'woocommerce', + ]; + + return in_array( $context, $supported_contexts, true ); + } + + /** + * Store intercepted request body and return request result. + * + * @param object $response Response + * @param array $query Query + * @param array $args WP_Query argument array + * @param int $failures Count of failures in request loop + * @return object $response Response + */ + public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) { + $this->search_template = $query['args']['body']; + + return wp_remote_request( $query['url'], $args ); + } + + /** + * If generating the search template query, do not bypass the post exclusion + * + * @since 5.3.0 + * @param bool $bypass_exclusion_from_search Whether the post exclusion from search should be applied or not + * @param WP_Query $query The WP Query + * @return bool + */ + public function maybe_bypass_post_exclusion( $bypass_exclusion_from_search, $query ) { + return true === $query->get( 'ep_search_template' ) ? + false : // not bypass, apply + $bypass_exclusion_from_search; + } + + /** + * Apply product visibility taxonomy query to search template queries. + * + * @param \WP_Query $query Query instance. + * @return void + */ + public function maybe_apply_product_visibility( $query ) { + if ( true !== $query->get( 'ep_search_template' ) ) { + return; + } + + if ( ! $this->is_woocommerce ) { + return; + } + + $this->apply_product_visibility( $query ); + } + + /** + * Apply product visibility taxonomy query. + * + * Applies filters to exclude products set to be excluded from search. Out + * of stock products will also be excluded if WooCommerce is configured to + * hide those products. + * + * Mimics the logic of WC_Query::get_tax_query(). + * + * @param \WP_Query $query Query instance. + * @return void + */ + public function apply_product_visibility( $query ) { + $product_visibility_terms = wc_get_product_visibility_term_ids(); + $product_visibility_not_in = (array) $product_visibility_terms['exclude-from-search']; + + if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) { + $product_visibility_not_in[] = $product_visibility_terms['outofstock']; + } + + if ( ! empty( $product_visibility_not_in ) ) { + $tax_query = $query->get( 'tax_query', array() ); + + $tax_query[] = array( + 'taxonomy' => 'product_visibility', + 'field' => 'term_taxonomy_id', + 'terms' => $product_visibility_not_in, + 'operator' => 'NOT IN', + ); + + $query->set( 'tax_query', $tax_query ); + } + } + + /** + * Add additional fields to post mapping. + * + * @param array $mapping Post mapping. + * @return array Post mapping. + */ + public function add_mapping_properties( $mapping ) { + $elasticsearch_version = Elasticsearch::factory()->get_elasticsearch_version(); + + $properties = array( + 'post_content_plain' => array( 'type' => 'text' ), + ); + + if ( version_compare( (string) $elasticsearch_version, '7.0', '<' ) ) { + $mapping['mappings']['post']['properties'] = array_merge( + $mapping['mappings']['post']['properties'], + $properties + ); + } else { + $mapping['mappings']['properties'] = array_merge( + $mapping['mappings']['properties'], + $properties + ); + } + + return $mapping; + } + + /** + * Add data for additional mapping properties. + * + * @param array $post_args Post arguments. + * @param integer $post_id Post ID. + * @return array Post sync args. + */ + public function add_post_sync_args( $post_args, $post_id ) { + $post = get_post( $post_id ); + + $post_args['post_content_plain'] = $this->prepare_plain_content_arg( $post ); + + return $post_args; + } + + + /** + * Get data for the plain post content. + * + * @param WP_Post $post Post object. + * @return string Post content. + */ + public function prepare_plain_content_arg( $post ) { + $post_content = apply_filters( 'the_content', $post->post_content ); + + return wp_strip_all_tags( $post_content ); + } + + /** + * Get schema for search args. + * + * @return array Search args schema. + */ + public function get_args_schema() { + /** + * The number of results per page for AutosuggestV2. + * + * @since 5.3.0 + * @hook ep_autosuggest_v2_per_page + * @param {int} $per_page Results per page. + */ + $per_page = apply_filters( 'ep_autosuggest_v2_per_page', $this->settings['per_page'] ); + + $args_schema = array( + 'offset' => array( + 'type' => 'number', + 'default' => 0, + ), + 'orderby' => array( + 'type' => 'string', + 'default' => 'relevance', + 'allowedValues' => [ 'date', 'price', 'relevance' ], + ), + 'order' => array( + 'type' => 'string', + 'default' => 'desc', + 'allowedValues' => [ 'asc', 'desc' ], + ), + 'per_page' => array( + 'type' => 'number', + 'default' => absint( $per_page ), + ), + 'post_type' => array( + 'type' => 'strings', + ), + 'search' => array( + 'type' => 'string', + 'default' => '', + ), + 'relation' => array( + 'type' => 'string', + 'default' => 'all' === $this->settings['match_type'] ? 'and' : 'or', + 'allowedValues' => [ 'and', 'or' ], + ), + ); + + /** + * The schema defining the API arguments used by AutosuggestV2. + * + * The argument schema is used to configure the APISearchProvider + * component used by AutosuggestV2, and should conform to what is + * supported by the API being used. The AutosuggestV2 UI expects + * the default list of arguments to be available, so caution is advised + * when adding or removing arguments. + * + * @since 5.3.0 + * @hook ep_autosuggest_v2_args_schema + * @param {array} $args_schema Results per page. + */ + return apply_filters( 'ep_autosuggest_v2_args_schema', $args_schema ); + } + + /** + * Set the `settings_schema` attribute + * + * @since 5.3.0 + */ + protected function set_settings_schema() { + + $this->settings_schema = [ + [ + 'default' => get_option( 'posts_per_page', 6 ), + 'key' => 'per_page', + 'type' => 'hidden', + ], + ]; + } +} diff --git a/package.json b/package.json index 865b3c6fe4..f74c1ee080 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "entry": { "admin-script": "./assets/js/admin.js", "autosuggest-script": "./assets/js/autosuggest/index.js", + "autosuggest-v2-script": "./assets/js/autosuggest-v2/index.js", "blocks-script": "./assets/js/blocks/index.js", "comments-script": "./assets/js/comments.js", "comments-block-script": "./assets/js/blocks/comments/index.js", @@ -96,6 +97,7 @@ "woocommerce-order-search-script": "./assets/js/woocommerce/admin/orders/index.js", "autosuggest-styles": "./assets/css/autosuggest.css", + "autosuggest-v2-styles": "./assets/css/autosuggest-v2.css", "comments-styles": "./assets/css/comments.css", "dashboard-styles": "./assets/css/dashboard.css", "facets-block-styles": "./assets/css/facets-block.css", diff --git a/tests/cypress/integration/features/autosuggestv2.cy.js b/tests/cypress/integration/features/autosuggestv2.cy.js new file mode 100644 index 0000000000..83b073b1f3 --- /dev/null +++ b/tests/cypress/integration/features/autosuggestv2.cy.js @@ -0,0 +1,100 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/* global isEpIo */ + +// eslint-disable-next-line jest/valid-describe-callback +describe('Autosuggest V2 Feature', { tags: '@slow' }, () => { + before(() => { + cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + cy.maybeDisableFeature('autosuggest-v2'); + cy.maybeDisableFeature('autosuggest'); + }); + + /** + * Test that the feature cannot be activated when not in ElasticPress.io nor using a custom PHP proxy. + */ + it("Can't activate the feature if not in ElasticPress.io nor using a custom PHP proxy", () => { + if (isEpIo) { + return; + } + + cy.visitAdminPage('admin.php?page=elasticpress'); + + cy.contains('button', 'Live Search').click(); + cy.contains('button', 'Autosuggest V2').click(); + cy.contains('.components-notice', 'To use this feature you need').should('exist'); + cy.get('.components-form-toggle__input').should('be.disabled'); + }); + + describe('Autosuggest V2 enabled', () => { + before(() => { + if (!isEpIo) { + cy.activatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + } + cy.maybeEnableFeature('autosuggest-v2'); + cy.wpCli('wp elasticpress sync'); + }); + + after(() => { + cy.maybeDisableFeature('autosuggest-v2'); + if (!isEpIo) { + cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + } + }); + + it('Reports as enabled', () => { + /** Visit the feature */ + cy.visitAdminPage('admin.php?page=elasticpress'); + cy.contains('button', 'Live Search').click(); + cy.contains('button', 'Autosuggest V2').click(); + + if (!isEpIo) { + cy.get('.components-notice').should( + 'contain.text', + 'You are using a custom proxy.', + ); + } + + cy.get('.components-toggle-control input:checked').should('exist'); + cy.get('.components-toggle-control input:not(:checked)').should('not.exist'); + }); + + /** + * Test that the feature works after being activated + */ + it('Displays autosuggestions after being enabled', () => { + cy.intercept({ url: /search=[^&]*/, method: 'GET' }).as('apiRequest'); + + cy.visit('/'); + + cy.get('.wp-block-search__input').type('Markup: HTML Tags and Formatting'); + + cy.wait('@apiRequest'); + + cy.get('.ep-autosuggest').should(($autosuggestList) => { + // eslint-disable-next-line no-unused-expressions + expect($autosuggestList).to.be.visible; + expect($autosuggestList[0].innerText).to.contains( + 'Markup: HTML Tags and Formatting', + ); + }); + }); + }); + + describe('Autosuggest V2 Disabled', () => { + before(() => { + // This block already ensures its desired state + cy.maybeDisableFeature('autosuggest-v2'); + cy.deactivatePlugin('autosuggestv2-proxy-plugin', 'wpCli'); + }); + it('Can be disabled', () => { + cy.visitAdminPage('admin.php?page=elasticpress'); + cy.contains('button', 'Live Search').click(); + cy.contains('button', 'Autosuggest V2').click(); + + cy.get('.components-toggle-control input:checked').should('not.exist'); + cy.get('.components-toggle-control input:not(:checked)').should('exist'); + + cy.contains('button', 'Save changes').click(); + }); + }); +}); diff --git a/tests/cypress/integration/features/interface.cy.js b/tests/cypress/integration/features/interface.cy.js index 7abc765bdc..94fb0e2a46 100644 --- a/tests/cypress/integration/features/interface.cy.js +++ b/tests/cypress/integration/features/interface.cy.js @@ -34,6 +34,7 @@ describe('Feature Grouping and Persistence', () => { .then(() => { // eslint-disable-next-line cypress/unsafe-to-chain-command cy.get('button[id*="autosuggest"]') + .first() .click() .then(() => { // Verify the feature is active diff --git a/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy-plugin.php b/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy-plugin.php new file mode 100644 index 0000000000..5f1424e067 --- /dev/null +++ b/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy-plugin.php @@ -0,0 +1,72 @@ +get( 'post' )->get_index_name(); + $post_index_url = trailingslashit( Utils\get_host( true ) ) . $post_index; + + require_once ABSPATH . '/wp-admin/includes/file.php'; + WP_Filesystem(); + + $file_content = array( + 'put_contents( + trailingslashit( $uploads_dir['basedir'] ) . 'ep-custom-proxy-credentials.php', + $file_content + ); +} +add_action( 'ep_autosuggest_v2_template_saved', __NAMESPACE__ . '\save_template' ); + +/** + * Set the custom proxy as the search endpoint. + * + * @return string + */ +function set_proxy() { + return plugin_dir_url( __FILE__ ) . 'autosuggestv2-proxy.php'; +} +add_filter( 'ep_autosuggest_v2_search_endpoint', __NAMESPACE__ . '\set_proxy' ); diff --git a/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php b/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php new file mode 100644 index 0000000000..79879af197 --- /dev/null +++ b/tests/cypress/wordpress-files/test-plugins/autosuggestv2-proxy.php @@ -0,0 +1,477 @@ +query = $query_template; + $this->post_index_url = $post_index_url; + + $this->build_query(); + $this->make_request(); + $this->return_response(); + } + + /** + * Build the query to be sent, i.e., get the template and make all necessary replaces/changes. + */ + protected function build_query() { + // For the next replacements, we'll need to work with an object + $this->query = json_decode( $this->query, true ); + + $this->set_search_term(); + $this->set_pagination(); + $this->set_order(); + + $this->relation = ( ! empty( $_REQUEST['relation'] ) ) ? $this->sanitize_string( $_REQUEST['relation'] ) : 'or'; + $this->relation = ( 'or' === $this->relation ) ? $this->relation : 'and'; + + $this->handle_post_type_filter(); + $this->handle_taxonomies_filters(); + $this->handle_price_filter(); + + $this->apply_filters(); + + $this->query = json_encode( $this->query ); + } + + /** + * Set the search term in the query. + */ + protected function set_search_term() { + $search_term = $this->sanitize_string( $_REQUEST['search'] ); + + // Stringify the JSON object again just to make the str_replace easier. + if ( ! empty( $search_term ) ) { + $query_string = json_encode( $this->query ); + $query_string = str_replace( '{{ep_placeholder}}', $search_term, $query_string ); + $this->query = json_decode( $query_string, true ); + return; + } + + // If there is no search term, get everything. + $this->query['query'] = array( 'match_all' => array( 'boost' => 1 ) ); + } + + /** + * Set the pagination. + */ + protected function set_pagination() { + // Pagination + $per_page = $this->sanitize_number( $_REQUEST['per_page'] ); + $offset = $this->sanitize_number( $_REQUEST['offset'] ); + if ( $per_page && $per_page > 1 ) { + $this->query['size'] = $per_page; + } + if ( $offset && $offset > 1 ) { + $this->query['from'] = $offset; + } + } + + /** + * Set the order. + */ + protected function set_order() { + $orderby = $this->sanitize_string( $_REQUEST['orderby'] ); + $order = $this->sanitize_string( $_REQUEST['order'] ); + + $order = ( 'desc' === $order ) ? $order : 'asc'; + + $sort_clause = array(); + + switch ( $orderby ) { + case 'date': + $sort_clause['post_date'] = array( 'order' => $order ); + break; + + case 'price': + $sort_clause['meta._price.double'] = array( + 'order' => $order, + 'mode' => ( 'asc' === $order ) ? 'min' : 'max', + ); + break; + + case 'rating': + $sort_clause['meta._wc_average_rating.double'] = array( 'order' => $order ); + break; + } + + if ( ! empty( $sort_clause ) ) { + $this->query['sort'] = array( $sort_clause ); + } + } + + /** + * Add post types to the filters. + */ + protected function handle_post_type_filter() { + $post_types = ( ! empty( $_REQUEST['post_type'] ) ) ? explode( ',', $_REQUEST['post_type'] ) : array(); + $post_types = array_filter( array_map( array( $this, 'sanitize_string' ), $post_types ) ); + if ( empty( $post_types ) ) { + return; + } + + if ( 'or' === $this->relation ) { + $this->filters['post_type'] = array( + 'terms' => array( + 'post_type.raw' => $post_types, + ), + ); + return; + } + + $terms = array(); + foreach ( $post_types as $post_type ) { + $terms[] = array( + 'term' => array( + 'post_type.raw' => $post_type, + ), + ); + } + + $this->filters['post_type'] = array( + 'bool' => array( + 'must' => $terms, + ), + ); + } + + /** + * Add taxonomies to the filters. + */ + protected function handle_taxonomies_filters() { + $taxonomies = array(); + $tax_relations = ( ! empty( $_REQUEST['term_relations'] ) ) ? (array) $_REQUEST['term_relations'] : array(); + foreach ( (array) $_REQUEST as $key => $value ) { + if ( ! preg_match( '/^tax-(\S+)$/', $key, $matches ) ) { + continue; + } + + if ( empty( $value ) ) { + continue; + } + + $taxonomy = $matches[1]; + + $relation = ( ! empty( $tax_relations[ $taxonomy ] ) ) ? + $this->sanitize_string( $tax_relations[ $taxonomy ] ) : + $this->relation; + + $taxonomies[ $matches[1] ] = array( + 'relation' => $relation, + 'terms' => array_map( array( $this, 'sanitize_number' ), explode( ',', $value ) ), + ); + } + + if ( empty( $taxonomies ) ) { + return; + } + + foreach ( $taxonomies as $taxonomy_slug => $taxonomy ) { + if ( 'or' === $this->relation ) { + $this->filters[ $taxonomy_slug ] = array( + 'terms' => array( + "terms.{$taxonomy_slug}.term_id" => $taxonomy['terms'], + ), + ); + return; + } + + $terms = array(); + foreach ( $taxonomy['terms'] as $term ) { + $terms[] = array( + 'term' => array( + "terms.{$taxonomy_slug}.term_id" => $term, + ), + ); + } + + $this->filters[ $taxonomy_slug ] = array( + 'bool' => array( + 'must' => $terms, + ), + ); + } + } + + /** + * Add price ranges to the filters. + */ + protected function handle_price_filter() { + $min_price = ( ! empty( $_REQUEST['min_price'] ) ) ? $this->sanitize_string( $_REQUEST['min_price'] ) : ''; + $max_price = ( ! empty( $_REQUEST['max_price'] ) ) ? $this->sanitize_string( $_REQUEST['max_price'] ) : ''; + + if ( $min_price ) { + $this->filters['min_price'] = array( + 'range' => array( + 'meta._price.double' => array( + 'gte' => $min_price, + ), + ), + ); + } + + if ( $max_price ) { + $this->filters['max_price'] = array( + 'range' => array( + 'meta._price.double' => array( + 'lte' => $max_price, + ), + ), + ); + } + } + + /** + * Add filters to the query. + */ + protected function apply_filters() { + $occurrence = ( 'and' === $this->relation ) ? 'must' : 'should'; + + $existing_filter = ( ! empty( $this->query['post_filter'] ) ) ? $this->query['post_filter'] : array( 'match_all' => array( 'boost' => 1 ) ); + + if ( ! empty( $this->filters ) ) { + $this->query['post_filter'] = array( + 'bool' => array( + 'must' => array( + $existing_filter, + array( + 'bool' => array( + $occurrence => array_values( $this->filters ), + ), + ), + ), + ), + ); + } + + /** + * If there's no aggregations in the template or if the relation isn't 'and', we are done. + */ + if ( empty( $this->query['aggs'] ) || 'and' !== $this->relation ) { + return; + } + + /** + * Apply filters to aggregations. + * + * Note the usage of `&agg` (passing by reference.) + */ + foreach ( $this->query['aggs'] as $agg_name => &$agg ) { + $new_filters = array(); + + /** + * Only filter an aggregation if there's sub-aggregations. + */ + if ( empty( $agg['aggs'] ) ) { + continue; + } + + /** + * Get any existing filter, or a placeholder. + */ + $existing_filter = $agg['filter'] ?? array( 'match_all' => array( 'boost' => 1 ) ); + + /** + * Get new filters for this aggregation. + * + * Don't apply a filter to a matching aggregation if the relation is 'or'. + */ + foreach ( $this->filters as $filter_name => $filter ) { + // @todo: this relation should not be the global one but the relation between aggs. + if ( $filter_name === $agg_name && 'or' === $this->relation ) { + continue; + } + + $new_filters[] = $filter; + } + + /** + * Add filters to the aggregation. + */ + if ( ! empty( $new_filters ) ) { + $agg['filter'] = array( + 'bool' => array( + 'must' => array( + $existing_filter, + array( + 'bool' => array( + $occurrence => $new_filters, + ), + ), + ), + ), + ); + } + } + } + + /** + * Make the cURL request. + */ + protected function make_request() { + $http_headers = array( 'Content-Type: application/json' ); + $endpoint = $this->post_index_url . '/_search'; + + // Create the cURL request. + $this->request = curl_init( $endpoint ); + + curl_setopt( $this->request, CURLOPT_POSTFIELDS, $this->query ); + + curl_setopt_array( + $this->request, + array( + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HEADER => true, + CURLOPT_RETURNTRANSFER => true, + CURLINFO_HEADER_OUT => true, + CURLOPT_HTTPHEADER => $http_headers, + ) + ); + + $this->response = curl_exec( $this->request ); + } + + /** + * Format and output the response from Elasticsearch. + */ + protected function return_response() { + // Fetch all info from the request. + $header_size = curl_getinfo( $this->request, CURLINFO_HEADER_SIZE ); + $response_header = substr( $this->response, 0, $header_size ); + $response_body = substr( $this->response, $header_size ); + $response_info = curl_getinfo( $this->request ); + $response_code = $response_info['http_code'] ?? 500; + $response_headers = preg_split( '/[\r\n]+/', $response_info['request_header'] ?? '' ); + if ( 0 === $response_code ) { + $response_code = 404; + } + + curl_close( $this->request ); + + // Respond with the same headers, content and status code. + + // Split header text into an array. + $response_headers = preg_split( '/[\r\n]+/', $response_header ); + // Pass headers to output + foreach ( $response_headers as $header ) { + // Pass following headers to response + if ( preg_match( '/^(?:Content-Type|Content-Language|Content-Security|X)/i', $header ) ) { + header( $header ); + } elseif ( strpos( $header, 'Set-Cookie' ) !== false ) { + // Replace cookie domain and path + $header = preg_replace( '/((?>domain)\s*=\s*)[^;\s]+/', '\1.' . $_SERVER['HTTP_HOST'], $header ); + $header = preg_replace( '/\s*;?\s*path\s*=\s*[^;\s]+/', '', $header ); + header( $header, false ); + } elseif ( 'Content-Encoding: gzip' === $header ) { + // Decode response body if gzip encoding is used + $response_body = gzdecode( $response_body ); + } + } + + http_response_code( $response_code ); + exit( $response_body ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Utilitary function to sanitize string. + * + * @param string $str String to be sanitized + * @return string + */ + protected function sanitize_string( $str ) { + return htmlspecialchars( $str ); + } + + /** + * Utilitary function to sanitize numbers. + * + * @param string $str Number to be sanitized + * @return string + */ + protected function sanitize_number( $str ) { + return filter_var( $str, FILTER_SANITIZE_NUMBER_INT ); + } +} + +$ep_as_php_proxy = new EP_AS_PHP_Proxy(); +$ep_as_php_proxy->proxy();